Skip to main content

使用FizzBuzz学习TDD

前言

FizzBuzz是一个简单的入门者编程题目,被广泛推荐用来学习TDD(Test-Driven Development 测试驱动开发)开发模式。

1. 题目(Problem)

想象一下场景。你今年11岁,在数学课程结束前的五分钟内,你的数学老师认为他应该通过引入“游戏”使课堂更加“有趣”。 他说明,他将依次指着每个学生,并要求他们依次说出下一个数字。“有趣”的部分是,如果数字可被3整除,则改为说“嘶嘶(Fizz)”,如果数字可被5整除,则说“嗡嗡(Buzz)”。同时是3与5的倍数,则说“嘶嘶嗡嗡(FizzBuzz)” 因此,现在你的数学老师依次指向所有同学,他们高兴地喊着 “1!” “2!” “嘶嘶!(Fizz)” “4!” “嗡嗡!(Buzz)”……

直到他故意指向你,全班同学凝视着你……时间静止不动,你开始嘴巴干涩,手掌出汗,终于你小声的说出“嘶嘶!(Fizz)”避免了这场尴尬,老师的手指继续前进。

因此,为了避免再次尴尬,你必须打印出一张清单,以便知道你该说些什么。你的班级大约有33名学生,老师可能会在钟声响起之前休息三遍。下一堂数学课在星期四。Get coding!

清单样本:

1
2
Fizz
4
Buzz
... etc up to 100

题目取自编码道场。原文链接 http://codingdojo.org/kata/FizzBuzz/

2. 目标(Target)

题目需求

编写一个程序,打印从1到100的数字(33名学生, 休息3次),但是

  • 3的倍数打印“Fizz”
  • 5的倍数打印“Buzz”
  • 3和5的倍数打印“FizzBuzz”
  • 以上都不满足打印数字

代码规范

  • 单个 Java 文件不得超过50行
  • 单行代码长度不得超过150个字符
  • 单个方法长度不得超过10行
  • 单个方法的圈复杂度不得超过4
  • 单个方法参数个数不得超过3
  • 友好的方法命名

3. 实践

3.1 使用TDD之前

实现代码

如果对代码没有要求的话,会写出以下代码:

package cn.dxsuite.core.tdd;

public class FizzBuzz {

public void sayNumber(int firstNum, int secondNum) {

// 从1到100报数
for (int i = 1; i <= 100; i++) {

// 如果同时被3和5整除则报“FizzBuzz”
if (isMultipleNum(firstNum, i) && isMultipleNum(secondNum, i)) {
System.out.println(String.format("%s FizzBuzz", i));
continue;
}

// 如果碰到被3整除的数则报“Fizz”
if (isMultipleNum(firstNum, i)) {
System.out.println(String.format("%d Fizz", i));
continue;
}

// 如果碰到被5整除的数则报“Buzz”
if (isMultipleNum(secondNum, i)) {
System.out.println(String.format("%d Buzz", i));
continue;
}

// 以上都不满足则报数字本身
System.out.println(String.format("%d", i));
}
}

private boolean isMultipleNum(int targetNum, int sayNum) {
String targetNumStr = String.valueOf(targetNum);
String sayNumStr = String.valueOf(sayNum);
return sayNum % targetNum == 0 || sayNumStr.contains(targetNumStr);
}

public void main(String[] args) {
sayNumber(3, 5);
}
}

存在问题

  • 太多if
  • 重复代码太多
  • 人肉测试,没有单元测试
  • 没有自动化测试
  • 方法sayNumber圈复杂度6
  • 方法sayNumber行数25行
  • 文言BuzzFizz没有定义常量

3.2 使用TDD实现

工具

  • 系统:mac os
  • 开发环境:IntelliJ IDEA
  • 编程语言:Java8
  • 构建系统:Gradle 6
  • 单元测试框架:Junit 5

TDD步骤

  1. TESTS FAILED 先写测试代码,并执行,得到失败结果
  2. TESTS PASSED 写实现代码让测试通过
  3. REFACTORING 重构代码,并保证测试通过。
反复实行这个步骤
TESTS FAILED(测试失败)→ TESTS PASSED(测试成功)→ REFACTORING(重构)

实现过程

第1个用例:第一个人报数1

@Test
void giveOne_whenSay_thenReturnOne() {
assertEquals("1", fizzBuzz.say(1));
}

执行测试,失败!

接下来实现say方法,满足第一个人报数1

public String say(int number) {
return "1";
}
测试结果html报告路径
工程目录/fizzbuzz/build/reports/tests/test/classes/cn.dxsuite.core.tdd.TestFizzBuzz.html

执行测试,成功!

第2个用例:第二个人报数2

@Test
void giveTwo_whenSay_thenReturnTwo() {
assertEquals("2", fizzBuzz.say(2));
}

执行,测试失败!

接下来改造say方法,满足第二个人报数2

public String say(int number) {
return String.valueOf(number);
}

执行,成功!

第3个用例:3的倍数则报“Fizz”

@ParameterizedTest
@CsvSource({"3, Fizz", "6, Fizz"})
void giveThreeMultiple_whenSay_thenReturnFizz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}

执行,测试失败!

接下来改造say方法,满足测试用例

public String say(int number) {
// 3的倍数说Fizz
if (number % 3 == 0) {
return "Fizz";
}
return String.valueOf(number);
}

执行,成功~~~

