1. 概述
英文原文地址 concepts/best-practice-for-testing.md
本文是针对 iceoryx 的测试编写指南,旨在覆盖大多数常见的测试情况,大约适用于 99% 的测试用例。这些指南并不是绝对的规则,而是建议,遇到不适用的情况时,可以使用常识来判断。若测试的半数以上不符合这些指南,则明显需要重构。
本文假设读者已经具备 gtest
的基础知识,至少应阅读 Googletest Primer 文档后再继续阅读本指南。
编写测试时,不要仅仅为了高覆盖率而编写测试。首先,测试必须具有意义,验证代码,以防止错误和回归。新代码的编写应考虑可测试性。对于不可测试的遗留代码,应进行重构。
通常,应使用 Arrange Act Assert 模式。这使得隔离测试失败变得简单,因为每次只测试一个状态转换。这两篇博客 帖子 更详细地解释了 AAA 模式。
尽管 AAA 模式提供了合理的结构,ZOMBIES 原则可以帮助你找到合理的测试用例。
Z = Zero
O = One
M = Many (or More complex)
B = Boundary Behaviors
I = Interface Definition
E = Exercise Exceptional Behavior
S = Simple Scenarios, Simple Solutions
可以将其分为 ZOM 和 BIE 两部分,其中 S 用于将两者连接在一起。ZOM 通常是简单的测试,比如 一个零元素的向量是空的 或 一个包含一个元素的向量不是空的 或 一个包含 N 个元素的向量的大小是 N。BIE 部分处理边界情况,比如 对 Number::max 加一会饱和 或 除以零返回错误。后者与 ZOM 部分的情况重叠,这表明这些情况并非总是明确分开的。
Exercise Exceptional Behavior 意味着不仅测试正常情况,还要测试负面情况。基本上,你应该测试一些无意义的输入并检查其优雅的行为,如前面的除以 0 示例。相关的博客文章更详细地解释了 负面测试 。
这些关键词可以用于绘制简单的场景,适合 AAA 模式。一些非详尽的场景包括:
- overflow
- underflow
- wrap around
- empty
- full
- out of bounds
- timeouts
- copy
- are the objects equal
- is the copy origin unchanged
- etc.
- move
- is the move destination object cleaning up its resources
- is the move origin object in a defined but unspecified state
- etc.
- etc.
根据 Hyrum’s Law 的宽松理解,用户足够多时,会找到超出预期的使用方式。因此,不要低估聪明或愚蠢的创造力。
在某些情况下,可能需要在堆上实例化对象。虽然生产代码中不允许这样做,但在测试代码中是可以的。为了避免使用 new/delete 进行手动内存管理,尽可能使用智能指针。作为提醒,如果方法接受一个对象的指针,则可以在栈上实例化该对象,并将该对象的地址传递给方法。使用堆的一个好理由是大型对象可能会导致栈溢出。一些操作系统的栈相当小,仅有几 KB,因此这个限制可能比你想象的要接近。
通常,测试应以不会在实现错误的情况下崩溃应用程序的方式编写。必须假设实现是错误的,只有成功的测试运行才能证明其他情况。sut
(被测试系统)可能会返回 nullptr
而不是期望的有效指针,因此必须使用 ASSERT_*
进行 nullptr
检查,以优雅地中止当前测试。仅使用 EXPECT_*
进行检查是不够的,因为潜在的 nullptr
会在稍后被解引用并导致应用程序崩溃。其他潜在危险操作也适用,例如访问 iox::optional
或 iox::expected
的值,或对 iox::vector
进行越界访问。
最后但同样重要的是,应用 DRY 原则(不要重复自己),使用类型化测试和参数化测试来检查多种实现和变体,而不重复大量代码。
2. 实际示例
让我们测试以下类
class SingleDigitNumber
{
public:
SingleDigitNumber() noexcept = default;
constexpr SingleDigitNumber(uint64_t number) noexcept
: m_number(number)
{
}
constexpr operator uint64_t() const noexcept
{
return m_number;
}
constexpr SingleDigitNumber operator+(const SingleDigitNumber rhs) const noexcept
{
return m_number + rhs.m_number;
}
constexpr SingleDigitNumber operator-(const SingleDigitNumber rhs) const noexcept
{
return m_number - rhs.m_number;
}
private:
uint64_t m_number;
};
将使用以下测试夹具
class SingleDigitNumber_test : public Test
{
public:
void SetUp() override{};
void TearDown() override{};
};
2.1 第一次尝试
这个类非常简单,所以测试也应该简单,对吧?
TEST_F(SingleDigitNumber_test, TestClass)
{
SingleDigitNumber number1;
SingleDigitNumber number2(3U);
SingleDigitNumber number3(2U);
auto number4 = number2 + number3;
auto number5 = number2 - number3;
EXPECT_TRUE(number4 == 5U);
EXPECT_EQ(1U, static_cast<uint64_t>(number5));
}
完成了,我们达到了 100% 的覆盖率。我们可以拍拍肩膀,然后继续。不过,我们不能这样做。
上面的测试存在几个主要和次要的缺陷。首先,它不符合 AAA 模式。当测试失败时,我们不知道原因,需要查看代码以确定问题所在。即使只测试了类的一个方面,测试结果也只是 [ FAILED ] SingleDigitNumber_test.TestClass
,我们需要查看代码以了解具体的错误。这个测试用例检查了太多的状态转换,而且没有一个合适的名称。
此外,测试中调用了默认构造函数,但没有检查其是否正确执行。如果进行了检查,将会发现 m_number
未正确初始化。
接着,使用 EXPECT_TRUE
比较值。虽然这能工作,但当测试失败时,我们仅能获取失败信息,而不能知道比较了哪些值。为了获取这些信息,应使用 EXPECT_EQ
或 EXPECT_THAT
。此外,为了方便区分实际值和预期值,应该按照 EXPECT_THAT
的顺序来排列值。第一个值应为实际值,第二个值为预期值。
尽管覆盖率可能达到 100%,但测试中没有考虑:
- 无效参数,例如将
10
传递给构造函数 - 溢出问题,比如将
7
和8
相加 - 下溢问题,比如将
8
从7
中减去
这里,ZOMBIES 原则发挥了作用,帮助我们找出这些测试用例。
有些测试可能需要首先定义类的行为,例如定义 operator+
将饱和到最大值而不是溢出。
2.2 如何改进
首先,将测试拆分成多个测试用例,符合 AAA 模式
TEST_F(SingleDigitNumber_test, DefaultConstructedObjectIsCorrectlyInitialized)
{
constexpr uint64_t EXPECTED_VALUE{0U};
SingleDigitNumber sut;
EXPECT_EQ(static_cast<uint64_t>(sut), EXPECTED_VALUE);
}
这个测试有了一个有意义的名称。如果 CI 失败了,立即可以清楚地知道哪个部分出错了。此外,测试对象称为 sut
,这使得识别实际测试对象变得容易。最后,使用 constexpr
作为预期值,这消除了魔法值,同时使失败测试的输出更加可读,因为实际测试值和预期值一目了然。
接下来,继续进行其他测试,应用 ZOMBIES 原则
TEST_F(SingleDigitNumber_test, ConstructionWithValidValueCreatesNumberWithSameValue)
{
constexpr uint64_t NUMBER_VALUE{7U};
constexpr uint64_t EXPECTED_VALUE{NUMBER_VALUE};
SingleDigitNumber sut{NUMBER_VALUE};
EXPECT_EQ(static_cast<uint64_t>(sut), EXPECTED_VALUE);
}
TEST_F(SingleDigitNumber_test, ConstructionWithInvalidValue
CreatesNumberWithSameValue)
{
constexpr uint64_t INVALID_VALUE{10U};
constexpr uint64_t EXPECTED_VALUE{0U};
SingleDigitNumber sut{INVALID_VALUE};
EXPECT_EQ(static_cast<uint64_t>(sut), EXPECTED_VALUE);
}
TEST_F(SingleDigitNumber_test, AdditionSaturatesToMaximumValue)
{
constexpr uint64_t NUMBER1{8U};
constexpr uint64_t NUMBER2{9U};
constexpr uint64_t EXPECTED_VALUE{9U};
SingleDigitNumber number1{NUMBER1};
SingleDigitNumber number2{NUMBER2};
auto result = number1 + number2;
EXPECT_EQ(static_cast<uint64_t>(result), EXPECTED_VALUE);
}
TEST_F(SingleDigitNumber_test, SubtractionSaturatesToMinimumValue)
{
constexpr uint64_t NUMBER1{0U};
constexpr uint64_t NUMBER2{1U};
constexpr uint64_t EXPECTED_VALUE{0U};
SingleDigitNumber number1{NUMBER1};
SingleDigitNumber number2{NUMBER2};
auto result = number1 - number2;
EXPECT_EQ(static_cast<uint64_t>(result), EXPECTED_VALUE);
}
这些测试用例检查了多个方面并提供有意义的名称。所有测试都使用 EXPECT_EQ
比较实际值和预期值。附加的测试包括边界条件以及无效输入的处理。通过这种方式,你可以快速查明出错的具体原因,并能更好地理解预期行为。
3 稍微高级的主题
3.1 类型化测试
在某些情况下,测试用例仅在应用于“被测试系统(sut)”的类型上有所不同。在这种情况下,可以使用类型化测试来减少重复。
在 gtest 的高级文档中有一个关于类型化测试的部分。
在 gtest 的 GitHub 仓库中有一个更详尽的示例。
3.2 参数化测试
与类型化测试类似,有些情况下相同的测试用例应该使用多个参数运行。
一个例子是将“枚举”值转换为字符串。虽然这可以在一个循环中完成,但参数化测试是一个更好的方法。
这是一篇相当不错的关于参数化测试的博客文章。此外,在 gtest 的高级文档中也有一个部分。
该博客文章提到可以使用元组一次传递多个参数。由于元组使用起来可能会很麻烦,尤其是在参数重新排列时,建议创建一个“结构体”来包装参数。
3.3 模拟对象
有些类很难测试或达到完全覆盖。这可能是由于外部访问或与操作系统的交互。
模拟对象可以帮助完全控制“被测试系统(sut)”,并可靠地引发错误条件来测试负面代码路径。
在 gtest 的 GitHub 仓库中有广泛的 gmock 文档。
3.4 陷阱
一些测试需要创建虚拟类,可能会多次选择相同的名称,例如“class DummyData {…};”。
通常,在某些时候编译器会抱怨重复定义。
但由于定义不在头文件中而是在源文件中,因此局限在一个翻译单元内,所以这种情况不会出现并且测试二进制文件会被创建。
不过可能仍然会有问题,因为二进制文件包含多个同名的符号。
其中一个问题可能出现在使用清理器时,例如地址或泄漏清理器。
如果有多个不同大小的“DummyData”类并且它们在堆上创建(这对于测试完全没问题),地址清理器可能会检测到错误,因为释放的东西的大小与预期不同。
为了防止此类问题,测试应放在一个匿名命名空间中,以使所有符号都是唯一的。
namespace
{
struct DummyData {
uint32_t foo{0};
};
class MyTest : public Test
{
//...
};
TEST_F(MyTest, TestName)
{
EXPECT_EQ(ANSWER, 42)
}
} // namespace
4. 结论
- 应用 AAA 模式来组织测试,并在每个测试用例中只检查一个状态转换(不过必须检查该转换的所有副作用)
- 不要测试之前已经测试过的行为
- 使用 ZOMBIES 原则来找到合理的测试用例
- 为测试使用有意义的名称,以表明测试应该做什么和预期的结果,例如“…IsSuccessful”,“…Fails”,“…ResultsIn…”,“…LeadsTo…”等。
- 将测试对象命名为“sut”,以明确哪个对象正在被测试
- 不要使用魔法数字
- 在栈上实例化对象或对大型对象使用智能指针,并避免使用 new/delete 进行手动内存管理
- 在进行可能导致测试应用程序崩溃的潜在危险操作(例如访问“nullptr”或具有“iox::nullopt”的“iox::optional”)之前使用“ASSERT_*”
- 使用模拟对象来降低测试安排的复杂性
- 通过使用类型化和参数化测试应用“不要重复自己(DRY)”原则
- 将测试包装在匿名命名空间中