TDD Kata - 保龄球(Bowling)Coding

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

  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);
                totalScore += pouredNumbers.get(rollIndex + 1);
                rollIndex += 2;
            }
        }
        return totalScore;
    }

运行测试,确认所有测试通过后,我戴上蓝帽子,进行了一波重构:

    public int scoring() {
        int totalScore = 0;
        int rollIndex = 0;
        for (int round = 0; round < 10; round++) {
            if (isSpare(rollIndex)) {
                totalScore += 10;
                totalScore += pouredNumbers.get(rollIndex + 2);
            } else {
                totalScore += pouredNumbers.get(rollIndex);
                totalScore += pouredNumbers.get(rollIndex + 1);
            }
            rollIndex += 2;
        }
        return totalScore;
    }

   // 重构: 抽取查询方法
    private boolean isSpare(int rollIndex) {
        return pouredNumbers.get(rollIndex) + pouredNumbers.get(rollIndex + 1) == 10;
    }

然后对测试代码进行了一轮重构:

    @Test
    void should_involve_SPARE_bonus_when_scoring_given_one_SPARE_occurs() {
        bowlingGame.roll(6);
        bowlingGame.roll(4);

        rolls(3, 18);
        assertThat(bowlingGame.scoring()).isEqualTo(67);
    }

   // 重构:引入次数的参数
    private void rolls(int score, int times) {
        for (int rollIndex = 0; rollIndex < times; rollIndex++) {
            bowlingGame.roll(score);
        }
    }

第四个测试

  1. 存在一轮STRIKE,所得分数为每次扔球的倒瓶数总和再加STRIKE轮后两球的倒瓶数。

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

    @Test
    void should_involve_STRIKE_bonus_when_scoring_given_one_STRIKE_occurs() {
        bowlingGame.roll(10);
        rolls(3, 18);
        assertThat(bowlingGame.scoring()).isEqualTo(70);
    }

戴上Lv帽子,编写实现,有了上一个案例的经验积累,我很容易想到设计:

    public int scoring() {
        int totalScore = 0;
        int rollIndex = 0;
        for (int round = 0; round < 10; round++) {
            if (pouredNumbers.get(rollIndex) == 10) { // Strike case
                totalScore += 10;
                totalScore += pouredNumbers.get(rollIndex + 1);
                totalScore += pouredNumbers.get(rollIndex + 2);
                rollIndex++;
            } else if (isSpare(rollIndex)) {
                totalScore += 10;
                totalScore += pouredNumbers.get(rollIndex + 2);
                rollIndex += 2;
            } else {
                totalScore += pouredNumbers.get(rollIndex);
                totalScore += pouredNumbers.get(rollIndex + 1);
                rollIndex += 2;
            }
        }
        return totalScore;
    }

运行测试,通过了测试,戴上蓝帽子进行重构:

public class BowlingGame {
    private List<Integer> pouredNumbers = new ArrayList<>();

    public void roll(int pouredNumber) {
        pouredNumbers.add(pouredNumber);
    }

    public int scoring() {
        int totalScore = 0;
        int rollIndex = 0;
        for (int round = 0; round < 10; round++) {
            if (isStrike(rollIndex)) { // Refactoring: Extract query method
                totalScore += (10 + getStrikeBonus(rollIndex));
                rollIndex++;
            } else if (isSpare(rollIndex)) {
                totalScore += (10 + getSpareScore(rollIndex));
                rollIndex += 2;
            } else {
                totalScore += (pouredNumbers.get(rollIndex) + pouredNumbers.get(rollIndex + 1));
                rollIndex += 2;
            }
        }
        return totalScore;
    }

    // 抽取方法:Spare Bonus
    private int getSpareBonus(int rollIndex) {
        return pouredNumbers.get(rollIndex + 2);
    }

    // 抽取方法:Strike Bonus
    private int getStrikeBonus(int rollIndex) {
        return pouredNumbers.get(rollIndex + 1) + pouredNumbers.get(rollIndex + 2);
    }
    private boolean isStrike(int rollIndex) {
        return pouredNumbers.get(rollIndex) == 10;
    }
    private boolean isSpare(int rollIndex) {
        return pouredNumbers.get(rollIndex) + pouredNumbers.get(rollIndex + 1) == 10;
    }
}

基本上到这里,几种场景都覆盖了,此时我发现我的任务列表还有一个完美场景,我隐约预料到这个场景应该能通过,如果我的代码没有Bug的话。我依照惯例,编写了第五个测试。

第五个测试

  1. 十轮均为STRIKE,所得分数300。

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

    @Test
    void should_sum_all_STRIKE_bonus_when_scoring_given_every_round_is_STRIKE() {
        rolls(10, 12);
        assertThat(bowlingGame.scoring()).isEqualTo(300);
    }

运行测试时通过的,其实我在上一个循环中的感觉是对的,但这个测试我已经编写了,它没有失败,我将其保留下来,因为它能够为我的保护网增添一条保护丝线。同样的道理,在上一篇文章中,我提到一个信息:QA如果穷举出10轮中的所有场景,将有59049种,此时你如果对自己的程序不太放心,你可以任意编写的场景来验证你的代码,直到你放心为止。

总结

  1. TDD的过程始终按照Tasking环节中的任务列表来小步前进。
  2. 一个TDD循环中通过三顶帽子能够控制自己的注意力,分离关注点。
  3. TDD是一个以终为始的方式,从结果倒逼+意图导向的方式驱动我们去思考系统的接口如何定义。
  4. 为了更加容易的增加新功能,我需要对原有代码进行重构,而我采取了回退的方式,一次只关注一件事情。并且,此时的重构想法是由新的测试驱动出来的。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,616评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,020评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,078评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,040评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,154评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,265评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,298评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,072评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,491评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,795评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,970评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,654评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,272评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,985评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,223评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,815评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,852评论 2 351