初识TDD(原理+实例)

工作两年多了,一直采用TDD(测试驱动开发),刚开始觉得是反人类的方法论,后来在使用的过程中逐渐发现它的妙处。本文介绍了一些TDD的基本概念,并结合几个小需求进行实践。由于本人能力、精力有限,如有错误或者不当之处,还请各位提出宝贵的建议。

1. TDD原理

TDD流程.png

步骤:

  1. 先写测试代码,并执行,得到失败结果
  2. 写刚好让测试通过的代码,并通过测试用例
  3. 识别坏味道,重构代码,并保证测试通过
  4. 反复实行这个步骤,测试失败 -> 测试成功 -> 重构

三原则:

  1. 除非是为了使一个失败的用例通过,否则不允许编写任何代码
  2. 在一个单元测试中,只允许编写刚好能够导致失败的内容
  3. 只允许编写刚好能够使一个失败的用例通过的代码

详细的介绍详见参考文献1和2。

2. TDD实例

话不多说,下面通过一个实际的例子说明。由于最近在看STL,就实现一个简单的Array容器类,此例子可能不太贴切,但大体上是那么个过程。
该例子采用C++语言(为了方便,暂时将代码全部放在.h文件中)和谷歌的gtest测试框架(详见参考文献3)。完整的代码详见参考文献4,已经在Ubuntu 18.04调试通过,如有编译及运行问题,欢迎提出。

2.1 需求一

模仿STL,实现一个数据类型为int的Array类,且能够指定长度,此需求只要求实现其构造和析构函数。
按照TDD的步骤,我们首先写出测试用例(Test.cpp文件):

#include "IntArray.h"
#include "gtest/gtest.h"

struct IntArrayTest : testing::Test
{

};

TEST_F(IntArrayTest, test_constructor)
{
    IntArray array{1};
    ASSERT_EQ(0, array[0]);
}

此时执行代码,编译是失败的。
然后写刚好让测试通过的代码(IntArray.h文件),并通过测试用例:

#ifndef INTARRAY_H_
#define INTARRAY_H_

#include <cassert>

struct IntArray
{
    IntArray() = default;

    IntArray(int len) : len(len)
    {
        assert(this->len >= 0);
        if(this->len > 0)
        {
            this->data = new int[this->len]{0};
        }
    }

    ~IntArray()
    {
        delete[] this->data;
    }

    int& operator [](int idx) const
    {
        assert(idx >= 0 and idx < this->len);
        return this->data[idx];
    }

private:
    int len;
    int* data;
};

#endif

至此实现了需求一,且代码和用例编译、运行通过。此时代码没有出现明显的坏味道,暂时不需要重构。但是此时有一个比较大的问题,不知各位有没有发现,由于我们的关注点不在此处,暂时不做解释,下文会有说明及修改。

2.2 需求二

实现一个size()方法,该方法返回IntArray的长度;实现一个erase()方法,该方法可以清除IntArray的所有内容。
其实需求二是两个小需求,首先写出测试用例一:

TEST_F(IntArrayTest, test_func_size)
{
    IntArray array{3};
    ASSERT_EQ(3, array.size());
}

此时编译失败,再写刚好让测试通过的代码:

int size() const
{
    return this->len;
}

此时编译运行通过,再写第二个小需求的测试用例:

TEST_F(IntArrayTest, test_func_erase)
{
    IntArray array{3};
    ASSERT_EQ(3, array.size());

    array.erase();
    ASSERT_EQ(0, array.size());
}

再写刚好让测试通过的第二个小需求的代码:

void erase()
{
    delete[] this->data;
    this->data = nullptr;
    this->len = 0;
}

此时代码也没有出现明显的坏味道,暂时不需要重构。

2.3 需求三

实现一个类似于STL的insert()方法,要求能够实现Array任意位置的插入,包括起始、中间和结束位置。
此处我们先写出在中间位置插入的用例:

TEST_F(IntArrayTest, test_func_insert)
{
    IntArray array{2};
    for(int idx = 0; idx < 2; ++idx)
    {
        array[idx] = idx;
    }
    ASSERT_EQ(0, array[0]);
    ASSERT_EQ(1, array[1]);

    int value = 3;
    int index = 1;
    array.insertBefore(value, index);
    ASSERT_EQ(3, array.size());
    ASSERT_EQ(0, array[0]);
    ASSERT_EQ(3, array[1]);
    ASSERT_EQ(1, array[2]);
}

再写出刚好能够使此测试用例通过的代码:

void insertBefore(int value, int index)
{
    assert(index >= 0 and index <= this->len);

    int* tmpData = new int[this->len + 1]{};
    for(int before = 0; before < index; ++before)
    {
        tmpData[before] = this->data[before];
    } 
    tmpData[index] = value;
    for(int after = index; after < this->len; ++after)
    {
        tmpData[after + 1] = this->data[after];
    }

    delete[] this->data;
    this->data = tmpData;
    ++this->len;
}

