轻松TDD之旅

TDD简介

TDD是什么

TDD一般是Test Driven Development(测试驱动开发)的缩写,它以测试作为开发过程的中心,要求在编写任何产品代码之前,首先编写用于定义产品代码行为的测试,而编写的产品代码又要以使测试通过为目标。TDD要求测试可以完全自动化地运行,在对代码进行重构前后必须运行测试。这是一种革命性的开发方法,能够造就简单、清晰和高质量的代码。

虽然TDD中T是第一个字母,但是:

  1. TDD是一项开发活动,而不是测试活动;
  2. 测试是手段,设计是目标。

TDD的过程

TDD的过程 = TDD的三个步骤 + TDD的三条规则

首先,我们看一下TDD的三个步骤,如下图所示:

tdd_procedure.png
  1. 添加一个测试,测试变成红色;
  2. 快速使测试通过,测试变成绿色;
  3. 优化设计,不断重构,测试变成蓝色。

其次,我们了解一下TDD的三条规则:

  1. 不允许写任何产品代码,除非是为了让失败的测试用例能通过;
  2. 不允许写更多的产品代码,只要刚刚让失败的测试用例通过即可;
  3. 不允许写更多的测试代码,只要刚刚让测试失败即可,编译失败也算失败。

关键点

经过这几年的产品开发,笔者已将TDD作为最常用的XP实践之一,感受比较深的关键点有:

  1. 分离关注点
    一个测试用例关注一个问题,不要写大而全的用例,同时用例是黑盒的,用例之间彼此独立,每个用例要保证自己的前置和后置完备。

  2. 小步快跑
    添加一个测试用例,快速使测试通过,小步安全灵活流畅的持续重构。当测试失败时,就那么几行修改,通过走查代码就可以快速定位问题,可以真正做到debug free。
    当在5分钟内解决不了测试失败的问题时,立即回滚,然后重新出发。
    测试及时反馈,一直进行“红色->绿色->蓝色”的正向循环,人的奖励神经不断被刺激,长期处于兴奋中,经常忘记时间。

  3. 用例要对产品代码非入侵
    just do no harm! 不要为了测试通过在产品代码里加各种预编译宏,不要为了测试通过给产品代码增加很多测试分支。我们要通过抽象和防腐层来解决测试问题,同时可以使用stub和mock技术。

  4. 测试代码和产品代码一样重要
    产品代码的正确性有测试代码保证,那么测试代码的正确性谁来保证呢?当然是程序员自己。我们要把测试代码写得非常简单,让错误无处藏身。但实际情况是,很多程序员都不重视测试代码,写的测试代码可读性差,而且很长,非常难维护。我们要重视测试代码,让它保持简单、清晰、深合己意,并且富有表达力。我们要有一个好鼻子,当嗅到测试代码有坏味道时,要第一时间进行重构。
    关于测试代码的重构,我给大家推荐一本书,书名是《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小步快跑的初衷。

我们的基本思路是:

  1. 分解复杂度,拆分出to do list;
  2. 抽象返回值类型,不仅不用改既有用例,而且能应对新用例。

分解复杂度

实现大数的加法是我们的目标,我们从目标开始反向推演,为了使当前目标达成的这一步操作能够容易实现,它的上一步状态应该是什么?如此递归,一直到起点状态。

从目标反向推演,思路如下

  1. 通过字符串完成两个大数的加法;
  2. 为了使1能够容易完成,我们需要对字符串进行格式化,使得两个大数的位数相等,从而问题等价于两个长度相同的字符串加法;
  3. 为了使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的返回值类型:

  1. 它既可以通过int构造,也可以通过string构造;
  2. 它既可以和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之旅,我们体会到:

  1. 坚持小步快跑,使得测试有正向变色过程,即红色->绿色->蓝色;
  2. 当遇到复杂需求时,先分解复杂度,即从目标开始反向推演,形成to do list,然后再开始;
  3. 在演进式开发中,如果既有产品代码和测试代码受到了比较大的冲击,那么我们一定要想到抽象这个强大的屠龙刀,记住抽象、抽象还是抽象。

至此,轻松TDD之旅已到终点,但是我们以终为始,在工作和学习中不断修炼自己的TDD能力,开发出高质量的代码,做一个play而不pray的程序员。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,423评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,339评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,241评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,503评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,824评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,262评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,615评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,337评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,989评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,300评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,829评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,193评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,753评论 3 230
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,970评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,708评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,295评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,207评论 2 258

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,537评论 25 707
  • 本文结构: 什么是 TDD 为什么要 TDD 怎么 TDD FAQ 学习路径 延伸阅读 什么是 TDD TDD 有...
    SeabornLee阅读 72,547评论 16 166
  • 1.测试与软件模型 软件开发生命周期模型指的是软件开发全过程、活动和任务的结构性框架。软件项目的开发包括:需求、设...
    Mr希灵阅读 21,831评论 7 277
  • 1.测试与软件模型 软件开发生命周期模型指的是软件开发全过程、活动和任务的结构性框架。软件项目的开发包括:需求、设...
    宇文臭臭阅读 6,662评论 5 100
  • 35岁那年,韩晓静报名参加了县城一个会计培训班,1500块钱的报名费让她的手哆嗦了一下。 更让她哆嗦的是一进家门,...
    我在他城阅读 597评论 0 0