(四)测试驱动开发技巧--测试结构


测试结构

前言

本章将深入讨论实现测试的具体细节,包括:文件组织、fixture、setup、teardown、过滤器、断言和基于异常的断言。

组织方式

从文件和逻辑方面着眼,组织测试的方式有几种:

  1. fixture 方式,以及如何利用 setup 和 teardown 钩子函数。
  2. 如何使用 Given-When-Then 的概念来组织。

文件组织

在测试驱动开发相关行为时,我们会将相关的测试定义在同一个测试文件中。最终可能需要多个测试文件来验证相关行为,也可能需要用一个测试文件覆盖多个地方行为,所以不用拘泥于一个类一个测试文件的形式

基于所包含的测试来给文件命名。概括相关的行为,并据此给测试命名。选定一个命名体系诸如 BehaviorDescriptionTest.cppBehaviorDescriptionTests.cpp 或者 TestBehaviorDescription.cpp

fixture

大多数单元测试工具都支持将逻辑上相关的测试分组。再 Google Mock 以及 Catch2 中都支持所谓的测试用例名称来将相关测试分组。下面的测试所属的测试用例名为 ARetweetCollection。IncrementsSizeWhenTweetAdded 是此测试用例中的一个测试。

//google mock
TEST(ARetweetCollection, IncrementsSizeWhenTweetAdded)

//catch2
TEST_CASE("IncrementsSizeWhenTweetAdded","[ARetweetCollection]")

相关的测试运行时需要相同的环境,例如许多测试都需要公共的初始化或者符主函数,许多测试工具能够使你定义一个 fixture – 一个跨测试可重用的类。

//google mock
class ARetweetCollection: public Test
{
public:
    RetweetCollection collection;
};

TEST_F(ARetweetCollection, IsEmptyWhenCreated)
{
    ASSERT_THAT(collection.isEmpty(), Eq(true));
}

TEST_F(ARetweetCollection, IsNoLongerEmptyAfterTweetAdded)
{
    collection.add(Tweet());
    ASSERT_THAT(collection.isEmpty(), Eq(false));
}

//catch2
class FixtureSoundex
{
public:
    Soundex MSoundex;
};

TEST_CASE_METHOD(FixtureSoundex, "Retain sole letter of one letter world", "[SoundexEncoding]")
{
    REQUIRE_THAT(MSoundex.encode("A"), Equals("A000"));
}

Setup 与 Teardown

如果测试用例中的所有测试需要一条或更多的相同初始化语句,那么可以将它们写在 fixture 类的初始化函数中。在 Google Mock 中,必须将此函数命名为 SetUp(它覆写了基类 ::testing::Test 中的虚函数)。

//google mock
class ARetweetCollectionWithOneTweet: public Test {
public:
    RetweetCollection collection;
    void SetUp() override {
        collection.add(Tweet());
    }
};
TEST_F(ARetweetCollectionWithOneTweet, IsNoLongerEmpty) {
    ASSERT_FALSE(collection.isEmpty());
}
TEST_F(ARetweetCollectionWithOneTweet, HasSizeOfOne) {
    ASSERT_THAT(collection.size(), Eq(1u));
}

再 Catch2 中使用了不同的方式,来进行共有代码初始化与释放,即 SECTION 机制,每个 SECTION 都是再测试开始阶段运行,不受其他 SECTION 段影响,更强的是它支持 SECTION 中继续嵌套 SECTION 来更加精细的适配不同测试所需的前置条件:

