2023. 9. 24. 14:10ㆍTechnology/Others
그놈의 TDD가 무엇인지 알아보자.
💡 TDD는 과정이 중요하다
- 처음부터 완벽하게 핸들링하여 개발할 수는 없다.
- 그래서 대부분 첫 Test Run에서 Test Fail 인 상황이 발생한다.
- 이를 Test Success로 옮겨가는 식으로 개발을 한다.
- 이 과정에서 활용되는 것이 Test Case들이며, Test Case들의 묶음이 Test Scenario이다.
이 과정을 볼링 게임으로 익혀보자.
Case1. 전부 또랑행 (score 0)
10번의 프레임 시도에서 모두 또랑에 공이 빠진다면, 모든 점수는 0이다.
이때, 점수는 0이 반환되면 된다.
이 시나리오를 토대로 아래와 같이 테스트 코드를 작성한다.
@Test
fun `gutter game`() {
val game = BowlingGame()
repeat(20) {
game.roll(0)
}
assertThat(game.score()).isEqualTo(0)
}
테스트가 성공하려면 어떤 것이 필요한가?
BowlingGame class에 공을 던져서 핀을 쓰러트렸다는 뜻의 roll(pins: Int)이라는 함수를 정의해야 한다.
그리고 최종 점수를 반환하는 score()라는 함수를 정의해야 한다.
class BowlingGame {
fun roll(pins: Int) {}
fun score(): Int = 0
}
테스트를 run 하면?
성공한다.
Case2. 모든 프레임에서 pin 1개씩만 쓰러트리기 (score 20)
10번의 프레임 모두 핀을 1개씩만 쓰러트린 경우의 시나리오를 추가한다.
@Test
fun `all 1s`() {
val game = BowlingGame()
repeat(20) {
game.roll(1)
}
assertThat(game.score()).isEqualTo(20)
}
테스트를 run 하면?
실패한다.
BowlingGame의 score() 함수는 매번 0을 반환하기 때문이다.
쓰러진 pin 수만큼 score에 누적해서 반환해 주도록 리팩토링이 필요하다.
class BowlingGame {
var score = 0
fun roll(pins: Int) {
score += pins
}
fun score(): Int = score
}
리팩토링 했으니 테스트를 run 하면?
성공한다.

하지만 테스트 코드에 중복 코드가 보인다.
class BowlingGameTest {
@Test
fun `gutter game`() {
val game = BowlingGame()
repeat(20) {
game.roll(0)
}
assertThat(game.score()).isEqualTo(0)
}
@Test
fun `all 1s`() {
val game = BowlingGame()
repeat(20) {
game.roll(1)
}
assertThat(game.score()).isEqualTo(20)
}
}
game 객체를 매번 생성하는 부분과 roll을 반복하는 것을 리팩토링 해보자.
먼저 game 객체는 테스트가 시작되기 전에 set up 되도록 @BeforeEach를 통해 생성해 두자.
그다음 pin n개를 을 m번 반복하는 부분을 method로 추출한다.
class BowlingGameTest {
lateinit var game: BowlingGame
@BeforeEach
fun setUp() {
game = BowlingGame()
}
private fun rollMany(times: Int, pins: Int) {
repeat(times) {
game.roll(pins)
}
}
@Test
fun `gutter game`() {
rollMany(20, 0)
assertThat(game.score()).isEqualTo(0)
}
@Test
fun `all 1s`() {
rollMany(20, 1)
assertThat(game.score()).isEqualTo(20)
}
}

