轻松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之旅

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