阅读本文后,希望你能够有如下收获:
- 能够采用TDD的方式实现保龄球业务需求。
- 掌握TDD的节奏:红(失败测试)、绿(产品代码)、蓝(重构)
- 理解测试驱动设计的一种运用场景。
如若与你期望相符,欢迎你继续阅读!文章篇幅较长,代码居多,由于代码多为片段截取,建议阅读时保持注意力集中。
在上一篇文章 TDD Kata - 保龄球(Bowling)Tasking中,我对保龄球业务需求做了分析和拆分,并得到了一个需求的任务列表,本文我将基于此任务列表一步一步地进行TDD。
开始前,先来TDD的三顶帽子:

我会将每一个测试从失败到通过到重构的过程视为一个循环,在这个循环中,我不断地切换红、Lv、蓝三顶帽子。
任务列表
- 每一轮的两次扔球都没有碰到球,所得分数为0。
- 每一轮的两次扔球都没有全部击倒球瓶,所得分数为每次扔球的倒瓶数总和。
- 存在一轮SPARE,所得分数为每次扔球的倒瓶数总和再加SPARE轮后的一球的倒瓶数。
- 存在一轮STRIKE,所得分数为每次扔球的倒瓶数总和再加STRIKE轮后两球的倒瓶数。
- 十轮均为STRIKE,所得分数300。
TDD
第一个测试
- 每一轮的两次扔球都没有碰到球,所得分数为0。
从任务里中取出第一个任务,按照任务描述翻译成第一个测试:
class BowlingGameTest {
@Test
void should_return_0_when_scoring_given_every_roll_is_0() {
BowlingGame bowlingGame = new BowlingGame(); // 3. 倒逼出来需要一个BowlingGame
for (int rollIndex = 0; rollIndex < 20; rollIndex++) {
bowlingGame.roll(0); // 4. 倒逼出来需要一个roll方法记录每一次的倒瓶数
}
int score = bowlingGame.scoring(); // 2. 倒逼驱动出分数是bowlingGame暴露的行为
assertThat(score).isEqualTo(0); // 1. 从断言开始,定义验收标准
}
}
编写测试的过程中,记住你戴的是红帽子,控制自己注意力,思考如何去验收功能,如何定义接口,这个过程其实是在设计系统的对外的用户接口,此时接口可能还不存在,但是按照你的意图,按照业务含义去定义接口出来,这就是一个反向驱动的过程。但你要控制自己避免受IDE的编译错误的提示干扰,一心一意将测试写完,确认写完之后,然后再切换帽子。
public class BowlingGame {
public void roll(int pouredNumber) {
}
public int scoring() {
return 0;
}
}
带上另一顶帽子(Lv 帽子),开始编写产品代码,解决编译错误,借助IDE的自动提示功能,上述代码让第一个测试通过了。你可能觉得幸福来得太突然,这感觉有点不合理,没关系,你的目标是让第一个测试通过了。通过后你尽管带上蓝帽子,看看有什么重构工作没有,至少目前为止,不需要重构产品代码。但可以对测试代码做一些重构:
@Test
void should_return_0_when_scoring_given_every_roll_is_0() {
BowlingGame bowlingGame = new BowlingGame();
for (int rollIndex = 0; rollIndex < 20; rollIndex++) {
bowlingGame.roll(0);
}
assertThat(bowlingGame.scoring()).isEqualTo(0); // 重构:内联变量,初学者也可以不这么做,便于区分when和then
}
做完重构,运行测试通过之后,进入下一个循环。
第二个测试
- 每一轮的两次扔球都没有全部击倒球瓶,所得分数为每次扔球的倒瓶数总和。
带上红帽子,按照任务编写第二个测试:
@Test
void should_sum_all_rolls_when_scoring_given_every_roll_is_common_as_3() {
BowlingGame bowlingGame = new BowlingGame();
for (int rollIndex = 0; rollIndex < 20; rollIndex++) {
bowlingGame.roll(3);
}
assertThat(bowlingGame.scoring()).isEqualTo(60);
}
运行测试之后,如期失败,带上Lv的帽子,编写产品代码:
public class BowlingGame {
private List<Integer> pouredNumbers = new ArrayList<>();
public void roll(int pouredNumber) {
pouredNumbers.add(pouredNumber);
}
public int scoring() {
return pouredNumbers.stream().mapToInt(number -> number).sum();
}
}
此时,因为第二个测试,你不得不存储每一球的倒瓶数,然后进行求和,进行这样的完善,第二个测试也通过了。通过了,就带上蓝帽子,进行重构,可以对测试代码进行进一步的重构,移除重复代码:
class BowlingGameTest {
private BowlingGame bowlingGame;
@BeforeEach
void setup() {
bowlingGame = new BowlingGame();
}
@Test
void should_return_0_when_scoring_given_every_roll_is_0() {
rolls(0);
assertThat(bowlingGame.scoring()).isEqualTo(0);
}
@Test
void should_sum_all_rolls_when_scoring_given_every_roll_is_common_as_3() {
rolls(3);
assertThat(bowlingGame.scoring()).isEqualTo(60);
}
private void rolls(int score) {
for (int rollIndex = 0; rollIndex < 20; rollIndex++) {
bowlingGame.roll(score);
}
}
}
做完后着进入下一个循环,此时如果想去喝口水,尽管走开,回来可以无缝衔接。
第三个测试
- 存在一轮SPARE,所得分数为每次扔球的倒瓶数总和再加SPARE轮后的一球的倒瓶数。
带上红帽子,按照任务编写第三个测试:
@Test
void should_involve_SPARE_bonus_when_scoring_given_one_SPARE_occurs() {
bowlingGame.roll(6);
bowlingGame.roll(4);
for (int rollIndex = 0; rollIndex < 18; rollIndex++) {
bowlingGame.roll(3);
}
assertThat(bowlingGame.scoring()).isEqualTo(67);
}
运行测试,发现失败了,戴上Lv帽子,回到代码中,发现此时要引入轮循环遍历了,可能对代码进行大幅度的修改,这个改动对之前的功能影响较大,为了保险期间,我先删掉这个测试,回到上一个循环,进行重构:
public int scoring() {
int totalScore = 0;
int rollIndex = 0;
for (int round = 0; round < 10; round++) {
totalScore += pouredNumbers.get(rollIndex);
totalScore += pouredNumbers.get(rollIndex + 1);
rollIndex += 2;
}
return totalScore;
}
运行测试,并没有破坏之前的测试。然后继续戴上第三个循环的红帽子,此时我通过重构引入了轮循环的设计。到这里,思考一下这个重构是什么触发的?(5秒后......)刚才,我在添加了新测试的时候,发现为了实现这个新功能,我需要引入轮循环,这是新的测试驱动出来的一种思考,我在新增功能的时候发现原有的设计有点困难,我先停下来,回到上一个循环,对代码进行重构,这样的好处是我不用去思考如何让新的测试通过,而是把注意力控制在上一个循环的重构中。
其实,我完全可以不返回上一个循环,直接对代码进行大幅度重构,如果这样做,我带了上一个循环的的蓝帽子和本循环的Lv帽子,注意力会比较多,所以为了控制焦点,我回退了一步。
经过上一步重构,我又回到第三个循环,戴上Lv帽子,此时我就较为容易的在新的设计中增加代码,来让第三个测试通过:
public int scoring() {
int totalScore = 0;
int rollIndex = 0;
for (int round = 0; round < 10; round++) {
if (pouredNumbers.get(rollIndex) + pouredNumbers.get(rollIndex + 1) == 10) { // Spare case
totalScore += 10;
totalScore += pouredNumbers.get(rollIndex + 2);
rollIndex += 2;
} else {
totalScore += pouredNumbers.get(rollIndex