TDD Kata编程实战:保龄球(Bowling)

阅读本文后,希望你能够有如下收获:

  1. 能够采用TDD的方式实现保龄球业务需求。
  2. 掌握TDD的节奏:红(失败测试)、绿(产品代码)、蓝(重构)
  3. 理解测试驱动设计的一种运用场景。

如若与你期望相符,欢迎你继续阅读!文章篇幅较长,代码居多,由于代码多为片段截取,建议阅读时保持注意力集中。

在上一篇文章 TDD Kata - 保龄球(Bowling)Tasking中,我对保龄球业务需求做了分析和拆分,并得到了一个需求的任务列表,本文我将基于此任务列表一步一步地进行TDD。

开始前,先来TDD的三顶帽子:



我会将每一个测试从失败到通过到重构的过程视为一个循环,在这个循环中,我不断地切换红、Lv、蓝三顶帽子。

任务列表

  1. 每一轮的两次扔球都没有碰到球,所得分数为0。
  2. 每一轮的两次扔球都没有全部击倒球瓶,所得分数为每次扔球的倒瓶数总和。
  3. 存在一轮SPARE,所得分数为每次扔球的倒瓶数总和再加SPARE轮后的一球的倒瓶数。
  4. 存在一轮STRIKE,所得分数为每次扔球的倒瓶数总和再加STRIKE轮后两球的倒瓶数。
  5. 十轮均为STRIKE,所得分数300。

TDD

第一个测试

  1. 每一轮的两次扔球都没有碰到球,所得分数为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
    }

做完重构,运行测试通过之后,进入下一个循环。

第二个测试

  1. 每一轮的两次扔球都没有全部击倒球瓶,所得分数为每次扔球的倒瓶数总和。

带上红帽子,按照任务编写第二个测试:

    @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);
        }
    }
}

做完后着进入下一个循环,此时如果想去喝口水,尽管走开,回来可以无缝衔接。

第三个测试

  1. 存在一轮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
                
            
QR Code
微信扫一扫,欢迎咨询~

联系我们
武汉格发信息技术有限公司
湖北省武汉市经开区科技园西路6号103孵化器
电话:155-2731-8020 座机:027-59821821
邮件:tanzw@gofarlic.com
Copyright © 2023 Gofarsoft Co.,Ltd. 保留所有权利
遇到许可问题?该如何解决!?
评估许可证实际采购量? 
不清楚软件许可证使用数据? 
收到软件厂商律师函!?  
想要少购买点许可证,节省费用? 
收到软件厂商侵权通告!?  
有正版license,但许可证不够用,需要新购? 
联系方式 155-2731-8020
预留信息,一起解决您的问题
* 姓名:
* 手机:

* 公司名称:

姓名不为空

手机不正确

公司不为空