[翻译]Iceoryx - 最佳实践测试指南

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

可以将其分为 ZOMBIE 两部分,其中 S 用于将两者连接在一起。ZOM 通常是简单的测试,比如 一个零元素的向量是空的一个包含一个元素的向量不是空的一个包含 N 个元素的向量的大小是 NBIE 部分处理边界情况,比如 对 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::optionaliox::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_EQEXPECT_THAT。此外,为了方便区分实际值和预期值,应该按照 EXPECT_THAT 的顺序来排列值。第一个值应为实际值,第二个值为预期值。

尽管覆盖率可能达到 100%,但测试中没有考虑:

  • 无效参数,例如将 10 传递给构造函数
  • 溢出问题,比如将 78 相加
  • 下溢问题,比如将 87 中减去

这里,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)”原则
  • 将测试包装在匿名命名空间中
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
下面是一个iceoryx发布订阅的C代码示例,其中一个发布者发布一个消息,而两个订阅者订阅这个消息: ```c #include <stdbool.h> #include <stdio.h> #include <stdlib.h> #include "iceoryx_posh/popo/subscriber.hpp" #include "iceoryx_posh/popo/publisher.hpp" #include "iceoryx_posh/runtime/posh_runtime.hpp" #include "iceoryx_posh/roudi/introspection_types.hpp" int main() { // 初始化iceoryx runtime iox::runtime::PoshRuntime::initRuntime("publisher"); // 创建发布者和订阅者 iox::popo::Publisher publisher({"Radar", "FrontLeft", "Object"}); iox::popo::Subscriber subscriber1({"Radar", "FrontLeft", "Object"}); iox::popo::Subscriber subscriber2({"Radar", "FrontLeft", "Object"}); // 订阅者1等待消息 subscriber1.subscribe(); printf("Subscriber 1 waiting for messages...\n"); // 订阅者2等待消息 subscriber2.subscribe(); printf("Subscriber 2 waiting for messages...\n"); // 发布者发布消息 printf("Publisher publishing message...\n"); publisher.publish("Hello, world!"); // 等待订阅者接收消息 while (true) { if (subscriber1.hasData()) { printf("Subscriber 1 received message: %s\n", subscriber1.getChunk()->userPayload()); break; } if (subscriber2.hasData()) { printf("Subscriber 2 received message: %s\n", subscriber2.getChunk()->userPayload()); break; } } // 清理资源并退出 iox::runtime::PoshRuntime::shutdownRuntime(); return 0; } ``` 需要注意的是,这个示例代码需要使用iceoryx库,需要将iceoryx库链接到您的项目中。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值