//catch2
TEST_CASE( "vectors can be sized and resized", "[vector]" ) {

    std::vector<int> v( 5 );

    REQUIRE( v.size() == 5 );
    REQUIRE( v.capacity() >= 5 );

    SECTION( "resizing bigger changes size and capacity" ) {
        v.resize( 10 );

        REQUIRE( v.size() == 10 );
        REQUIRE( v.capacity() >= 10 );
    }
    SECTION( "resizing smaller changes size but not capacity" ) {
        v.resize( 0 );

        REQUIRE( v.size() == 0 );
        REQUIRE( v.capacity() >= 5 );
    }
    SECTION( "reserving bigger changes capacity but not size" ) {
        v.reserve( 10 );

        REQUIRE( v.size() == 5 );
        REQUIRE( v.capacity() >= 10 );

        SECTION( "reserving smaller again does not change capacity" ) {
            v.reserve( 7 );

            REQUIRE( v.capacity() >= 10 );
        }
    }
}

初始化的代码适用于所有相关的测试。如果只用少数几个测试来设置上下文反而容易造成不必要的困惑。当一些测试需要一个初始化代码块,而其他测试不需要时,最好再创建一个初始化块,并且把测试合理地分开。

在创建额外的 fixture 时,不要犹豫。但是每创建一个 fixture,判断一下是不是需要显现出产品代码中的设计缺陷。如果需要两个不同的 fixture 的话,这有可能意味着你正在测试的类违反了单一责任原则,你可能需要将它们拆分为两个类。

Arrange-Act-Assert/Given-When-Then

测试都有相同的流程。首先需要设置好合适的条件,然后执行代表要验证的行为的代码,最后验证结果是否和期望的一样。

测试应当尽可能地直接反映其测试意图。这就意味着阅读测试的人不需要细细品读测试中的每一行,就能很容易地理解测试的基本构成:测试的初始化(Arrange)、测试的行为(Act),以及怎样验证行为结果(Assert)。例如以下代码,使用 AAA 的区别:

TEST_F(ARetweetCollection, IgnoresDuplicateTweetAdded) {
    Tweet tweet("msg", "@user");
    Tweet duplicate(tweet);
    collection.add(tweet);
    collection.add(duplicate);
    ASSERT_THAT(collection.size(), Eq(1u));
}
//VS

TEST_F(ARetweetCollection, IgnoresDuplicateTweetAdded) {
    Tweet tweet("msg", "@user");
    Tweet duplicate(tweet);
    collection.add(tweet);

    collection.add(duplicate);

    ASSERT_THAT(collection.size(), Eq(1u));
}

再 Catch2 中还支持另一种方式 Given-When-Then。给定(Given)一个上下文,当(When)
测试调用一些行为,然后(Then)验证结果。Given-When-Then 表述法稍微侧重强调验证行为,而非测试执行。这里的 SCENARIO 与 TEST_CASE 没有区别,从命名上更符合 GWT 语义。

SCENARIO( "vectors can be sized and resized", "[vector]" ) {

    GIVEN( "A vector with some items" ) {
        std::vector<int> v( 5 );

        REQUIRE( v.size() == 5 );
        REQUIRE( v.capacity() >= 5 );

        WHEN( "the size is increased" ) {
            v.resize( 10 );

            THEN( "the size and capacity change" ) {
                REQUIRE( v.size() == 10 );
                REQUIRE( v.capacity() >= 10 );
            }
        }
        WHEN( "the size is reduced" ) {
            v.resize( 0 );

            THEN( "the size changes but not capacity" ) {
                REQUIRE( v.size() == 0 );
                REQUIRE( v.capacity() >= 5 );
            }
        }
    }
}

快速测试、慢速测试、过滤器和测试集

如果编写的是小而独立的代码单元,那么每个测试都会运行得快如闪电。通常一个测试在一台配置完备的电脑上的运行时间不到一毫秒。以这种速度,几分钟内至少可以运行几千个测试。如果与一些外部慢速资源(如数据库或其他慢速服务)交互的话,那么测试就会变慢。单单建立数据库连接就可能花费 50 毫秒。如果大部分测试必须与数据库交互,那么需要几分钟才能运行完几千个测试。有些工作室需要半个多小时才能运行完所有测试。

