工作两年多了,一直采用TDD(测试驱动开发),刚开始觉得是反人类的方法论,后来在使用的过程中逐渐发现它的妙处。本文介绍了一些TDD的基本概念,并结合几个小需求进行实践。由于本人能力、精力有限,如有错误或者不当之处,还请各位提出宝贵的建议。
1. TDD原理
步骤:
- 先写测试代码,并执行,得到失败结果
- 写刚好让测试通过的代码,并通过测试用例
- 识别坏味道,重构代码,并保证测试通过
- 反复实行这个步骤,测试失败 -> 测试成功 -> 重构
三原则:
- 除非是为了使一个失败的用例通过,否则不允许编写任何代码
- 在一个单元测试中,只允许编写刚好能够导致失败的内容
- 只允许编写刚好能够使一个失败的用例通过的代码
详细的介绍详见参考文献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。
- 浅拷贝
浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一块内存空间。当多个对象共用同一块内存资源时,若同一块资源释放多次,会发生崩溃或者内存泄漏。 - 深拷贝
深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。
最终实现的拷贝和赋值构造函数如下:
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算是敏捷开发的一种很好的实现方式,它的整体步骤如下:
- 先分解任务,分离关注点,实例化需求
- 写测试,只关注需求、程序的输入输出,不关心中间过程
- 写实现,不考虑别的需求,用最简单的方式实现当前这个小需求
- 手动测试一下,基本没什么问题,有问题再修复
- 重构,用手法消除代码里的坏味道
- 重复以上的步骤2, 3, 4和5
- 代码整洁且用例齐全,信心满满地提交
它有以下的优点:
- 提前澄清需求,明晰需求中的各种细节
- 小步快走,有问题能够及时修复
- 一个测试用例只关注一个点,降低开发者的负担
- 从整体来看,可以明显提升开发的效率
4. 参考文献
- tdd(测试驱动开发)的概述, https://blog.csdn.net/abchywabc/article/details/91351044
- 深度解读 - TDD(测试驱动开发), https://www.jianshu.com/p/62f16cd4fef3
- gtest的介绍和使用, https://blog.csdn.net/linhai1028/article/details/81675724
- https://github.com/mzh19940817/ArrayClass
- C++中深复制和浅复制(深拷贝和浅拷贝), https://zhangkaifang.blog.csdn.net/article/details/107865997
本文结合实例分享了TDD,文中可能有些许不当及错误之处,代码也没有用做到尽善尽美,欢迎大家批评指正,同时也欢迎大家评论、转载(请注明源出处),谢谢!