轻松TDD之旅
TDD简介
TDD是什么
TDD一般是Test Driven Development(测试驱动开发)的缩写,它以测试作为开发过程的中心,要求在编写任何产品代码之前,首先编写用于定义产品代码行为的测试,而编写的产品代码又要以使测试通过为目标。TDD要求测试可以完全自动化地运行,在对代码进行重构前后必须运行测试。这是一种革命性的开发方法,能够造就简单、清晰和高质量的代码。
虽然TDD中T是第一个字母,但是:
- TDD是一项开发活动,而不是测试活动;
- 测试是手段,设计是目标。
TDD的过程
TDD的过程 = TDD的三个步骤 + TDD的三条规则
首先,我们看一下TDD的三个步骤,如下图所示:
tdd_procedure.png- 添加一个测试,测试变成红色;
- 快速使测试通过,测试变成绿色;
- 优化设计,不断重构,测试变成蓝色。
其次,我们了解一下TDD的三条规则:
- 不允许写任何产品代码,除非是为了让失败的测试用例能通过;
- 不允许写更多的产品代码,只要刚刚让失败的测试用例通过即可;
- 不允许写更多的测试代码,只要刚刚让测试失败即可,编译失败也算失败。
关键点
经过这几年的产品开发,笔者已将TDD作为最常用的XP实践之一,感受比较深的关键点有:
-
分离关注点
一个测试用例关注一个问题,不要写大而全的用例,同时用例是黑盒的,用例之间彼此独立,每个用例要保证自己的前置和后置完备。 -
小步快跑
添加一个测试用例,快速使测试通过,小步安全灵活流畅的持续重构。当测试失败时,就那么几行修改,通过走查代码就可以快速定位问题,可以真正做到debug free。
当在5分钟内解决不了测试失败的问题时,立即回滚,然后重新出发。
测试及时反馈,一直进行“红色->绿色->蓝色”的正向循环,人的奖励神经不断被刺激,长期处于兴奋中,经常忘记时间。 -
用例要对产品代码非入侵
just do no harm! 不要为了测试通过在产品代码里加各种预编译宏,不要为了测试通过给产品代码增加很多测试分支。我们要通过抽象和防腐层来解决测试问题,同时可以使用stub和mock技术。 -
测试代码和产品代码一样重要
产品代码的正确性有测试代码保证,那么测试代码的正确性谁来保证呢?当然是程序员自己。我们要把测试代码写得非常简单,让错误无处藏身。但实际情况是,很多程序员都不重视测试代码,写的测试代码可读性差,而且很长,非常难维护。我们要重视测试代码,让它保持简单、清晰、深合己意,并且富有表达力。我们要有一个好鼻子,当嗅到测试代码有坏味道时,要第一时间进行重构。
关于测试代码的重构,我给大家推荐一本书,书名是《xUnit测试模式:测试码重构》。
TDD实战
我们通过一个有趣的实战演练,轻松体验一段TDD之旅,零距离感受TDD的魅力。
众所周知,Fibnacci数列指的是这样一个数列:0、1、1、2、3、5、8、13、21、34、……
在数学上,斐波纳契数列以递归的方式定义:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2), n>=2, n是自然数
我们实战的题目是:使用C++语言以TDD的方式实现Fibonacci数列的通项计算函数fib(n)。
我们从简单需求fib(0) = 0开始,一路小步快跑,轻松过五关斩六将,最后演进出深合己意的实现。
需求一: fib(0) = 0
这个需求非常简单,我们先写测试用例:
//TestFib.cpp
#include <gtest/gtest.h>
#include "Fib.h"
TEST(fib, should_return_0_when_input_0)
{
ASSERT_EQ(0, fib(0));
}
快速使测试通过:
//Fib.cpp
#include "Fib.h"
int fib(int input)
{
return 0;
}
这个实现很简单,没有坏味道,需求一完成。
需求二:fib(1) = 1
先写测试用例:
//TestFib.cpp
TEST(fib, should_return_1_when_input_1)
{
ASSERT_EQ(1, fib(1));
}
快速使测试通过:
//Fib.cpp
#include "Fib.h"
int fib(int input)
{
return input;
}
这个实现依然很简单,用例很容易通过,我们可以增加一条异常用例:
//TestFib.cpp
TEST(fib, should_return_invalid_value_when_input_not_between_min_and_max)
{
ASSERT_EQ(-1, fib(2));
ASSERT_EQ(-1, fib(-1));
}
快速使测试通过:
//Fib.cpp
#include "Fib.h"
int fib(int input)
{
if (input > 1 || input < 0) return -1;
return input;
}
重构,消除magic number的坏味道:
//TestFib.cpp
#include "Fib.h"
#include "Const.h"
#include <gtest/gtest.h>
TEST(fib, should_return_invalid_value_when_input_not_between_min_and_max)
{
ASSERT_EQ(INVALID_VALUE, fib(MAX_LIMIT + 1));
ASSERT_EQ(INVALID_VALUE, fib(MIN_LIMIT - 1));
}
//Fib.cpp
#include "Fib.h"
#include "Const.h"
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
return input;
}
至此,需求二完成。
需求三:fib(2) = 1
先写测试用例:
//TestFib.cpp
TEST(fib, should_return_1_when_input_2)
{
ASSERT_EQ(1, fib(2));
}
快速使测试通过:
//Fib.cpp
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
if (input == 2) return 1;
return input;
}
目前代码还比较简单,继续做下一个需求。
需求四:fib(3) = 2
先写测试用例:
//TestFib.cpp
TEST(fib, should_return_2_when_input_3)
{
ASSERT_EQ(2, fib(3));
}
我们使用表驱动,快速使测试通过:
//Fib.cpp
namespace
{
int ret[] = {0, 1, 1, 2};
}
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
return ret[input];
}
目前代码很简洁,我们继续下一个需求。
需求五:fib(8) = 21
先写测试用例:
//TestFib.cpp
TEST(fib, should_return_21_when_input_8)
{
ASSERT_EQ(21, fib(8));
}
扩展表驱动,快速使测试通过:
//Fib.cpp
namespace
{
int ret[] = {0, 1, 1, 2, 3, 5, 8, 13, 21};
}
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
return ret[input];
}
目前代码还没有坏味道,我们继续下一个需求。
需求六:fib(80) = 23416728348467685
先写测试用例:
//TestFib.cpp
TEST(fib, should_return_23416728348467685_when_input_80)
{
ASSERT_EQ(23416728348467685, fib(80));
}
这时如果继续扩充表驱动,则非常麻烦,我们考虑到fib函数是一个递归函数,先快速使测试通过:
//Fib.cpp
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
if (input < 2) return input;
return fib(input - 1) + fib(input -2);
}
测试通过了,但是考虑到递归实现对堆栈的开销比较大,当input扩大时,不但运算速度很慢,而且有堆栈溢出的风险,所以我们需要重构,将绿色变成红色。
解决此类问题的通用方法一般是通过递推代替递归,我们按递归的思想重构代码如下:
//Fib.cpp
int fib(int input)
{
if (input > MAX_LIMIT || input < MIN_LIMIT) return INVALID_VALUE;
if (input < 2) return input;
int prev = fib(0);
int current = fib(1);
int next;
for (int i = 2; i <= input; i++)
{
next = prev + current;
prev = current;
current = next;
}
return current;
}
代码从绿色变成了红色,我们继续下一个需求。
需求七:fib(800) = 6928308186...8725
这个需求好比游戏中的大怪,是最后一关,不是容易攻克的。
首先,期望的数据长度非常非常长,基本数据类型根本容纳不下,其次涉及大数的加法,所以先不用急着写测试用例,因为如果写了用例后,会长时间不过,从而背离TDD小步快跑的初衷。
我们的基本思路是:
- 分解复杂度,拆分出to do list;
- 抽象返回值类型,不仅不用改既有用例,而且能应对新用例。
分解复杂度
实现大数的加法是我们的目标,我们从目标开始反向推演,为了使当前目标达成的这一步操作能够容易实现,它的上一步状态应该是什么?如此递归,一直到起点状态。
从目标反向推演,思路如下
- 通过字符串完成两个大数的加法;
- 为了使1能够容易完成,我们需要对字符串进行格式化,使得两个大数的位数相等,从而问题等价于两个长度相同的字符串加法;
- 为了使2能够容易完成,我们需要完成两个一位数字符的加法,有进位;
第3步已经很简单了,所以我们生成了to do list:[3, 2, 1]。
两个一位数字符的加法,有进位
先写一个测试用例:
//TestFib.cpp
TEST(charAdd, should_return_right_char_and_inc)
{
int inc = 0;
ASSERT_EQ('8', charAdd('2', '6', inc));
ASSERT_EQ(0, inc);
ASSERT_EQ('4', charAdd('8', '6', inc));
ASSERT_EQ(1, inc);
ASSERT_EQ('0', charAdd('3', '6', inc));
ASSERT_EQ(1, inc);
}
快速使测试通过:
//Fib.cpp
char charAdd(char first, char second, int& inc)
{
int add = (first - '0') + (second - '0') + inc;
inc = add / 10;
return add % 10 + '0';
}
这个实现很简单,没有坏味道,我们继续。
两个大数位数不等时,需要格式化
我们先写一个测试用例:
//TestFib.cpp
TEST(formatString, should_make_two_string_length_equal)
{
string first = "123456";
string second = "789";
formatString(first, second);
ASSERT_EQ(6, first.length());
ASSERT_EQ(6, second.length());
}
快速使测试通过:
//Fib.cpp
void formatString(string &first, string &second)
{
int firstLen = first.length();
int secondLen = second.length();
if (firstLen < secondLen)
{
first.insert(first.begin(), secondLen - firstLen, '0');
}
else if (firstLen > secondLen)
{
second.insert(second.begin(), firstLen - secondLen, '0');
}
}
该函数的实现比较简洁,不需要重构,我们继续。
两个大数相加
我们先写一个测试用例:
//TestFib.cpp
TEST(stringAdd, should_return_124245_when_input_123456_and_789)
{
string first = "123456";
string second = "789";
ASSERT_EQ("124245", stringAdd(first, second));
}
快速使测试通过:
//Fib.cpp
string stringAdd(string first, string second)
{
formatString(first, second);
char add;
int inc = 0;
string result;
for(int i = first.length() - 1; i >= 0 ; i--)
{
add = charAdd(first[i], second[i], inc);
result.insert(result.begin(), add);
}
if (inc == 1)
{
result.insert(0, "1");
}
return result;
}
实现fibBigNum(800)
为了不影响既有测试,我们通过扩展接口实现fibBigNum(800),先增加测试用例:
//Fib.cpp
TEST(fibBigNum, should_return_XXX_when_input_800)
{
ASSERT_EQ("69283081864224717136290077681328518273399124385204820718966040597691435587278383112277161967532530675374170857404743017623467220361778016172106855838975759985190398725", fibBigNum(800));
}
参考fib的实现,快速实现fibBigNum,使测试通过:
//Fib.cpp
string fibBigNum(int input)
{
string prev = "0";
string current = "1";
string next;
for (int i = 2; i <= input; i++)
{
next = stringAdd(prev, current);
prev = current;
current = next;
}
return current;
}
当前实现使得测试通过了,但是测试用例中用的不是fib函数,而是新增函数fibBigNum,所以我们需要将fib函数和fibBigNum函数很优雅的合一,这时就要用到抽象这个强大的屠龙刀了。
返回值的抽象
考虑引入FibType类型,作为fib的返回值类型:
- 它既可以通过int构造,也可以通过string构造;
- 它既可以和int进行比较,也可以和string进行比较。
我们写一个测试用例:
//TestFib.cpp
TEST(FibType, should_return_equal_when_compare_different_type)
{
FibType fibTypeInt(100);
ASSERT_EQ(100, fibTypeInt);
ASSERT_EQ("100", fibTypeInt);
FibType fibTypeStr("100");
ASSERT_EQ(100, fibTypeStr);
ASSERT_EQ("100", fibTypeStr);
}
快速实现代码:
//FibType.h
#include <string>
struct FibType
{
FibType(int num);
FibType(std::string str);
friend bool operator==(long long expect, const FibType& actual);
friend bool operator==(std::string expect, const FibType& actual);
private:
std::string str;
};
//FibType.cpp
#include "FibType.h"
#include <string>
#include <sstream>
using namespace std;
namespace
{
string toString(long long num)
{
stringstream ss;
ss << num;
return ss.str();
}
}
FibType::FibType(int num)
{
str = toString(num);
}
FibType::FibType(std::string str) : str(str)
{
}
bool operator==(long long expect, const FibType& actual)
{
return toString(expect) == actual.str;
}
bool operator==(std::string expect, const FibType& actual)
{
return expect == actual.str;
}
完成了抽象后,我们就可以用返回值类型FibType代替int和string了,从而将fib和fibBigNum两个函数合一,这就是我们的终极重构。
终极重构
先修改测试用例,将fibBigNum修改为fib:
TEST(fib, should_return_XXX_when_input_800)
{
ASSERT_EQ("69283081864224717136290077681328518273399124385204820718966040597691435587278383112277161967532530675374170857404743017623467220361778016172106855838975759985190398725", fib(800));
}
然后重构实现代码,将fibBigNum和fib合一,并将返回值修改为FibType:
FibType fib(int input)
{
if (input < MIN_LIMIT || input > MAX_LIMIT)
{
return FibType(INVALID_VALUE);
}
if (input < 2)
{
return FibType(input);
}
string prev("0");
string current("1");
string next;
for(int i = 2; i <= input; i++)
{
next = stringAdd(prev, current);
prev = current;
current = next;
}
return FibType(current);
}
当然我们还可以继续重构过程中分解出来的非外部接口:
char charAdd(char first, char second, int& inc);
void formatString(std::string& first, std::string& second);
std::string stringAdd(std::string first, std::string second);
重构思路其实很简单,即先将相关的测试用例删除,然后将这几个函数的实现放到匿名的命名空间中。
小结
通过轻松TDD之旅,我们体会到:
- 坚持小步快跑,使得测试有正向变色过程,即红色->绿色->蓝色;
- 当遇到复杂需求时,先分解复杂度,即从目标开始反向推演,形成to do list,然后再开始;
- 在演进式开发中,如果既有产品代码和测试代码受到了比较大的冲击,那么我们一定要想到抽象这个强大的屠龙刀,记住抽象、抽象还是抽象。
至此,轻松TDD之旅已到终点,但是我们以终为始,在工作和学习中不断修炼自己的TDD能力,开发出高质量的代码,做一个play而不pray的程序员。