而 TDD 的核心目标就是尽可能频繁地获得较多的反馈。当修改了一点代码时,会马上想知道改动是否正确,影响到了其他代码?这时如果运行测试时间很长,那么就不会频繁的运行他们,一旦反馈周期变长,TDD 的威力就急剧减弱。因为越晚察觉到的问题,修改所需的耗费就更长。

运行慢的测试对 TDD 来说是个问题,所以一些人不再把它们称为单元测试,而是称其为集成测试。

运行测试的一个子集

可能已有一个测试集(Test suite)来验证系统的一部分行为。同时也有可能这些测试运行的很慢,有时需要指定运行某一个测试子集。

Google Mock 可以通过指定一个测试过滤器轻松地做到只运行一个测试子集。你可以把测试过滤器作为执行测试的命令行参数。过滤器的语法是:测试用例名.测试名称。例如,如果想要运行一个特定的测试,可以使用下面的命令:

./test --gtest_filter=ATweet.CanBeCopyConstructed # weak
./test --gtest_filter=ATweet.* # 使用通配符运行一组
./test --gtest_filter=*Retweet*.*:ATweet.*:-ATweet*.*Construct* # 自定义复杂的过滤器

Catch2 也同样有一些过滤方法:

~/WorkSpace/TDD_Learning/build/test/Catch2/TestSoundex master
❯ ./catch2_tdd_test "[SoundexEncoding]"
Filters: [SoundexEncoding]
===============================================================================
All tests passed (11 assertions in 11 test cases)

断言

断言可以说是自动化测试的核心,它替代了人工查看结果。一般测试框架运行单个测试时,它会从头到尾执行测试代码段中的语句。每遇到一个断言,都意味着要去验证一些期待的结果。如果断言的条件不满足,那么测试框架就中止测试。

有些工具,包括 Google Mock 与 Catch2,提供了另一种断言机制,它允许测试在遇到断言失败的情况下继续运行。这些断言又称作非致命性断言,与致命性断言相对,后者会中止测试。

具体断言使用方式详见,GMock 与 Catch2 的文档。

基于异常的测试

开发者应当知晓在代码执行时会出现哪些错误,也应当知道什么时候抛出异常,哪里需要引入 try-catch 块来保护应用程序。

在 TDD 中,先从一个失败的测试开始,然后将对异常的顾虑化作代码编写入系统。所得结果就是可以用作文档之用的测试,可以将此测试提供给不太清楚代码执行路径的开发人员。当异常发生时,可以在测试中找到可能出错的地方和将会发生的事情。拥有这些知识的客户端开发者可以很自信地使用你提供的类。

有些单元测试框架允许你声明应该抛出的异常,如果异常没有抛出,则测试失败。可以编写如下代码,如果 ASSERT_ANY_TRHROW 宏内的表达式不抛出异常,那么测试失败。:

//GMock
TEST(ATweet, RequiresUserToStartWithAtSign) {
    string invalidUser("notStartingWith@");
    ASSERT_ANY_THROW(Tweet tweet("msg", invalidUser));
}

// Catch2
REQUIRE_THROWS_WITH( openThePodBayDoors(), Contains( "afraid" ) && Contains( "can't do that" ) );
REQUIRE_THROWS_WITH( dismantleHal(), "My mind is going" );

探查私有成员

可以针对私有成员数据编写测试吗?私有成员函数呢?这两个既相关又迥异的话题会影响你的设计抉择。

私有数据

如遇到了一些情况需要判断类的私有数据,为那些非公用接口的成员提供访问通道方式是可以接受的,如下:

public:
// 仅仅为了测试的目的而暴露数据;避免直接用于生产:
unsigned int currentWeighting;

将数据暴露出来仅仅是为了测试,这对许多人来说是不易接受的,但更重要的是清楚你的代码能正常工作。然而,过量的状态测试显露了设计坏味。无论什么时候,如果暴露数据仅仅是为了断言,那么就需要考虑用验证行为的方式来替代。参考第 5 章获取更多信息。

私有行为

