轻松TDD之旅

作者: _张晓龙_ | 来源:发表于2016-07-27 20:56 被阅读893次

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的程序员。

相关文章

  • 轻松TDD之旅

    TDD简介 TDD是什么 TDD一般是Test Driven Development(测试驱动开发)的缩写,它以测...

  • TDD之旅

    关于TDD我都经历过什么: 最开始对TDD的理解很表面,只知道TDD就是先写测试,再写实现,由于大家(当然包括我自...

  • TDD 之旅

    最近两年由于敏捷社区的活跃TDD也受到了更多程序员关注。业内对TDD 争论一直没有停止有推崇的也有诋毁的,这个世界...

  • 深度解读 - TDD(测试驱动开发)

    本文结构: 什么是 TDD 为什么要 TDD 怎么 TDD FAQ 学习路径 延伸阅读 什么是 TDD TDD 有...

  • TDD和BDD

    TDD(Test-Driven Development)——测试驱动开发 1.为什么使用TDD: 1)TDD根据客...

  • 初识TDD

    什么是TDD 本文所说的 TDD 指狭义上的 TDD,也就是「单元测试驱动开发」。 TDD 是敏捷开发中的一项核心...

  • 为什么TDD很难在项目上推动?

    经常在TDD训练营中有学员提这个问题:学了TDD,在项目上也没法落地,为什么TDD很难在项目上推动? TDD本身就...

  • 认识 TDD

    什么是TDD? TDD 有广义和狭义之分,常说的是狭义的 TDD,也就是 UTDD(Unit Test Drive...

  • 测试驱动开发(TDD)总结——原理篇

    标签 | TDD Java 测试驱动开发(TDD)总结——原理篇

  • Robolectric

    开始单元测试之前还是要先了解TDD 中文版:TDD 已死?让我们再聊聊 TDD 英文版:Introduction ...

网友评论

    本文标题: 轻松TDD之旅

    本文链接:https://www.haomeiwen.com/subject/hpwojttx.html