TDD - Test Scenario, Test Case, Test Code

2023. 9. 24. 14:10Technology/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
반응형