大多数时候,当你觉得需要测试私有行为时,可以尝试将代码移到另一个类或为之创建的一个新类。

测试和测试驱动:参数化的测试及其他方法

尽管测试驱动开发中有测试两个字,但是它更多地与设计有关,而非测试。在 TDD 过程中,你会编写单元测试,但它们基本就是一些副产品。貌似差别不大,但 TDD 的真正目的是让你一直保持设计整洁,这样在引入新的行为或改变现有行为时,你会更从容自信,并且不会太费力。

从测试的角度看,你寻求的是创建具有高覆盖率的测试。所写的测试有五类:无(zero)、有(one)、多(many)、边界(boundary)和异常(exceptional)情形。而从测试驱动角度来看,你写测试的目的是为了保证代码能够符合预定规范。虽然测试和测试驱动都能够保证你有足够的信心去发布代码,但是一旦对所构建的东西有足够的信心,就可以停止 TDD。与此相反,优秀的测试人员会竭尽所能地去覆盖上面所说的五类情形。

你可以在测试驱动开发时编写额外的事后测试。但通常而言,一旦你认为你有一个正确且干净的实现,并且这个实现能覆盖你要支持的情形,那么就可以立刻停止。换句话说,在你想不出可以写出不能通过的测试时,就可以停止。

现在以罗马数字转换器为例,它可以将阿拉伯数字转换为对应的罗马数字。优秀的测试人员可能至少会测试几十个转换,以确保能覆盖各种数字和组合。相反,在测试驱动开发解决方案时,本文在测试完十几个转换后就可以停下来。此时,我有足够的信心保证你已经开发出了正确的算法,剩下的工作仅仅是完成阿拉伯数字到罗马数字的转换表。

在测试驱动开发时,追求的是快速且独立的测试,所以不需要这种测试间的依赖关系,因为它会让事情变得复杂。

参数化测试

以一个简单的名为 Adder 类举例,输入参数测试功能:

class Adder
{
public:
    static int sum(int A, int B) { return A + B; }
};

TEST_CASE("GeneratesASumFromTwoNumbers", "AnAdder")
{
    REQUIRE(Adder::sum(1, 1) == 2);
}

一个简单的 sum 函数实现测试,假如想要加入更多的参数来进行测试,可以使用参数生成器:

TEST_CASE("BulkTest", "AnAdder")
{
    using std::make_tuple;
    size_t TestInputA, TestInputB, ExpectOutPut;
    // clang-format off
    std::tie(TestInputA, TestInputB, ExpectOutPut) =
        GENERATE(table<size_t, size_t, size_t>(
                {
                    make_tuple(1, 2, 3),
                    make_tuple(2, 3, 5),
                    make_tuple(3, 3, 6),
                    make_tuple(4, 4, 8),
                }
            )
        );
    //clang-format on

    CAPTURE(TestInputA, TestInputB, ExpectOutPut);//用于记录数据信息 log
    REQUIRE(Adder::sum(TestInputA, TestInputB) == ExpectOutPut);
}

关于 gmock 的参数生成器使用方法,详见配套的工程代码

测试中的注释

注释不是测试工具的特性而是一个语言特性。在产品代码和测试代码中,最好的选择是尽量将注释化为更具表达力的代码。所剩的注释只回答类似下面的问题:为什么我会这样编写代码?

除了回答此类“为什么”,如果你要加注释来阐明测试的话,那就糟糕透顶了。测试应当清楚地阐明类的功能。你总是可以用一种无需使用阐述性注释的方法来重命名和组织测试。

可以用一句话把这一点说得更清楚:不要用描述性的注释来总结测试,而是修改测试名称以达到描述效果。不要引导读者通过注释来理解测试。要整理测试中的步骤。

总结

本章讲解了如何组织单元测试代码,包括了源码文件以及代码结构上如何进行。

针对测试集的大小分布,以及断言相关概念进行了介绍。


文章作者: Layton
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Layton !
  目录