-
코틀린(kotlin) - 자동차 경주 게임프로젝트/Kotlin + TDD 2022. 10. 18. 00:01반응형
개요
코틀린과 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
랜덤에 대한 테스트는 어떻게 이루어 져야 하는가?
테스트 코드를 작성하다 보면, 랜덤하게 발생하는 경우에 대하여 테스트를 진행할 경우가 생긴다. 이러한 상황에 봉착했을 때 우리는 어떻게 테스트 하는게 좋을까?? 우아한 테크 코스(이하 우
bperhaps.tistory.com
'프로젝트 > Kotlin + TDD' 카테고리의 다른 글
코틀린(kotlin) - 문자열계산기 (0) 2023.09.03 코틀린(kotlin) - 숫자 야구 게임 (0) 2022.10.22 코틀린 프로젝트 시작하기 (0) 2022.10.16