至此代码没有明显的坏味道,但是用例中出现了较多的ASSERT_EQ形式的重复。我们暂且忍一下,先实现我们其余的需求。
写出在起始位置插入的用例:

TEST_F(IntArrayTest, test_func_insert_at_begining)
{
    IntArray array{1};
    for(int idx = 0; idx < 1; ++idx)
    {
        array[idx] = idx;
    }
    ASSERT_EQ(0, array[0]);

    int value = 3;
    array.insertAtBegining(value);
    ASSERT_EQ(2, array.size());
    ASSERT_EQ(3, array[0]);
    ASSERT_EQ(0, array[1]);
}

再写出刚好使此用例通过的代码:

void insertAtBegining(int value)
{
    insertBefore(value, 0);
}

我们可以发现ASSERT_EQ形式的重复在增多,我们选择继续忍(毕竟Copy and Paste多舒服),先实现我们其余的需求。
写出在结束位置插入的用例:

TEST_F(IntArrayTest, test_func_insert_at_end)
{
    IntArray array{1};
    for(int idx = 0; idx < 1; ++idx)
    {
        array[idx] = idx;
    }
    ASSERT_EQ(0, array[0]);

    int value = 3;
    array.insertAtEnd(value);
    ASSERT_EQ(2, array.size());
    ASSERT_EQ(0, array[0]);
    ASSERT_EQ(3, array[1]);
}

再写出刚好使此用例通过的代码:

void insertAtEnd(int value)
{
    insertBefore(value, this->len);
}

2.4 代码重构

此时我们再也不能忍了,用例中重复的代码越来越多,代码重复是最严重的坏味道,必须消除。通过分析发现,重复代码无非两种类型,一种是IntArray的初始化,另一种是IntArray的校验。只需要将这些重复的代码提取到Fixture中即可,简单的重构如下:

struct IntArrayTest : testing::Test
{
   void initIntArr(IntArray& array) const
   {
       for(int idx = 0; idx < array.size(); ++idx)
       {
           array[idx] = idx;
       }
   }

   void assertIntArr(const IntArray& array, const int num) const
   {
       ASSERT_EQ(num, array.size());
       for(int idx = 0; idx < array.size(); ++idx)
       {
           ASSERT_EQ(array[idx], idx);
       }
   }
};

然后选择insert的用例重构如下:

TEST_F(IntArrayTest, test_func_insert)
{
    IntArray array{2};
    initIntArr(array);
    assertIntArr(array, 2);

    int value = 3;
    int index = 1;
    array.insertBefore(value, index);
    ASSERT_EQ(3, array.size());
    ASSERT_EQ(0, array[0]);
    ASSERT_EQ(3, array[1]);
    ASSERT_EQ(1, array[2]);
}

TEST_F(IntArrayTest, test_func_insert_at_begining)
{
    IntArray array{1};
    initIntArr(array);
    assertIntArr(array, 1);

    int value = 3;
    array.insertAtBegining(value);
    ASSERT_EQ(2, array.size());
    ASSERT_EQ(3, array[0]);
    ASSERT_EQ(0, array[1]);
}

TEST_F(IntArrayTest, test_func_insert_at_end)
{
    IntArray array{1};
    initIntArr(array);
    assertIntArr(array, 1);

    int value = 3;
    array.insertAtEnd(value);
    ASSERT_EQ(2, array.size());
    ASSERT_EQ(0, array[0]);
    ASSERT_EQ(3, array[1]);
}

此处的重构可能并不完美,但是重点是告诉大家要识别代码中的坏味道,并且主动去重构消除。

2.5 需求四

实现一个remove()方法,可以删除指定索引的元素。
相同的套路,首先写出用例:

TEST_F(IntArrayTest, test_func_remove)
{
    IntArray array{2};
    initIntArr(array);
    assertIntArr(array, 2);

    int index = 1;
    array.remove(index);
    ASSERT_EQ(1, array.size());
    ASSERT_EQ(0, array[0]);
}

相同的套路,再写出刚好能够使此测试用例通过的代码:

void remove(int index)
{
    assert(index >= 0 and index < this->len);

    if(this->len == 1)
    {
        erase();       
        return ;
    }

    int* tmpData = new int[this->len]{};
    for(int before = 0; before < index; ++before)
    {
        tmpData[before] = this->data[before];
    } 
    for(int after = index + 1; after < this->len; ++after)
    {
        tmpData[after - 1] = this->data[after];
    }

    delete[] this->data;
    this->data = tmpData;
    --this->len;
}

