이 프로젝트에서는 밥아저씨를 비롯한 많은 사람들이 10여간 TDD를 설명하기 위한 예제로 사용한 Bowling Game을 TDD로 구현하는 것을 설명하고, TDD에 대한 반발과 이에 대한 답변에 대해 알아본다.
- 규칙
- 볼링 게임은 10개의 프레임으로 구성된다.
- 각 프레임은 대개 2 롤을 갖는다(10개의 핀을 쓰러 뜨리기 위해 2번의 기회를 갖는다).
- Spare: 10 + next first roll에서 쓰러 뜨린 핀수.
- Strike: 10 + next two rolls에서 쓰러 뜨린 핀수.
- 10th 프레임은 특별. spare 처리하면 3번 던질 수 있음.
Game이라는 클래스를 생성하는 것이 이 예제의 목적이다.
- Game 클래스는
- roll과 score라는 2개의 메소드를 갖는다.
- roll 메소드는 ball을 roll할 때마다 호출된다. 인자로는 쓰러뜨린 핀수를 갖는다.
- score 메소드는 게임이 끝난 후에만 호출되어 게임의 점수를 반환한다.
- 게임은 10개의 프레임을 갖는다.
- Game은 roll, score 함수를 갖는다.
- Game은 10개의 Frame을 갖는다.
- Frame은 1..2개의 Roll을 갖는다.
- 10번 프레임은 예외를 갖는다(1..2 roll을 갖는 것이 아니라 2..3 roll을 갖는다).
- 객체지향에서 이런 예외를 어떻게 표현하나 ???
** score 함수의 알고리즘
frame수만큼 loop를 돌면서 각 frame의 점수를 합산할 것이다.
frame은 roll수 만큼 loop를 돌면서 점수를 계산할 것이다. strike, spare를 위해서 look ahead roll을 해야 한다.
TDD에서 이상한 일을 한다. 설계를 무시하는 것이다. 완전히 무시하는 것은 아니고 따르지 않는 것이다. 단지 가이드 라인의 일정으로 사용한다.
public class BowlingTest {
@Test
public void nothing() {
}
}
- 아무것도 없는 nothing이라는 테스트로 시작. 이것도 실행해 본다. 밥 아저씨는 항상 뭔가 실행되는 것으로 시작한다고 한다. 그래서 심지어 의미 있는 코드가 하나도 없더라도 실행되는 뭔가를 가지고 있다.
- 그리곤 지운다. 아마 테스트 작성을 위한 설정이 제대로 되었는지 확인하는가보다. 나중에 알았지만 항상 동작하는 코드로 작업하기 위해서이다. 이게 개발자들에게는 편안함을 준다.
- 무슨 테스트를 작성해야 하나 ?
- failing unit test가 있기 전에는 production code를 작성하면 안된다.
- 하지만 이미 개발자는 어떤 production code를 작성해야 하는지 안다. public class Game을 작성해야 한다는 것을 ...
- 그러나 우리는 public class Game을 작성하도록 허가 받지 않았다. 우리는 유닛 테스트를 먼저 작성해야 한다.
- 어떤 테스트를 작성해야 내가 원하는 코드를 작성하게 될까 ?
- 어떤 failing test를 작성해야 Game.java에 public class Game을 선언하게 될까 ?
- 이제부터 red-green-refactor cycle로 들어가자.
- red phase: next most interesting case but still really simple
- green phase: make it pass
- blue phase: refactor
- 가장 쉽고(간단하고) 흥미로운(easy/simple and interesting) 테스트부터 작성
@Test
public void canCreateGame() {
Game g = new Game();
}
- IDE의 hot fix를 이용(F2: next error, opt+enter: hot fix)
- 스코어를 바로 계산하기 보다는 이를 위한 과정으로 canRoll을 먼저 추가
- 넘어진 pin수가 0인 것에 대한 failing test를 먼저 추가
@Test
public void canRoll() {
Game g = new Game();
g.roll(0);
}
- make it pass by using hot fix
- 테스트에 있는 중복(
Game game = new Game();
) 제거 - 해당 코드를 extract field(initialize는 setUp을 선택)
- 불필요한 코드 제거(
@Test public void canCreate()
메소드는 불필요)
- score를 바로 호출하고 싶지만, 게임이 끝나야만 score함수를 호출할 수 있다.
- 게임을 끝내는 가장 간단한 방법은 gutter game이다.
@Test
public void gutterGame() {
for(int i = 0; i < 20; i++)
game.roll(0);
assertThat(game.score(), is(0));
}
- next most simple and interesting test case
@Test
public void allOnes() {
for(int i = 0; i < 20; i++)
game.roll(1);
assertThat(game.score(), is(20));
}
private Integer score = 0;
public void roll(int pins) {
score += pins;
}
public Integer score() {
return score;
}
- extract variable
- gutterGame()에서 rolls, pins를 추출
@Test
public void gutterGame() {
int rolls = 20;
int pins = 0;
for(int i = 0; i < rolls; i++) {
game.roll(pins);
}
assertThat(game.score(), is(0));
}
- extract method - rollMany()
@Test
public void gutterGame() {
int rolls = 20;
int pins = 0;
rollMany(rolls, pins);
assertThat(game.score(), is(0));
}
private void rollMany(int rolls, int pins) {
for(int i = 0; i < rolls; i++)
game.roll(pins);
}
- inline variables
@Test
public void gutterGame() {
rollMany(20, 0);
assertThat(game.score(), is(0));
}
- gutter, allOne이 있으니 allTwo를 생각해 볼 수 있으나 이건 잘 동작할 것이다.
- 뻔히 동작할 것을 알 수 있는 테스트는 작성할 필요가 없다.
- allThree, allFour도 잘 동작할 것이다. 그런데 allFive는 그렇지 않다. spare가 있기 때문에.
- spare에 대한 테스트를 작성할 차례이다. 가장 간단한 spare는 어떤 경우가 있을까 ? one spare + gutter.
@Test
public void oneSpare() {
game.roll(5);
game.roll(5); // spare
game.roll(3);
rollMany(17, 0);
assertThat(game.score(), is(16));
}
- 근데 어떻게 해야 할지 모르겠다.
public void roll(int pins) {
if(pins + lastPins == 10)
...
}
- 위와 같이 하려다 보니 이상하다. 플래그 변수, 정적 변수를 사용해야 하고…
- 이처럼 끔찍한 일을 해야 하는 경우가 생길때마 잠시 물러나야 한다.
- 뭔가 디자인이 잘 못된 것이다.
- 디자인 원칙이 위배된 것이 있다.
- 첫번째 원칙: 스코어를 계산하는 것을 의미하는 이름을 갖는 함수가 무엇인가 ?
- score 함수이다. 근데 실제로 score를 계산하는 함수는 roll 함수이다.
- 잘못된 책임 할당(misplaced responsibility)이 디자인 원칙, 잘못된 디자인 냄새이다.
- roll에선 각 roll을 저장하고, score에서 계산을 해야 한다.
- 어쩌지. refactoring. 근데 failing test가 있다. @Ignore 처리…
- 이제 테스트가 수행되니 리팩토링하자.
- Roll을 배열에 저장하자.
- 이에 잘못된 책임 할당이 해소되었다.
public class Game {
private int[] rolls = new int[21];
private int currentRoll = 0;
public void roll(int pins) {
rolls[currentRoll++] = pins;
}
public Integer score() {
int score = 0;
for(int i = 0; i < rolls.length; i++)
score += rolls[i];
return score;
}
}
- frame을 도입하여 읽기 쉽게한다.
public Integer score() {
int score = 0;
int i = 0;
for(int frame = 0; frame < 10; frame++) {
score += rolls[i] + rolls[i + 1];
i += 2;
}
return score;
}
- @Ignore 제거
if(rolls[i] + rolls[i + 1] == 10) { // spare
score += 10 + rolls[i + 2];
i += 2;
}
else {
score += rolls[i] + rolls[i + 1];
i += 2;
}
- rename i to firstFrame
- 처음부터 firstFrame이라는 변수명을 찾은 것이 아니라 여러차례의 시도로 찾음
- extract method isSpare
- remove duplication - comment // spare
- extract method for readability(rollSpare)
@Test
public void oneSpare() {
rollSpare();
game.roll(3);
rollMany(17, 0);
assertThat(game.score(), is(16));
}
private void rollSpare() {
game.roll(5);
game.roll(5);
}
@Test
public void oneStrike() {
game.roll(10);
game.roll(5);
game.roll(3);
rollMany(16, 0);
assertThat(game.score(), is(26));
}
if(rolls[firstFrame] == 10) { // strike
score += 10 + rolls[firstFrame + 1] + rolls[firstFrame + 2];
firstFrame += 1;
}
else if(isSpare(firstFrame)) {
...
- extract method isStrike
- extract method for readabiliy(nextTwoBallsForStrike, nextBallForSpare, nextBallsInFrame)
if(isStrike(firstFrame)) {
score += 10 + nextTwoBallsForStrike(firstFrame);
firstFrame += 1;
}
else if(isSpare(firstFrame)) {
score += 10 + nextBallForSpare(firstFrame);
firstFrame += 2;
}
else {
score += nextBallsInFrame(firstFrame);
firstFrame += 2;
}
- extract method for readabiliy(rollStrike)
@Test
public void oneStrike() {
rollStrike();
game.roll(5);
game.roll(3);
rollMany(16, 0);
assertThat(game.score(), is(26));
}
private void rollStrike() {
game.roll(10);
}
@Test
public void perfectGame() {
rollMany(12, 10);
assertThat(game.score(), is(300));
}
- red phase인데 테스트가 성공한다. 왜 그럴까 ?
- production code를 살펴보니 3개의 경우 수가 있는데, 실제 볼링 게임에도 그 3가지 경우 수만 존재한다.