편-안
Case3. 첫 번째 프레임 spare - 7, 3, 5 순으로 쓰러트리기 (score 25)
첫 번째 프레임에서 roll(7), roll(3)로 spare 처리를 했고, 두 번째 프레임의 초구에서 5개를 던진 시나리오를 작성해 보자.
@Test
fun `spare at frame 1`() {
game.roll(7)
game.roll(3)
game.roll(5)
rollMany(17, 0)
assertThat(game.score()).isEqualTo(20)
}
당연히 실패한다.
BowlingGame에서 spare를 고려하도록 리팩토링 해야 한다.
일단 Baby Step👣으로 개선해 보자.
첫 번째, roll()이 score를 계산하지 않도록 관심사를 분리한다.
currentRoll이라는 횟수 변수를 만들고, roll들을 저장해 둘 rolls array를 만들어서 활용한다.
class BowlingGame {
var rolls = IntArray(21)
var currentRoll = 0
fun roll(pins: Int) {
rolls[currentRoll++] = pins
}
fun score(): Int {
var score = 0
for (roll in rolls) {
score += roll
}
return score
}
}
두 번째, 원래 목적인 spare를 고려하도록 해본다.
그러려면 첫 번째 프레임을 인지시켜야 하니, frameIndex로 추적하고 10번의 frame을 돌도록 짠다.
그러니까 현재의 frameIndex와 frameIndex+1의 합산이 10이면 spare이니, frameIndex+2를 추가로 더해준다.
class BowlingGame {
var rolls = IntArray(21)
var currentRoll = 0
fun roll(pins: Int) {
rolls[currentRoll++] = pins
}
fun score(): Int {
var score = 0
var frameIndex = 0
for (frame in 0 until 10) {
if (rolls[frameIndex] + rolls[frameIndex+1] == 10) {
score += 10 + rolls[frameIndex+2]
frameIndex += 2
} else {
score += rolls[frameIndex] + rolls[frameIndex+1]
frameIndex += 2
}
}
return score
}
}
여기서 다른 사람이 읽었을 때 spare인지 명확히 보이진 않는다.
그래서 spare인지 확인하고, spare 보너스를 합산하는 method들을 추출해 보자.
fun score(): Int {
var score = 0
var frameIndex = 0
for (frame in 0 until 10) {
if (isSpare(frameIndex)) {
score += spareBonus(frameIndex)
frameIndex += 2
} else {
score += rolls[frameIndex] + rolls[frameIndex+1]
frameIndex += 2
}
}
return score
}
private fun isSpare(frameIndex: Int) = rolls[frameIndex] + rolls[frameIndex + 1] == 10
private fun spareBonus(frameIndex: Int) = 10 + rolls[frameIndex + 2]
여기도 리팩토링 해줬으니 테스트 코드도 보완해 보자.
아래처럼 작성하면 spare를 테스트한다는 것이 더욱 명확해진다.
private fun spare() {
game.roll(7)
game.roll(3)
}
@Test
fun `spare at frame 1`() {
spare()
game.roll(5)
rollMany(17, 0)
assertThat(game.score()).isEqualTo(20)
}
Case4. 첫 번째 프레임 strike - 10, 7, 2 순으로 쓰러트리기 (score 28)
알잘딱깔센 하게 strike() 먼저 추출해 주고, 시나리오대로 작성한다.
private fun strike() {
game.roll(10)
}
@Test
fun `strike at frame 2`() {
strike()
game.roll(7)
game.roll(2)
rollMany(16, 0)
assertThat(game.score()).isEqualTo(10 + 9 + 9)
}
당연히 run 하면? 실패
strike를 고려하도록 BowlingGame을 리팩토링 하자.
여기도 spare 때처럼 알잘딱깔센 하게 작성한다.
fun score(): Int {
var score = 0
var frameIndex = 0
for (frame in 0 until 10) {
if (isStrike(frameIndex)) {
score += strikeBonus(frameIndex)
frameIndex += 1
} else if (isSpare(frameIndex)) {
score += spareBonus(frameIndex)
frameIndex += 2
} else {
score += rolls[frameIndex] + rolls[frameIndex+1]
frameIndex += 2
}
}
return score
}
private fun isStrike(frameIndex: Int) = rolls[frameIndex] == 10
private fun strikeBonus(frameIndex: Int) = 10 + rolls[frameIndex + 1] + rolls[frameIndex + 2]
private fun isSpare(frameIndex: Int) = rolls[frameIndex] + rolls[frameIndex + 1] == 10
private fun spareBonus(frameIndex: Int) = 10 + rolls[frameIndex + 2]
Case5. Perfect (score 300)
마지막으로 만점까지 고려되었는지 Perfect case를 테스트해보자.
@Test
fun `perfect`() {
rollMany(12, 10)
assertThat(game.score()).isEqualTo(300)
}
성공!
Toby님의 설명을 추가하며 이 글을 마친다.
TDD는 객체지향과 유사한 특징이 있어요.
내부 구현이 아니라 밖에서 보이는 기능(observable behavior)만 생각하는 거죠.
객체가 캡슐화되면 내부 구현을 외부에 감추고, 변경에 용이해집니다.
TDD는 그걸 잘 보여주는 개발 방법이에요.
그래서 TDD를 잘하면 객체지향적인 관점으로 개발을 잘하게 됩니다.
출처
http://butunclebob.com/ArticleS.UncleBob.TheBowlingGameKata
https://oblac.rs/tdd-kuglanje-i-teca-bob/Bowling_Game_Kata.pdf
'Technology > Others' 카테고리의 다른 글
| T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 2 (1) | 2024.09.08 |
|---|---|
| T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 1 (3) | 2024.09.08 |
| 선배 Proxy 고려해서 equals 오버라이드 해주세요, 탕탕 후루후루~ (2) | 2024.07.26 |
| Test Code 500개 도달 여정 (feat. DIP, 맞다이로 드루와) (5) | 2024.05.14 |
| Controller 먼저? Domain 먼저? (0) | 2024.02.10 |