使用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行 - 文言
Buzz
、Fizz
没有定义常量
3.2 使用TDD实现
工具
- 系统:mac os
- 开发环境:IntelliJ IDEA
- 编程语言:Java8
- 构建系统:Gradle 6
- 单元测试框架:Junit 5
TDD步骤
TESTS FAILED
先写测试代码,并执行,得到失败结果TESTS PASSED
写实现代码让测试通过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 好的代码设计是通过不断的重构中实现的