2.6 代码重构

此时我们可以发现在代码中出现了明显的重复,即insertBefore()函数和最新的remove()函数,最简单直接的方法是提取公共部分,如下:

...
struct IntArray
{
    void insertBefore(int value, int index)
    {
        assert(index >= 0 and index <= this->len);

        int* tmpData = new int[this->len + 1]{};
        copyBeforeData(tmpData, index);
        tmpData[index] = value;
        for(int after = index; after < this->len; ++after)
        {
            tmpData[after + 1] = this->data[after];
        }

        delete[] this->data;
        this->data = tmpData;
        ++this->len;
    }

    void remove(int index)
    {
        assert(index >= 0 and index < this->len);

        if(this->len == 1)
        {
            erase();       
            return ;
        }

        int* tmpData = new int[this->len]{};
        copyBeforeData(tmpData, index);
        for(int after = index + 1; after < this->len; ++after)
        {
            tmpData[after - 1] = this->data[after];
        }

        delete[] this->data;
        this->data = tmpData;
        --this->len;
    }

private:
    void copyBeforeData(int* tmpData, const int index) const
    {
        for(int before = 0; before < index; ++before)
        {
            tmpData[before] = this->data[before];
        }
    }
};
...

2.7 需求五

实现一个reallocate()函数,可以改变IntArray的size(),并且清空原有的元素。再实现一个resize()函数,可以改变IntArray的size(),但是保留原有的元素。
套路相同,对于此需求不再赘述,详见参考文献4。

2.8 需求六

将数据类型由int修改为double,实现上述的所有功能。
此时我们需要再重写一遍吗?当然不用。借助C++的泛型编码(模板机制,其实所有的STL都是采用模板实现,这样可以将数据和算法解耦;泛型编码和面向对象是C++重要的两个分支,只是它们考虑问题的方向不同)可以快速实现,详见参考文献4。

2.9 代码review

此时我们反观整体代码,有没有发现一些问题?提醒一下,内存方面的,其实内存管理一直是C++比较让人头痛的问题。是的,代码中有指针,若不实现显示的拷贝和赋值构造函数,会有浅拷贝的问题。具体详见参考文献5。

  1. 浅拷贝
    浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一块内存空间。当多个对象共用同一块内存资源时,若同一块资源释放多次,会发生崩溃或者内存泄漏。
  2. 深拷贝
    深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。

最终实现的拷贝和赋值构造函数如下:

template<typename T>
struct Array
{
    Array(const Array& array)
    {
        this->len = array.size();
        setData(array);
    }

    Array& operator=(const Array& array)
   {
        if(this == &array)
        {
            return *this;
        }

        delete[] this->data;
        this->len = array.size();
        setData(array);

        return *this;
    }

private:
    void setData(const Array& array)
    {
        if(array.size() > 0)
        {
            this->data = new T[array.size()]{};
            for(int idx = 0; idx < array.size(); ++idx)
            {
                this->data[idx] = array[idx];
            }
        }
    }
};

3. 总结

TDD只是一种实现方法,在某些场景下比较好用,在现实中我们要合理利用,没必要完完全全按照TDD的要求来做。
个人理解TDD算是敏捷开发的一种很好的实现方式,它的整体步骤如下:

  1. 先分解任务,分离关注点,实例化需求
  2. 写测试,只关注需求、程序的输入输出,不关心中间过程
  3. 写实现,不考虑别的需求,用最简单的方式实现当前这个小需求
  4. 手动测试一下,基本没什么问题,有问题再修复
  5. 重构,用手法消除代码里的坏味道
  6. 重复以上的步骤2, 3, 4和5
  7. 代码整洁且用例齐全,信心满满地提交

它有以下的优点:

  1. 提前澄清需求,明晰需求中的各种细节
  2. 小步快走,有问题能够及时修复
  3. 一个测试用例只关注一个点,降低开发者的负担
  4. 从整体来看,可以明显提升开发的效率

4. 参考文献

  1. tdd(测试驱动开发)的概述, https://blog.csdn.net/abchywabc/article/details/91351044
  2. 深度解读 - TDD(测试驱动开发), https://www.jianshu.com/p/62f16cd4fef3
  3. gtest的介绍和使用, https://blog.csdn.net/linhai1028/article/details/81675724
  4. https://github.com/mzh19940817/ArrayClass
  5. C++中深复制和浅复制(深拷贝和浅拷贝), https://zhangkaifang.blog.csdn.net/article/details/107865997

本文结合实例分享了TDD,文中可能有些许不当及错误之处,代码也没有用做到尽善尽美,欢迎大家批评指正,同时也欢迎大家评论、转载(请注明源出处),谢谢!

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

推荐阅读更多精彩内容