测试驱动开发(TDD)实践与技巧
Posted 芥末的无奈
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了测试驱动开发(TDD)实践与技巧相关的知识,希望对你有一定的参考价值。
引言
测试驱动开发,英文全称 Test-Driven Development,简称 TDD,是一种不同于传统软件开发流程的开发方法。
在《程序员的职业素养》第五章,我第一次看到有关 TDD 内容,当时Bob大叔向我展示了一种不可思议的编程开发方法,这种方法颠覆了我的认知。Bob 大叔列举了 TDD 很多好处,例如确定性、降低代码缺陷、方便代码重构、测试单元文档化,更优秀的代码设计等等。并强力安利读者们尝试 TDD,因为 TDD 是专业人士的选择。
在 Bob 大叔的洗脑下,我决定去尝试学习如何使用 TDD。从写第一行 TDD 代码到现在,已经大概一年了,这期间我一直坚持用 TDD 的方式来写代码。还没毕业之前,需要写的代码比较少,鲜有机会锻炼 TDD 能力。进入公司工作后,就有大把的机会用 TDD 进行开发,对 TDD 的理解逐渐有一个比较全面的认知。此次要分享的目的就是向你展示实际使用 TDD 的系统方法,你将会学到:
- TDD的基本工作方式
- TDD的潜在好处
- TDD怎杨帮助解决设计缺陷
- TDD的难点和成本
- TDD怎么减少甚至免除调试工作
- 怎样长时间维持TDD
- 如何编写高质量的测试
另外,还会分享我这段时间使用 TDD 的一些感受,以及 TDD 对我开发思维的影响
Google Mock
在开始 TDD 之前,需要说明一些关于测试框架的内容,这一点不是必须的,你完全可以用assert
来完成测试的工作,但是测试框架提供了更好编码体验,能够提高开发效率,你只需要花一点点的时间学习它,就能获得巨大的收获,何乐而不为呢。
本文所有的例子都是 C++ 代码,所用的测试框架为 Google Mock,它是一种模拟(Mock)和匹配器的框架,其中包含了单元测试工具 Google Test。
详细的安装以及使用,请参考 快速上手Google C++测试框架。这里我们简单重述一遍如何使用 Google Mock,以便你可以不被打断的继续看下去。
测试用例结构
一个测试用例,在 Google Mock 下,大致是这样的结构:
TEST(TestSuiteName, TestName)
{
... test body ...
}
TestSuiteName
是测试套件名称,TestName
测试用例名称。'测试套件’这个翻译很怪,让人不知所云,其实它是许多测试用例的集合,可以认为是一种逻辑上分组。
从左到右阅读测试套件和测试用例的名称,可以连成一句话,描述了我们想要验证的行为,例如下面的代码中:Set insert ignores duplicate values
TEST(SetInsert, IgnoresDuplicateValues)
{
std::set<int> a_set;
int val = 0;
a_set.insert(val);
a_set.insert(val);
ASSERT_THAT(a_set.size(), Eq(1));
}
断言
使用自动断言是自动化测试的关键,Google Mock 中提供了两种断言:
- ASSERT_* 失败是会终止当前函数,ASSERT_* 后面的代码将不会运行
- EXPECT_* 失败时不会终止,EXPECT_* 后面的代码将会继续运行
Google Mock 提供了两种形式的断言:经典式 和 Hamcrest 断言
经典式断言
下表列出了 经典式断言 的两个主要断言。其他框架也会使用类似的名称
形式 | 描述 | 实例 |
---|---|---|
ASSERT_TRUE(表达式) | 表达式返回假(或者0),测试失败 | ASSERT_TRUE(4<7) |
ASSERT_EQ(期待值,实际值) | 期待值和实际值不等时,测试失败 | ASSERT_EQ(4, 20/5) |
更多关于经典断言的内容请参考:
Hamcrest 断言
Hamcrest 断言是为了提高测试的表达能力,创建复杂断言的灵活性,以及测试错误所提供的信息
Hamcrest 断言使用匹配器比较实际结果。匹配器可以组成复杂但易懂的比较表达式。你也可以自己定义匹配器。
几个简单的示例胜过千言万语
TEST(StringTest, StringEq)
{
string actual = string("al") + "pha";
ASSERT_THAT(actual, Eq("alpha"));
}
断言可以从左到右读作:断定实际值等于"alpha"。对比与 ASSERT_EQ(actual, "alpha")
,Hamcrest 断言用区区几个额外的字符就提高了阅读性。
匹配器的价值在于它能极大地提升测试的表达能力,许多匹配器能够减少所需的代码量,同时也能提升测试的抽象层次。
ASSERT_THAT(actual, StartsWith("alx"));
Hamcrest断言在提升失败信息的可读性方面意义更大。
Value of: actual
Expected: starts with "alx"
Actual: "alpha"
Hamcrest 断言真的很 Coooool,如果你想进一步了解它,可以参考 快速上手Google C++测试框架 中关于匹配器的部分。
关于 Google Mock 的部分,我们就此打住,了解了以上的内容,你已经可以无障碍继续阅读了。
太棒了!如果你坚持看到了这里,那么你已经穿好鞋,准备好向 TDD 出发了。相信我,这趟旅途一定会让你受益匪浅。
测试驱动开发:第一个示例
开场白
写个测试,保证它通过,接着重构设计。这就是TDD的全部内容。但是这个三个简单的步骤背后却另有乾坤。就像踢足球一样,规则简单,但是想要踢得好,就需要进行训练以及学习相应的技巧。为了让你更快的理解TDD,这里我举个例子,让我们一起用TDD的形式开发一个小工具。这个实例提供了很多教学点,展示了TDD如何增量地设计一个程序。
场景:字节转换器
我们需要一个类,它能将一组字节转换成一组音频采样点(float),这样可以方便音频算法处理
再提供两个强假设:
- 字节都是以小端格式排列的
- 音频数据是 16 bit的,也就是说两个字节构成一个采样点(float)
开始吧
OK,让我们开始,上面的场景告诉我们,有一组字节(byte),我们需要将其转换成采样点…。等等!我们应该从最简单的开始,如果这个字节数组只有两个字节呢?为此我们写个测试:
#include <gmock/gmock.h>
TEST(AByteConverter, ConvertsTwoBytesToOneFloat)
{
ByteConverter converter;
}
- 第一行代码包含了gmock的头文件
- 一个简单的测试声明需要使用 TEST 宏。TEST宏包括两个参数:测试套件名称和测试用例名称。从左右往右阅读测试套件和测试用例名称,可以连成一句话,描述了我们想要验证的行为:A Byte Converter converts two bytes to one float。
- 我们创建了一个
ByteConverter
对象,然后…,停止!在写更多测试代码之前,我们已经加入了一些不能通过编译的代码:我们还没有定义ByteConverter
类,先停下来解决这个问题。这个方法和TDD的三条规则保持一致:- 只在为了使失败的测试用例通过时才编写产品代码
- 当测试刚好失败时,停止继续编写。编译失败也是失败。
- 只编写刚好让一个失败测试用例通过的代码
我们现在编译失败了(原则二),因此我们停止编写测试,开始编写产品代码(原则一)。这种增量地获得反馈是很好的办法,因为有时候只需要几行代码,就能产生一大堆的编译错误。若能够及时的看到代码产生的错误,那么就可以更容易的解决它们。
编译器提示我们需要 ByteConverter
类。我们可以添加一对.h/.cpp文件,但是先别自找麻烦。相反,不要急于使用独立文件,先简单地在测试文件中包含所有东西。在最后提交代码的时候,或者苦于所有东西都放在同一个文件的时候,再以适当的方式把产品代码独立出去。这种方法可以减少一直在文件间来回切换的开销,是一种短期内不引入复杂开销而节省时间的方式。
#include <gmock/gmock.h>
class ByteConverter
{
};
TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
ByteConverter converter;
}
如果你无法忍受将测试代码和产品代码暂时放在一起的做法,那就马上把东西分散到不同的文件吧,依然可以继续我们的示例。但是我建议你先试试这种方法,因为它可能是一种更有效的工作方式。
我们遵循了原则三:只编写刚好让测试通过的代码。很显然,我们并没有完成测试,ConvertTwoBytesToFloat
没有测试任何行为,所以它验证不了什么,但是我们却对负反馈(编译失败)采取了应对措施,加入了足够的代码消除它。
构建并运行上面的测试就能得到正反馈了,如下:
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from AByteConverter
[ RUN ] AByteConverter.ConvertTwoBytesToOneFloat
[ OK ] AByteConverter.ConvertTwoBytesToOneFloat (0 ms)
[----------] 1 test from AByteConverter (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[ PASSED ] 1 test.
测试通过,欢乐时刻!虽然这个测试除了创建一个空类之外,没有干任何事情。但是,我们已经有了一些基本要素。并且,我们已经验证目前代码是正确的。如果你include的头文件写错了,或者class定义的分号忘记加了,那么你将第一时间发现这些错误。所以要尽早并频繁地测试,通常测试失败的原因只有一个。
继续吧!往测试中加入几行代码,来表示我们预期的客户端与ByteConverter
对象交互方式
#include <gmock/gmock.h>
#include <vector>
class ByteConverter
{
};
TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);
}
如上述代码所示,我们暴露了一个convertTwoBytesToFloat
公共函数。尝试编译会失败,因为convertTwoBytesToFloat
不存在。这个负反馈迫使我们去编写足够的代码,让测试通过编译。
class ByteConverter
{
public:
float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
return 0.0f;
}
};
现在代码可以编译了,所有测试也通过了。是时候验证一些东西了:给定两个字节,convertTwoBytesToFloat
能够正确一个float返回吗?我们加入一个验证结果的断言:
TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(resutl, ::testing::FloatEq(1.0));
}
断言可以用来验证结果是否符合预期。上面代码中断言中声明了convertTwoBytesToFloat
返回的值等于1.0。现在编译通过了,但是断言却失败了。
乍一看,Google Mock给出的信息可能不太容易读懂。从最后一行开始看,它提示我们有一个测试没有通过,并且在 [RUN] 和 [FAILED] 之间信息帮助我们了解测试为什么失败。在上面的例子中,可以看到下面信息:
Value of: result
Expected: is approximately 1
Actual: 0 (of type float)
断言信息非常明确:Google Mock期待 result
的值应该约等于1,但实际上却是0.
断言是意料之中的,因为为了通过编译,我们特意硬编码了一个0
。得到这个负反馈是好事,也是TDD周期中可以发生的事情。首先,我们保证新加的测试不能通过,这表示这个功能没有被显示(有时候是通过的,但这通常不是个好事)。起初测试是失败,在加入适当的代码后测试通过了,这也说明测试时可靠的。
失败的断言提醒我们编写仅通过断言的代码即可。如下:
class ByteConverter
{
public:
float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
int16_t result = byte_2nd | byte_1st;
return (float)(result);
}
};
现在编译并运行。最后两行显示测试通过了:
去掉不干净的代码
啥?我们才写了两行产品代码和四行测试代码就有问题?当然,区区几行代码也容易引入缺陷。让我们先审阅一下刚写的代码,找一找缺陷吧。有一点是确定的,测试中的断言不是非常便于阅读。
ASSERT_THAT(result, ::testing::FloatEq(1.0));
我们希望断言阅读起来想个句子,但是::testing::
却妨碍了阅读。我们引入using来帮忙:
#include <gmock/gmock.h>
#include <vector>
using namespace testing;
TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(result, FloatEq(1.0));
}
现在断言看起来好多了,我们称这个小小的改动为重构,在不改变现有行为的前提下改进了设计。
另外,byte_2nd | byte_1st
看起来不知所云,还有(float)(result)
强制转换不是良好的编程风格,我们进一步重构:
class ByteConverter
{
public:
float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
return static_cast<float>(convertTwoBytesToInt16(byte_1st, byte_2nd));
}
private:
int16_t convertTwoBytesToInt16(uint8_t byte_1st, uint8_t byte_2nd)
{
return byte_2nd | byte_1st;
}
};
我们使用static_cast
替换了强制转换,并封装convertTwoBytesToInt16
用来提高了代码的可阅读性。
现在看起来好多了,重新编译并且运行,保证测试时通过。
增量性
我们已经完成了将两个比特转换成浮点数值的函数,如果了解过音频的同学可能注意到了,我们编写的代码不符合音频处理的使用习惯,因为音频处理中,采样点的范围应该是[-1, 1]。就目前来说,我们的代码中没有做归一化,所以它并不符合规范。所以数值的归一化是我们接下去要做的事情。
为了测试新的行为,我们需要写一个新的单元测试:
TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);
}
同样的,convertTwoBytesToNormalizedFloat
没有定义,所以编译不过。因此编写刚好让编译通过的代码:
float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
return 0.0f;
}
然后编写断言:
TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(normalized_result, FloatEq(1.0/32768));
}
断言失败,这是预期之中的。接下去,写刚好让测试通过的代码:
float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
return convertTwoBytesToFloat(byte_1st, byte_2nd)/32768.0f;
}
新的测试通过了!但是代码中去出现了一些坏味:32768这个数字不知所云,并且重复了两次,一次在测试之中,一次在产品代码中。该进行重构了:
class ByteConverter
{
public:
constexpr static float MAX_16BIT_VAL = 32768.0f;
float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
return convertTwoBytesToFloat(byte_1st, byte_2nd)/MAX_16BIT_VAL;
}
...
};
TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(normalized_result, FloatEq(1.0/ByteConverter::MAX_16BIT_VAL));
}
我们将32768提取为静态常量,消除了硬编码的重复,并且提供了代码的可阅读性。
fixture 设置
在重构的时候,不仅要审阅产品代码,还要审阅测试代码。如上所述,我们的测试都要创建 ByteConverter
实例,并且使用相同的代码。我们不乐意看到这种貌似无关紧要的重复代码。这些重复会积累的很快,并且通常会演化为更为复杂的重复代码。这也会让测试变得主次不分,对于阅读代码的来说,这会分散注意力,从而忽视了真正需要关注的内容。
相关的测试拥有一些共同的代码是常见的,Google Mock 允许我们定义一个 fixture 类,我们可以在这个类里为相关的测试声明函数和数据。
class AByteConverter : public Test
{
public:
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
};
TEST_F(AByteConverter, ConvertTwoBytesToFloat)
{
float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(result, FloatEq(1.0));
}
TEST_F(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);
ASSERT_THAT(normalized_result, FloatEq(1.0/ByteConverter::MAX_16BIT_VAL));
}
上述代码中,我们创建了一个名为 AByteConverter
的 fixture(必须继承::testing::Test
)。在 fixture 内部我们声明了公共变量 converter
和 two_bytes
,以便测试可以访问。
Google Mock 运行每个单元测试时,会创建fixture实例。也就是说,在运行 ConvertTwoBytesToFloat
之前,它会创建一个 AByteConverter
实例。在运行ConvertTwoBytesToNormalizedFloat
之前,创建另一个 AByteConverter
实例。
为了使用fixture,我们将宏TEST
改成了TEST_F
,F
表示fixture。如果忘记加F
,所有测试都会失败。
去除掉重复的测试代码至少了两个好处:
- 提高了测试的抽象度。现在每个测试只有两行代码,这有助我们集中精力关注与测试相关的东西
- 可以减低未来的维护测试的开销。试想一下,如果
ByteConverter
的构造函数发生变化,那么只需要改动一个地方即可。
思索与测试驱动开发
简单的说,TDD的周期就是写一个测试,先确保测试失败,然后编码让其通过,接着审阅代码和打磨设计(包括测试的设计),最后保证所有测试仍然通过。在一天中,你不断的重复这个周期,保持周期尽量小,以便得到更多的反馈。
让我们继续下一个测试,我们传入一组字节,得到一组浮点值。沿着这个思路想下去,我们首先会考虑,返回的个数是多少?Ok,为了验证这个行为,让我们来写一个测试:
TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
int num_bytes = 5;
std::vector<uint8_t> five_bytes(num_bytes);
auto floats = converter.convertBytesToFloat(five_bytes);
}
我们编写了一个新的函数convertBytesToFloat
,它返回一组浮点数。同样的,这里提示编译失败,写一个刚好让编译通过的代码:
std::vector<float> convertBytesToFloat(const std::vector<uint8_t>& bytes)
{
return std::vector<float>();
}
编译通过,接着写测试代码:
TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
int num_bytes = 5;
std::vector<uint8_t> five_bytes(num_bytes);
auto floats = converter.convertBytesToFloat(five_bytes);
ASSERT_THAT(floats.size(), Eq(num_bytes/2));
}
测试失败。然后接着写产品代码:
std::vector<float> convertBytesToFloat(const std::vector<uint8_t>& bytes)
{
auto num_floats = (bytes.size() / 2);
std::vector<float> floats(num_floats);
return floats;
}
在编译运行,测试通过。值得庆幸的是,上面的代码没有需要重构的地方,至少现在没有。所以我们赶紧进入下一个测试。
很自然的,下一个测试中我们应该验证其转换的浮点值是否正确:
TEST_F(AByteConverter, ConvertsBytesToFloats)
{
std::vector<uint8_t> five_bytes{
0x01, 0x00, // 1
0x02, 0x00, // 2
0x00
};
auto floats = converter.convertBytesToFloats(five_bytes);
ASSERT_THAT(floats[0], Eq(1));
ASSERT_THAT(floats[0], Eq(2));
}
很明显,测试失败了:
非常好,这是符合预期的失败,我们接着写让测试刚好通过产品代码:
std::vector<float> convertBytesToFloats(const std::vector<uint8_t> &bytes)
{
auto num_floats = (bytes.size() / 2);
std::vector<float> floats(num_floats);
for(int i = 0; i < num_floats; ++i)
{
floats[i] = convertTwoBytesToFloat(bytes[i*2], bytes[i*2 + 1]);
}
return floats;
}
接着编译运行,所有测试都通过了。
重构时间到!现在的代码中有好几处可以进行改进:
convertBytesToFloats
函数中,2
多次出现,并且含义不明确- 新的测试代码中,用了两个
ASSERT_THAT
来验证结果。但是如果返回的数组包含多个值呢?岂不是要写一大串的ASSERT_THAT
才能验证结果?其实我们只需要一个ASSSERT_THAT
就可以对比容器的内容了 five_bytes
变量在两个单元测试中都出现了,可以通过fixture来消除重复
先从最简单的重构开始,消除重复的five_bytes
class AByteConverter : public ::testing::Test
{
public:
ByteConverter converter;
std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
std::vector<uint8_t> five_bytes
{
0x01, 0x00, // 1
0x02, 0x00, // 2
0x00
};
};
TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
auto floats = converter.convertBytesToFloats(five_bytes);
ASSERT_THAT(floats.size(), Eq(five_bytes.size()/2));
}
TEST_F(AByteConverter, ConvertsBytesToFloats)
{
auto floats = converter.convertBytesToFloats(five_bytes);
ASSERT_THAT(floats[0], Eq(1));
ASSERT_THAT(floats[1], Eq(2));
}
记得每次重构完成后都要重新运行单元测试,确保重构没有改变代码行为。
然后消除多余的ASSERT_THAT
,只需要添加一个匹配器
class AByteConverter : public ::testing::Test
{
public:
ByteConverter converterTDD测试驱动开发的实践心得