第4个用例:5的倍数则报“Buzz”

@ParameterizedTest
@CsvSource({"5, Buzz", "10, Buzz"})
void giveFiveMultiple_whenSay_thenReturnBuzz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}

执行,测试失败!

改造say方法,满足测试用例

public String say(int number) {
// 3的倍数说Fizz
if (number % 3 == 0) {
return "Fizz";
}

// 5的倍数说Buzz
if (number % 5 == 0) {
return "Buzz";
}

return String.valueOf(number);
}

执行,成功~~~

观察代码,求余运算会被多次使用提炼函数(Extract Method),使得函数可复用。


public String say(int number) {
// 3的倍数说Fizz
if (isMultiple(number, 3)) {
return "Fizz";
}

// 5的倍数说Buzz
if (isMultiple(number, 5)) {
return "Buzz";
}

return String.valueOf(number);
}

private boolean isMultiple(int dividend, int divisor) {
return dividend % divisor == 0;
}

第5个用例:3和5的倍数则报“FizzBuzz”

@ParameterizedTest
@CsvSource({"15, FizzBuzz", "30, FizzBuzz"})
void giveThreeMultipleAndFiveMultiple_whenSay_thenReturnFizzBuzz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}

执行,测试失败!

改造say方法,满足测试用例

public String say(int number) {
// 3和5的公倍数说FizzBuzz
if (isMultiple(number, 3) && isMultiple(number, 5)) {
return "FizzBuzz";
}

// 3的倍数说Fizz
if (isMultiple(number, 3)) {
return "Fizz";
}

// 5的倍数说Buzz
if (isMultiple(number, 5)) {
return "Buzz";
}

return String.valueOf(number);
}

执行测试,成功~

但是圈复杂度结果say方法为5,不符合代码规范

使用提炼函数(Extract Method)方法重构,解决圈复杂度问题,简明命名使得代码有更良好的可读性。

public String say(int number) {
if (isFizzBuzz(number)) {
return "FizzBuzz";
}
if (isFizz(number)) {
return "Fizz";
}
if (isBuzz(number)) {
return "Buzz";
}
return String.valueOf(number);
}

private boolean isFizzBuzz(int number) {
return isFizz(number) && isBuzz(number);
}

private boolean isFizz(int number) {
return isMultiple(number, 3);
}

private boolean isBuzz(int number) {
return isMultiple(number, 5);
}

private boolean isMultiple(int dividend, int divisor) {
return dividend % divisor == 0;
}

单元测试类代码

package cn.dxsuite.core.tdd;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import org.junit.jupiter.api.Assertions.*;

class TestFizzBuzz {

FizzBuzz fizzBuzz = new FizzBuzz();

@Test
void giveOne_whenSay_thenReturnOne() {
assertEquals("1", fizzBuzz.say(1));
}

@Test
void giveTwo_whenSay_thenReturnTwo() {
assertEquals("2", fizzBuzz.say(2));
}

@ParameterizedTest
@CsvSource({"3, Fizz", "6, Fizz"})
void giveThreeMultiple_whenSay_thenReturnFizz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}

@ParameterizedTest
@CsvSource({"5, Buzz", "10, Buzz"})
void giveFiveMultiple_whenSay_thenReturnBuzz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}

@ParameterizedTest
@CsvSource({"15, FizzBuzz", "30, FizzBuzz"})
void giveThreeMultipleAndFiveMultiple_whenSay_thenReturnFizzBuzz(int input, String expectedResult) {
assertEquals(expectedResult, fizzBuzz.say(input));
}
}

实现类的代码

package cn.dxsuite.core.tdd;

public class FizzBuzz {

public String say(int number) {
if (isFizzBuzz(number)) {
return "FizzBuzz";
}
if (isFizz(number)) {
return "Fizz";
}
if (isBuzz(number)) {
return "Buzz";
}
return String.valueOf(number);
}

private boolean isFizzBuzz(int number) {
return isFizz(number) && isBuzz(number);
}

private boolean isFizz(int number) {
return isMultiple(number, 3);
}

private boolean isBuzz(int number) {
return isMultiple(number, 5);
}

private boolean isMultiple(int dividend, int divisor) {
return dividend % divisor == 0;
}

public void main(String[] args) {
FizzBuzz fizzBuzz = new FizzBuzz();
for (int number = 1; number <= 100; number++) {
System.out.println(fizzBuzz.say(number));
}
}
}

验收结果

单元测试结果

根据代码规范检查结果

代码规范实现类测试类
单个 Java 文件不得超过50行
单行代码长度不得超过150个字符
单个方法长度不得超过10行
单个方法的圈复杂度不得超过4
单个方法参数个数不得超过3
友好的方法命名

圈复杂度结果

4.总结

// TODO 学习感受,TDD使我的代码更自信

4.1 测试先于实现
测试代码先于实现代码,你所开发的功能会被测试保护的,写代码,后写测试。实现的代码可能超出了测试代码。

4.2 baby step
频繁地实行这个开发循环。

TESTS FAILED(测试失败)→ TESTS PASSED(测试成功)→ REFACTORING(重构)  

不要写了半小时测试,然后运行一下,然后又开发半小时。 步步为营,循序渐进。如果测试出错了,回退到之前测试通过的状态也比较容易。

4.3 发现代码坏味道(Code Smell),使用重构手法改进代码

4.4 好的代码设计是通过不断的重构中实现的