ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 코틀린(kotlin) - 자동차 경주 게임
    프로젝트/Kotlin + TDD 2022. 10. 18. 00:01
    728x90

    개요

    코틀린과 TDD에 친숙해보고자 자동차 경주 게임을 구현해보고자 합니다.

     

    요구사항 정리

    - 참여자 이름을 받아 자동차를 생성

    - 이름은 5자 이하, 쉼표로 구분

    - 횟수를 입력받아 횟수만큼 자동차 전진

    - 자동차는 0~9의 랜덤 값이 4 이상일 경우 전진

    - 우승자 출력(복수 가능)

     

    README.md

    ### 요구 사항
    - [ ] 참여자 이름을 받아 자동차를 생성
    - [ ] 이름은 5자 이하, 쉼표로 구분
    - [ ] 자동차는 0~9의 랜덤값이 4이상일 경우 전진
    - [ ] 횟수를 입력받아 횟수만큼 자동차 전진
    - [ ] 우승자 출력(복수 가능)

     

     

    우선 "참여자의 이름을 받아 자동차를 생성한다" 구현을 해보겠습니다.

    1단계 : 실패하는 테스트 만들기

        @Test
        fun `참여자 이름으로 차를 생성`(){
            //given
            val participantName = "junuu"
    
            //when
            val car = Car(participantName = participantName)
    
            //then
            Assertions.assertNotNull(car)
            Assertions.assertEquals(car.participantName, participantName)
        }

    컴파일조차 되지 않는 코드입니다.

     

    2단계 : 테스트가 성공하도록 구현하기

    class Car(
        val participantName : String,
    ) {
    
    }

     

    3단계 : 리팩터링  (junuu대신 다른 이름도 제대로 생성되는지 확인하기 위해 @ParameterizedTest 활용)

        @ParameterizedTest
        @ValueSource(strings = ["junuu","hong"])
        fun `참여자 이름으로 차를 생성`(input: String){
            //given
    
            //when
            val car = Car(participantName = input)
    
            //then
            Assertions.assertNotNull(car)
            Assertions.assertEquals(car.participantName, input)
        }

     

    "이름은 5자이하이다"를 구현하겠습니다.

    1단계 : 실패하는 테스트 작성

        @ParameterizedTest
        @ValueSource(strings = ["junuu1","hong35"])
        fun `참여자의 이름은 5자 이하여야 한다`(input: String){
            //given
    
            //when, then
            Assertions.assertThrows(IllegalArgumentException::class.java){
                Car(participantName = input)
            }
        }

     

    2단계 : 테스트가 성공하도록 구현하기

    class Car(
        val participantName : String,
    ) {
        init{
            require(participantName.length <=5){
                throw IllegalArgumentException("이름은 5자 이하여야 합니다.")
            }
        }
    }

     

    3단계 : 리팩터링 (매직넘버인 5를 없애려고 합니다.)

    const val NAME_LENGTH_VALIDATION = 5
    
    class Car(
        val participantName: String,
    ) {
        init {
            require(participantName.length <= NAME_LENGTH_VALIDATION) {
                throw IllegalArgumentException("이름은 5자 이하여야 합니다.")
            }
        }
    }

     

    "이름은 쉼표로 구분하기"를 구현하겠습니다.

    1단계 : 실패하는 테스트 작성

    class InputTest {
        @Test
        fun `이름은 쉼표로 구분한다`() {
            //given
            val inputNames = "junuu,hong,ggam,chong"
    
            //when
            val input = Input(inputNames)
    
            //then
            Assertions.assertEquals(input.result.size, 4)
            Assertions.assertEquals(input.result, listOf("junuu", "hong", "ggam", "chong"))
    
        }
    }

     

    2단계 : 테스트가 성공하도록 구현

    class Input(
        private val inputNames : String,
    ) {
        val result : List<String> = inputNames.split(",")
    }

     

    3단계 : 리팩터링 (사용하지 않는 private val inputNames를 제거하여 value-parameter로 변경)

    class Input(
        inputNames : String,
    ) {
        val result : List<String> = inputNames.split(",")
    }

     

    추가 리팩터링 (굳이 Input 클래스를 써야 할까?라는 생각에 그냥 메서드로 구현했습니다.)

    fun input(inputNames : String) = inputNames.split(",")

     

    하지만 추후에 테스트를 하기 위해 실제로 사용자에게 입력받는 값이 필요했습니다.

    따라서 아래에서 랜덤값을 테스트하는 것처럼 interface를 만들고  inputForTest 클래스로 테스트하려고 합니다.

     

    "자동차는 0~9의 랜덤값이 4 이상일 경우 전진"을 구현하겠습니다.

    1단계 : 실패하는 테스트 작성

    class MoveStrategyTest{
    
        @ParameterizedTest
        @ValueSource(ints = [4,5,6,7,8,9])
        fun `0~9사이의 랜덤값이 주어지는데 4이상이면 전진한다`(input : Int){
            //given
            val moveStrategy = MoveStrategy()
    
            //when
            val result = moveStrategy.isMove(input)
    
            //then
            Assertions.assertTrue(result)
        }
    
        @ParameterizedTest
        @ValueSource(ints = [0,1,2,3])
        fun `0~9사이의 랜덤값이 주어지는데 4미만이면 전진하지 않는다`(input : Int){
            //given
            val moveStrategy = MoveStrategy()
    
            //when
            val result = moveStrategy.isMove(input)
    
            //then
            Assertions.assertFalse(result)
        }
    }

     

    2단계 : 테스트가 성공하도록 구현

    import kotlin.random.Random
    
    const val MOVE_THRESHOLD = 4
    
    class MoveStrategy {
    
        fun makeRandomNumber() = Random.nextInt(0,9)
    
        fun isMove(number: Int): Boolean {
            if (number < MOVE_THRESHOLD)
                return false
            return true
        }
    }

     

    3단계 : 리팩토링 (SecureRandom 예측 불가능한 랜덤 숫자를 생성합니다 + 싱글톤)

    import java.security.SecureRandom
    
    const val MOVE_THRESHOLD = 4
    
    class MoveStrategy {
    
        companion object {
            private val secureRandom = SecureRandom()
        }
    
        fun makeRandomNumber() = secureRandom.nextInt(9)
    
        fun isMove(number: Int): Boolean {
            if (number < MOVE_THRESHOLD)
                return false
            return true
        }
    }

     

    여기서 한가지 고민이 있었습니다

    "랜덤 값 어떻게 테스트할 거지?"

     

    - 첫 번째 랜덤 값의 범위에 대한 의문

    반복문을 돌려 랜덤 값 자체를 테스트하는 것조차 확률이 의존하는 테스트이며 랜덤 값에 대한 의문 자체는 라이브러리에 대한 도전으로 생각하고 그냥 믿기로 함

     

    - 두 번째 0~9 범위의 랜덤 값이 나오긴 하는데 이 값을 어떻게 예측할 수 있을까?

    평소에 외부 라이브러리에 적용하던 Mocking 방식이 가장 먼저 생각났지만 구글링을 하던 중 Random을 interface로 만들고 테스트를 위한 구현체를 직접 만들어서 테스트하는 방법이 있어 적용해보려고 합니다.

     

    RandomUtil 인터페이스

    interface RandomUtil {
        fun generateNumber() : Int
    }

     

    Test

    import org.junit.jupiter.api.Assertions
    import org.junit.jupiter.api.Test
    
    class RandomTest {
        @Test
        fun `랜덤값이 올바르게 나오는지 테스트`() {
            //given
            val random = RandomForTest()
    
            //when
            val result = random.generateNumber()
    
            //then
            Assertions.assertEquals(result, 3)
        }
    }
    
    class RandomForTest : RandomUtil {
        override fun generateNumber(): Int {
            return 3
        }
    }

     

    이후에는 RandomUtil 인터페이스를 구현하는 Random 클래스를 구현하고 MoveStrategy 테스트 부분에 Random 클래스의 구현체를 넣어줬습니다.

    const val UPPER_BOUND = 9
    
    class Random : RandomUtil {
    
        companion object {
            private val secureRandom = SecureRandom()
        }
    
        override fun generateNumber(): Int {
            return secureRandom.nextInt(UPPER_BOUND)
        }
    }

     

     

    마지막으로 "우승자 출력(복수 가능)"을 구현해보겠습니다.

    1단계 : 실패하는 테스트 작성

    class WinnerTest {
    
        @Test
        fun `position이 가장 높은 차가 우승한다`(){
            //given
            val hong = Car(
                participantName = "hong",
                position =  4,
            )
            val junuu = Car(
                participantName = "chong",
                position = 1,
            )
            val input = listOf(junuu, hong)
            val winnerSearch = WinnerSearch(input)
    
            //when
            val result = winnerSearch.findWinner()
    
            //then
            Assertions.assertEquals(result, listOf("hong"))
        }
    
        @Test
        fun `우승자는 여러명일 수 있다`() {
            //given
            val junuu = Car(
                participantName = "junuu",
                position = 2,
            )
            val hong = Car(
                participantName = "hong",
                position =  2,
            )
            val chong = Car(
                participantName = "chong",
                position = 1,
            )
    
            val input = listOf(junuu, hong, chong)
            val winnerSearch = WinnerSearch(input)
    
            //when
            val result = winnerSearch.findWinner()
    
            //then
            Assertions.assertEquals(result, listOf("junuu","hong"))
    
        }
    }

     

    2단계 : 테스트가 성공하도록 구현

    class WinnerSearch(
        private val inputCars: List<Car>
    ) {
    
        fun findWinner(): List<String> {
            val maxValue = inputCars.map {
                it.position
            }.maxOrNull()
    
            return inputCars
                .filter { it.position == maxValue }
                .map { it.participantName }
                .toList()
        }
    }

     

    현재는 기능별로만 각각 구현했기 때문에 이제 조립을 시작해야 합니다.

    객체들이 잘 협력할 수 있도록 하며 요구사항이 변경되었을 때 변경이 적도록 하는 것이 목표입니다.

     

    위의 목표를 잘 지키기 위해서 고민한 점들

    - 사용자에게 보이는 부분과 핵심 로직을 분리하자 ( View와 Logic의 분리)

    이유는 View는 요구사항에 따라 계속 달라질 수 있다.

    따라서 이를 분리하여 사용자에게 보이는 부분이 변경되면 View만 변경하고, 자동차의 우승자를 가리는 방식이 달라지게 된다면 Logic 부분만 변경하도록 합니다.

     

    - 변경될 수 있는 포인트들을 생각하고 분리해보자

    움직일 수 있는 전략이 변경될 수 있기 때문에 MoveStrategy 클래스를 사용하며 랜덤 값의 범위도 변경될 수 있기 때문에 RandomUtil 인터페이스로 이를 관리합니다.

     

    따라서 변경이 일어나면 해당 클래스로 바로 이동해서 변경하도록 합니다.

    또한 패키지 관리를 통해서 한눈에 이동할 수 있도록 합니다

     

    완성된 코드 - Github 주소

    https://github.com/Junuu/RacingCar-TDD

     

    GitHub - Junuu/RacingCar-TDD: 자동차 경주 게임을 코틀린 + TDD로 구현해보기

    자동차 경주 게임을 코틀린 + TDD로 구현해보기. Contribute to Junuu/RacingCar-TDD development by creating an account on GitHub.

    github.com

     

    다른 분들의 코드를 보고 배워보자

    우아한 테크 코스에서 kotlin-racingcar를 구현한 분들의 코드를 살펴보고 좋다고 생각하는 부분이 있으면 배워보려고 합니다.

    https://github.com/woowacourse/kotlin-racingcar/pulls

     

     

    개선해야 할 부분

    - 사용자에게 input을 받은 후 5글자 이상으로 인해 Exception이 발생했을 때 처리하는 로직을 넣어주면 좋을 것 같음(예외 발생 시 프로그램이 죽지 않게)

     

    - companion object를 클래스의 마지막에 넣어주기 (코틀린의 Coding convention)

     

    - Car 클래스에 move메서드를 만드려다가 RunGame 클래스에서 랜덤 값을 생성하고 position 변수에 접근하여 ++ 시켰는데 Car 클래스로 move 역할을 위임해서 내부적인 캡슐화도 지키며 position 변수에도 접근하지 않도록 변경하면 좋을 것 같음(캡슐화와 private set 활용)

     

    - Cars 클래스 도입에 대한 고민

      private fun inputUserNamesAndMakeCarList(): List<Car> {
            val splitName = view.printInputInfo()
            val carList = mutableListOf<Car>()
            splitName.forEach {
                carList.add(Car(participantName = it))
            }
            return carList
        }

    현재 사용자로부터 입력을 받고 carList를 생성하는데 List <Car>을 멤버 변수로 가지는 Cars클래스 도입에 대한 고민

     

    - input시 readline() 메서드라는 것을 알게 되어 적용

     

    - 사용자의 이름이 중복될 경우 Exception

     

     

    리팩터링 후 프로젝트의 의존성

     

    느낀 점

    금방 구현할 줄 알았는데 생각보다 오래 걸렸습니다.

     

    또한 TDD로 진행하려고 했지만 실제 로직을 먼저 작성하게 되는 부분들도 있었습니다.

    TDD의 목적이 테스트 코드를 작성하기 쉽게 로직을 작성하기 위함이라고 생각하는데 

     

    코드를 작성하면서 너무 과도하게 분리한 건가?라는 생각이 들긴 합니다.

    하지만 변경이 될 부분을 생각한다면 또 괜찮은 것 같습니다.

     

    실제로 리팩터링을 진행하면서 적절하게 역할이 분리되었는지도 생각하며 리팩터링이 일어나는 부분만 변경하면 문제없이 코드가 돌아가는 것을 확인했습니다.

     

     

     

     

     

     

    출처

    https://gmlwjd9405.github.io/2019/11/27/junit5-guide-parameterized-test.html

     

    [JUnit] JUnit5 사용법 - Parameterized Tests - Heee's Development Blog

    Step by step goes a long way.

    gmlwjd9405.github.io

    https://github.com/woowacourse/kotlin-racingcar/pulls

     

    GitHub - woowacourse/kotlin-racingcar

    Contribute to woowacourse/kotlin-racingcar development by creating an account on GitHub.

    github.com

    https://www.slipp.net/questions/559

     

    객체를 객체스럽게 사용하도록 리팩토링해라.

    자바 개발자들은 상태를 가지는 객체를 자주 만든다. 그런데 이 객체를 객체스럽게 사용하지 못하는 경우를 종종 본다. 오늘 리뷰할 코드는 초보 개발자 뿐만 아니라 현업에 있는 개발자들 또한

    www.slipp.net

    https://bperhaps.tistory.com/entry/%EB%9E%9C%EB%8D%A4%EC%97%90-%EB%8C%80%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9D%B4%EB%A3%A8%EC%96%B4-%EC%A0%B8%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80

     

    랜덤에 대한 테스트는 어떻게 이루어 져야 하는가?

    테스트 코드를 작성하다 보면, 랜덤하게 발생하는 경우에 대하여 테스트를 진행할 경우가 생긴다. 이러한 상황에 봉착했을 때 우리는 어떻게 테스트 하는게 좋을까?? 우아한 테크 코스(이하 우

    bperhaps.tistory.com

     

    댓글

Designed by Tistory.