-
코틀린(kotlin) - 숫자 야구 게임프로젝트/Kotlin + TDD 2022. 10. 22. 00:01
개요
코틀린과 TDD에 친숙해지고자 자동차 경주 게임에 이어 숫자 야구 게임을 구현해 보겠습니다.
요구 사항 정리
- 랜덤으로 1~9짜리 서로 다른 3개의 수를 생성한다.
- 사용자에게 수를 입력받는다.
- 입력받은 수를 검증한다.
- 입력받은 3자리 수에서 볼, 스트라이크 개수를 구해서 반환한다.
- 구해진 볼, 스트라이크를 통해 출력 값을 결정한다.
- 스트라이크, 볼 0개 : "낫싱"
- 스트라이크 0~2개, 볼 0개 아님 : "n볼 n스트라이크"
- 스트라이크 3개 : "3 스트라이크"
- 정답 문구 출력 : "3개의 숫자를 모두 맞히셨습니다! 게임 종료"
- 스트라이크 3개가 나올 때까지 2~5 과정을 반복한다.
README.md
### 요구 사항 - [ ] 랜덤으로 1~9까지의 숫자 3개 생성 - [ ] 사용자에게 3자리 숫자를 입력받는다. 입력받은 수는 1~9사이의 수여야만 한다. - [ ] 입력받은 3자리 수로 Strike, Ball을 판별한다 - [ ] 구해진 Strike, Ball을 사용자에게 보여준다. - [ ] 스트라이크가 3개 나올때까지 반복한다.
첫 번째로 랜덤으로 1~9까지의 숫자 3개를 생성하는 기능을 구현하겠습니다.
1단계 : 실패하는 테스트 만들기
package random import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test class RandomTest { @Test fun `랜덤으로 1~9까지의 숫자가 생성된다`() { //given val generateRandomNumber = GenerateRandomNumberForTest(3) //when val result = generateRandomNumber.makeRandomNumber() //then Assertions.assertEquals(result, 3) } @Test fun `1~9까지의 수가 아니라면 IllegalArgumentException()이 발생한다`() { //given val generateRandomNumber = GenerateRandomNumberForTest(10) //when, then Assertions.assertThrows(IllegalArgumentException::class.java) { generateRandomNumber.makeRandomNumber() } } class GenerateRandomNumberForTest( private val number: Int, ) : RandomUtil { override fun makeRandomNumber(): Int { if(number <LOW_BOUND || number > UPPER_BOUND){ throw IllegalArgumentException() } return number } companion object{ const val LOW_BOUND = 1 const val UPPER_BOUND = 9 } } }
2단계 : 테스트가 성공하도록 구현
interface RandomUtil { fun makeRandomNumber() : Int }
테스트는 성공하지만 실제 구현 로직은 아무것도 구현된 것이 없습니다.
따라서 실제 구현 로직을 구현하지만 이는 테스트와 무관하다고 생각이 들긴 합니다.
package random import java.security.SecureRandom class GenerateRandomNumber : RandomUtil { override fun makeRandomNumber(): Int { val number = generateRandomNumber(LOW_BOUND, UPPER_BOUND) validation(number) return number } private fun generateRandomNumber(from: Int, to: Int) = secureRandom.nextInt(to - from) + from private fun validation(number: Int) { if (number < LOW_BOUND || number > UPPER_BOUND) { throw IllegalArgumentException() } } companion object { const val LOW_BOUND = 1 const val UPPER_BOUND = 9 private val secureRandom = SecureRandom() } }
3단계 : 리팩터링
makeRandomNumber의 이름을 다시 보았을 때 조금 어색함이 느껴졌습니다.
실제로 validation까지 하기 때문에 genRandomNumberAndValidation()으로 변경하였습니다.
Random값이 만들어내는 수의 검증은 RandomUtil 구현체에서 이루어져야 지는 것이 적합하다고 판단하여 다음과 같은 리팩터링을 거쳤습니다.
두 번째로 사용자에게 3자리 숫자를 입력받으며, 입력받은 수는 1~9 사이의 수여야만 한다를 구현하겠습니다.
1단계 : 실패하는 테스트 만들기
package domain import input.InputUtil import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource class UserTest { @Test fun `사용자는 임의의 3개의 수를 입력할 수 있다`() { //given val user = User(InputForTest()) //when val result = user.inputNumber() //then Assertions.assertEquals(result, "123") } @ParameterizedTest @ValueSource(strings = ["135", "459", "562"]) fun `1~9사이의 임의의 숫자 3개는 유효성 검사에 통과한다`(input: String) { //given val user = User(InputForTest()) Assertions.assertDoesNotThrow() { //when user.validation(input) } } @ParameterizedTest @ValueSource(strings = ["190","900","350","407"]) fun `0을 입력한 경우에는 유효성 검사에 통과하지 못한다`(input: String){ //given val user = User(InputForTest()) //then Assertions.assertThrows(IllegalArgumentException::class.java){ //when user.validation(input) } } @ParameterizedTest @ValueSource(strings = ["1234","5678","9999","15487165","12","1"]) fun `3자리가 아닌 경우를 입력한 경우에는 유효성 검사에 통과하지 못한다`(input: String){ //given val user = User(InputForTest()) //then Assertions.assertThrows(IllegalArgumentException::class.java){ //when user.validation(input) } } @ParameterizedTest @ValueSource(strings = ["som","56a","99!","1 3"]) fun `숫자가 아닌 문자를 입력한 경우 유효성 검사에 통과하지 못한다`(input: String){ //given val user = User(InputForTest()) //then Assertions.assertThrows(IllegalArgumentException::class.java){ //when user.validation(input) } } class InputForTest : InputUtil { override fun inputNumber(): String { return "123" } } }
@ParameterizedTest를 사용하기 위해서는 gradle 의존성을 추가해야 합니다.
2단계 : 테스트가 성공하도록 구현
package domain import input.InputUtil class User( private val inputNumber: InputUtil ) { fun inputNumber(): String { return inputNumber.inputNumber() } fun validation(input: String) { if (input.length != VALIDATION_INPUT_SIZE) { throw IllegalArgumentException() } if (input.contains(ZERO)) { throw IllegalArgumentException() } if (!isNumeric(input)) { throw IllegalArgumentException() } } private fun isNumeric(toCheck: String): Boolean { return toCheck.all { char -> char.isDigit() } } companion object { const val VALIDATION_INPUT_SIZE = 3 const val ZERO = "0" } }
3단계 : 리팩터링
사용자가 음수를 입력할 수 있다고 생각하여 음수에 관련한 테스트도 추가해 주었습니다.
@ParameterizedTest @ValueSource(strings = ["-12", "-96", "-81"]) fun `음수를 입력한 경우에도 유효성 검사에 통과하지 못한다`(input: String) { //given val user = User(InputForTest()) //then Assertions.assertThrows(IllegalArgumentException::class.java){ //when user.validation(input) } }
세 번째로 입력받은 3자리 수로 Strike, Ball을 판별하는 하는 기능을 구현하겠습니다.
1단계 : 실패하는 테스트 만들기
package domain import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource class refereeTest { @ParameterizedTest @ValueSource(strings = ["111", "222", "333"]) fun `3자리수가 모두 일치하는 경우 심판은 3스트라이크 0볼을 반환한다`(input: String) { //given val referee = Referee(input) //when val result = referee.judge(input) //then Assertions.assertEquals(result.strikeCount, 3) Assertions.assertEquals(result.ballCount, 0) } @Test fun `3자리수가 모두 일치하지 않는 경우 심판은 0스트라이크, 0볼을 반환한다`() { //given val answerNumber = "123" val referee = Referee(answerNumber) val userInput = "456" //when val result = referee.judge(userInput) //then Assertions.assertEquals(result.strikeCount, 0) Assertions.assertEquals(result.ballCount, 0) } @Test fun `1자리수가 일치하고 2자리는 위치가 일치하는 않는 경우 2볼1스트라이크를 반환한다`() { //given val answerNumber = "123" val referee = Referee(answerNumber) val userInput = "132" //when val result = referee.judge(userInput) //then Assertions.assertEquals(result.strikeCount, 1) Assertions.assertEquals(result.ballCount, 2) } @Test fun `1자리수만 일치하는 경우에는 1스트라이크를 0볼을 반환한다`() { //given val answerNumber = "123" val referee = Referee(answerNumber) val userInput = "196" //when val result = referee.judge(userInput) //then Assertions.assertEquals(result.strikeCount, 1) Assertions.assertEquals(result.ballCount, 0) } @Test fun `1자리수만 위치가 일치하지 않는 경우 1볼을 반환한다`() { //given val answerNumber = "123" val referee = Referee(answerNumber) val userInput = "396" //when val result = referee.judge(userInput) //then Assertions.assertEquals(result.strikeCount, 0) Assertions.assertEquals(result.ballCount, 1) } }
Ball, Strike를 판별하는 Referee 클래스를 만들어서 숫자 2개를 주면 strikeCount, ballCount를 검증할 수 있도록 역할 위임
초기에는 "낫싱", "3 스트라이크" 등을 반환하였으나 이런 것들은 View 클래스에게 위임할 생각으로 strikeCount와 ballCount만 반환하도록 하였습니다.
2단계 : 테스트가 성공하도록 구현
package domain import dto.RefereeResponse class Referee( private val answerNumber: String, ) { fun judge(userInput: String): RefereeResponse { val ballCount = countBalls(userInput) val strikeCount = countStrikes(userInput) return RefereeResponse( strikeCount = strikeCount, ballCount = ballCount, ) } private fun countStrikes(userInput: String): Int { var strikeCount = 0 for (index in userInput.indices) { strikeCount += strikeCountByIndex(index, userInput) } return strikeCount } private fun strikeCountByIndex(index: Int, userInput: String): Int { if (answerNumber[index] == userInput[index]) { return 1 } return 0 } private fun countBalls(userInput: String): Int { var ballCount = 0 for (index in userInput.indices) { ballCount += ballCountByIndex(index, userInput) } return ballCount } private fun ballCountByIndex(index: Int, userInput: String): Int { if (answerNumber[index] != userInput[index] && answerNumber.contains(userInput[index])) { return 1 } return 0 } }
3단계 : 리팩터링 - Kotlin Code Convention에 따라 control-flow-statements 변경
private fun ballCountByIndex(index: Int, userInput: String): Int { if (answerNumber[index] != userInput[index] && answerNumber.contains(userInput[index]) ) { return 1 } return 0 }
네 번째로 구해진 Strike, Ball을 사용자에게 보여주는 것을 구현하겠습니다.
1단계 : 실패하는 테스트를 작성하겠습니다
package view import dto.RefereeResponse import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.io.ByteArrayOutputStream import java.io.PrintStream import java.util.function.Consumer import java.util.stream.Stream class OutputViewTest { private val byteArrayOutputStream = ByteArrayOutputStream() private val standardOut: PrintStream = System.out @BeforeEach fun setupStream(){ System.setOut(PrintStream(byteArrayOutputStream)) } @AfterEach fun initStream(){ System.setOut(standardOut) byteArrayOutputStream.reset() } @Test fun `출력 테스트 튜토리얼`() { val name = "까오기" val c = Consumer { nm: String -> println( nm + "님 안녕하세요." ) } c.accept(name) assertEquals("까오기님 안녕하세요.", byteArrayOutputStream.toString().trim()) } @ParameterizedTest @MethodSource("provideStrikeCountAndBallCount") fun `사용자는 strike, ball의 여부를 console을 통해 확인할 수 있다`(strikeCount : Int, ballCount : Int){ printStrikeCountAndBallCountTest( strikeCount = strikeCount, ballCount = ballCount, expectedPrint = "${ballCount}볼${strikeCount}스트라이크" ) } @Test fun `3스트라이크 0볼인 경우에는 3스트라이크만 출력한다`(){ //given val strikeCount = 3 val ballCount = 0 //when,then printStrikeCountAndBallCountTest( strikeCount = strikeCount, ballCount = ballCount, expectedPrint = "3스트라이크" ) } @Test fun `0스트라이크 3볼인 경우에는 3볼만 출력한다`(){ //given val strikeCount = 0 val ballCount = 3 //when,then printStrikeCountAndBallCountTest( strikeCount = strikeCount, ballCount = ballCount, expectedPrint = "3볼" ) } @Test fun `0스트라이크 0볼인 경우에는 낫싱을 출력한다`(){ //given val strikeCount = 0 val ballCount = 0 //when,then printStrikeCountAndBallCountTest( strikeCount = strikeCount, ballCount = ballCount, expectedPrint = "낫싱" ) } private fun printStrikeCountAndBallCountTest(strikeCount : Int, ballCount : Int, expectedPrint : String){ //given val refereeResponse = RefereeResponse( strikeCount = strikeCount, ballCount = ballCount, ) val outputView = OutputView() val expectedPrint = expectedPrint //when outputView.printStrikeAndBallCount(refereeResponse) //then Assertions.assertEquals(byteArrayOutputStream.toString().trim(), expectedPrint) } companion object{ @JvmStatic fun provideStrikeCountAndBallCount() : Stream<Arguments> { return Stream.of( Arguments.of(1,1), Arguments.of(2,1), Arguments.of(1,2), ) } } }
여기서 고민한 점은 "사용자에게 보여주는 print문을 어떻게 테스트할 것인가?"였습니다.
간단하게 출력하는 구문을 반환하는 코드를 작성할지, print문을 테스트할 수 있는 방법이 있는지 검색하여 보았습니다.
예전에는 input 테스트를 진행할 때 InputUtil 인터페이스를 만들고 DI를 통하여 Input 클래스를 주입하는 방식으로 구현하여 가짜 Input 클래스를 실제 테스트 때 사용하는 방식을 사용하였는데 input 또한 테스트가 가능함을 알게 되었습니다.
https://sakjung.tistory.com/33
또한 methodSource라는 것을 알게 되어 활용하였습니다.
https://stackoverflow.com/questions/57054115/use-pure-kotlin-function-as-junit5-methodsource
2단계 : 테스트가 성공하도록 구현
package view import dto.RefereeResponse class OutputView { fun printStrikeAndBallCount(refereeResponse: RefereeResponse) { if (refereeResponse.strikeCount == MAXIMUM_COUNT) { println(threeStrikeMessage) return } if (refereeResponse.ballCount == MAXIMUM_COUNT) { println(threeBallMessage) return } if (refereeResponse.strikeCount == ZERO_COUNT && refereeResponse.ballCount == ZERO_COUNT ) { println(noBallAndNoStrikeMessage) return } println("${refereeResponse.ballCount}볼${refereeResponse.strikeCount}스트라이크") } companion object { private const val MAXIMUM_COUNT = 3 private const val ZERO_COUNT = 0 private const val threeStrikeMessage = "3스트라이크" private const val threeBallMessage = "3볼" private const val noBallAndNoStrikeMessage = "낫싱" } }
마지막으로 스트라이크 3개가 나올 때까지 게임을 반복하는 기능을 구현하겠습니다.
1단계 : 실패하는 테스트 작성
package game import org.junit.jupiter.api.Assertions import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.ValueSource import java.util.stream.Stream class BaseBallGameTest { @ParameterizedTest @ValueSource(strings = ["123", "456", "259"]) fun `스트라이크 3개가 나오면 게임이 끝난다`(input: String) { //given val baseBallGame = BaseBallGame() //when val result = baseBallGame.isAllStrike(input, input) //then Assertions.assertTrue(result) } @ParameterizedTest @MethodSource("provideStrikeNumberAndUserInputNumber") fun `스트라이크 3개가 아니라면 게임이 끝나지 않는다`(answerNumber: String, userInputNumber: String) { //given val baseBallGame = BaseBallGame() //when val result = baseBallGame.isAllStrike(answerNumber, userInputNumber) //then Assertions.assertFalse(result) } companion object { @JvmStatic fun provideStrikeNumberAndUserInputNumber(): Stream<Arguments> { return Stream.of( Arguments.of("123", "143"), Arguments.of("125", "123"), Arguments.of("987", "187"), ) } } }
2단계 : 테스트가 성공하도록 구현
package game class BaseBallGame { fun isAllStrike(answerNumber: String, userInputNumber: String): Boolean { return answerNumber == userInputNumber } }
리팩터링 및 애플리케이션 조립하기
TDD 사이클을 수행하며 만들어낸 애플리케이션을 조립하여 야구게임이 진행하도록 만들고 리팩터링을 수행하겠습니다.
package game import domain.Referee import domain.User import input.InputNumber import random.GenerateRandomNumber import view.InputView import view.OutputView class BaseBallGame { private val generateRandomNumber = GenerateRandomNumber() private val inputNumber = InputNumber() private val inputView = InputView() private val outputView = OutputView() fun run() { val user = User(inputNumber) val answerNumber = makeRandomAnswerNumber() val referee = Referee(answerNumber) do { inputView.showInputGuideMessage() val userInputNumber = userInputAndValidation(user) val judgeResult = referee.judge(userInputNumber) outputView.printStrikeAndBallCount(judgeResult) } while (!isAllStrike(answerNumber, userInputNumber)) } private fun userInputAndValidation(user: User): String { val userInputNumber = user.inputNumber() user.validation(userInputNumber) return userInputNumber } private fun makeRandomAnswerNumber(): String { val firstRandomNumber = generateRandomNumberAndToString() val secondRandomNumber = generateRandomNumberAndToString() val lastRandomNumber = generateRandomNumberAndToString() return firstRandomNumber + secondRandomNumber + lastRandomNumber } private fun generateRandomNumberAndToString(): String { return generateRandomNumber.getRandomNumberAndValidation().toString() } private fun isAllStrike(answerNumber: String, userInputNumber: String): Boolean { return answerNumber == userInputNumber } }
BaseBallGame 클래스를 만들고 User, Referee, inputNumber, generateRandomNumber 등 역할을 분담시킵니다.
이후 isAllStrike의 경우에도 private 하게 만들어주고 reflection을 활용해서 테스트 코드를 리팩터링 하였습니다.
하지만 막상 짜고 보니 BaseBallGame이 추상화에 의존하지 않고 구현체에 의존하고 있기 때문에 추상화에 의존하도록 변경해보려고 합니다.
인터페이스를 도입하고 DI를 통해 BaseBallGame에 주입하는 형태로 변경하여 테스트하기 용이한 구조로 변경하였습니다.
따라서 사용자가 매번 input값을 주는 것에 따라 변할 수 있는 run메서드를 테스트할 수 있게 되었습니다.
Strike, Ball, 낫싱에 대한 Enum 도입 리팩터링
문자열로 사용하던 Strike, Ball, 낫싱에 대해 Enum을 도입하면 좋을 것 같다고 생각이 들었습니다.
이유는 Strike, Ball, 낫싱에 대한 변경 포인트를 가지기 위함입니다.
enum class RefereeDiscriminate(val discriminate : String) { STRIKE("스트라이크"), BALL("볼"), NOTHING("낫싱"), } //Enum 활용법 RefereeDiscriminate.STRIKE.discriminate == "스트라이크"
Git Repo
https://github.com/Junuu/Number-BaseBall-Game-TDD
출처
https://www.baeldung.com/kotlin/check-if-string-is-numeric
https://kotlinlang.org/docs/coding-conventions.html#control-flow-statements
https://sakjung.tistory.com/33
https://stackoverflow.com/questions/57054115/use-pure-kotlin-function-as-junit5-methodsource
'프로젝트 > Kotlin + TDD' 카테고리의 다른 글
코틀린(kotlin) - 문자열계산기 (0) 2023.09.03 코틀린(kotlin) - 자동차 경주 게임 (0) 2022.10.18 코틀린 프로젝트 시작하기 (0) 2022.10.16