cpp 单元测试总结

作者: | 更新日期:

很基础的知识,花几分钟来回顾一下?

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

2020 年 6 月份,我正式开始写单元测试,并在文章《接入单元测试,真香》中介绍了如何安装、编译、使用。

2020 年 12 月份,那时我已经写单元测试半年了,在文章《轻松理解单元测试中 mock》中介绍了基本的单元测试的基本用法。

最近,我梳理总结了下单元测试常用的写法,感兴趣的可以花几分钟快速来看看,复习一下。

一、基本检验测试

单元测试的目的是测试一段代码的正确性。
在程序里面,一段代码的调用往往使用函数来表示。
所以,单元测试往往是测试一个函数的正确性。

对于一个函数,往往有若干输入与若干输出,而且对于固定的输入有固定的输出(使用随机函数的除外)。
所以单元测试就是使用固定的输入,来检验函数是否按照预期的方式,输出了固定的输出。

检验手段很多,大概如下:

TEST(Demo, demo) {
    EXPECT_TRUE(condition);  // 真值
    EXPECT_FALSE(condition); // 假值
    EXPECT_EQ(val1, val2);   // 相等
    EXPECT_NE(val1, val2);   // 不等
    EXPECT_LT(val1, val2);   // 小于
    EXPECT_LE(val1, val2);   // 小于等于
    EXPECT_GT(val1, val2);   // 大于
    EXPECT_GE(val1, val2);   // 大于等于
    EXPECT_STREQ(str1, str_2); // 字符串等于
    EXPECT_STRNE(str1, str2);  // 字符串不等
    EXPECT_STRCASEEQ(str1, str2); // 字符串忽略大小写等于
    EXPECT_STRCASENE(str1, str2); // 字符串忽略大小写不等
}

当然,除了EXPECT_* 前缀的检验宏函数外,还有ASSERT_*前缀的宏函数。
前者代表预期判断,不管是否符合预期,都继续执行。
后者代表断言判断,不符合预期就停止向下执行。

二、解决依赖 MOCK

对于纯 CPU 函数,我们可以直接调用,然后来检查是否符合预期。
但是函数有外部依赖时,我们就需要通过 MOCK 的方法来解除外部依赖。

在 CPP 语言中,标准情况下,是通过虚函数的多态来解决依赖的。
这也是为什么,我之前一直在强调,MOCK 的本质就是多态,当然,正常情况下是这样的。

由于正常情况下使用多态来解决依赖,这就对依赖对象有两点要求。

1)依赖的对象使用指针的方式调用。
2)依赖的对象支持多态,即被调用的函数是虚函数。

比如下面的样子,我们要对 类 RouteApi 的 Add 函数进行单元测试,api_ 成员是支持多态的指针。

class RouteApi{
     int Add(int a, int b){
         return api_->ExAdd(a, b);
     }
  RouteApiWrap* api_;
};

此时,我们就可以在单元测试文件里继承依赖对象,进行常规的 MOCK 了。

class MockRouteApiWrap : public RouteApiWrap {
  public:
    virtual ~MockRouteApiWrap() {}
    MOCK_METHOD2(ExAdd, int(int, int));
};

TEST(Demo, demo) {
    MockRouteApiWrap wrap;

    // 下面这段代码是啥意思呢? 下文会讲解  
    EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
    .WillOnce(testing::Return(2)))
    .WillRepeatedly([&](int a, int b) {
        return a + b;
    });

    RouteApi route_api;
    route_api.SetWrap(&wrap);

    EXPECT_EQ(2, route_api.Add(1, 1));
    EXPECT_EQ(3, route_api.Add(1, 2));
}

三、测试私有函数

如果被测试的函数是私有函数,我们会发现没法直接调用这个函数。

这个时候,就需要使用一个宏替换的方式,在编译阶段,通过宏替换的方式,把这个私有函数转变为公开函数。

#define private public

#include "server.h"

TEST(Demo, demo){
    Server server; 
    // 这里可以直接调用 Server 的私有函数了
}

四、依赖是对象

上面介绍了怎么测试私有函数,是为了让大家了解到,我们可以在编译阶段对代码进行替换。

根据这个思路,我们还可以对类的成员变量进行替换,这样,我们就可以对非指针的成员变量进行 mock 了。

比如下面的代码,是不可 mock 的。

#ifdef DO_UNIT_TEST
#include "mock.h"
#endif // DO_UNIT_TEST

class RouteApi{
     int Add(int a, int b){
         return api_.ExAdd(a, b);
     }
  RouteApiWrap api_;
};

大家也许注意到了,我在上面的代码里面多加了一行头文件。

这行头文件的作用是,我们进行单元测试时,这个头文件会生效,从而能够替换类里面的对象 api_。

比如 mock.h 里面的代码如下,这样就完成了替换。

#define RouteApiWrap MockRouteApiWrap

class MockRouteApiWrap {
    virtual ~MockRouteApiWrap() {}
    MOCK_METHOD2(ExAdd, int(int, int));
}

使用的时候,就可以直接对 api_ 设置预期行为了。

五、预期行为

对于 MOCK 的函数,我们可以设置预期的行为,从而能够控制被测程序的行为。

比如下面这段代码。

EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
.WillOnce([&](int a, int b) {
    return a + b + 1;
})
.WillRepeatedly([&](int a, int b) {
    return a + b;
});

EXPECT_CALL 代表对 wrap 类的 ExAdd 函数设置预期行为。
ExAdd 后面的两个 ::testing::_ 可以认为是这个函数的两个参数,这里代表任意值。
WillOnce 代表后面的 lambda 表达式只执行一次。
WillRepeatedly 代表后面的 lambda 表达式可以执行无数次。

六、运行次数

由于单元测试要求所有行为都在预期之内,所以每个函数被调用几次也应该是预期之内。
因此,测试框架会严格检查预期行为的次数与实际执行次数是否完全相等。

因此,上一小节里,WillOnceWillRepeatedly就代表至少运行一次,向上次数无限制。

这时就有人说,既然每个函数执行几次都是完全确定的。
那能不能强制检查这个次数呢?而不是向上无限制。

具体来说,就是使用 Times 函数来设置。

EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
.Times(5)
.WillOnce([&](int a, int b) {
    return a + b + 1;
})
.WillRepeatedly([&](int a, int b) {
    return a + b;
});

还是上面那个代码,加上Times(5)就代表预期行为总共执行 5 次,即 WillOnce 执行一次,WillRepeatedly 执行 4 次。

七、直接返回值

上面可以看到,每个预期行为都要写一个 lambda 表达式。

实际上,对于返回值固定的场景,可以直接构造一个返回值的。
当然,这个方式是 Lambda 表达式的语法糖。

EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
.WillOnce(::testing::Return(3));

如上图,通过::testing::Return来直接返回固定的值 3。

那有时候,希望通过参数的形式返回值该怎么办呢?

EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
.WillOnce(SetArgReferee<0>(11));

我们通过SetArgReferee 可以做到这点。

那我们想要设置多个参数值,以及设置函数的返回值,该怎么做呢?

EXPECT_CALL(wrap, ExAdd(::testing::_, ::testing::_))
.WillOnce(DoAll(SetArgReferee<0>(11), SetArgReferee<1>(22), Return(33)));

如上,可以使用DoAllSetArgRefereeReturn 包起来,这样就可以同时参数的值以及返回值了。

八、参数匹配

大家可以看到,ExAdd(::testing::_, ::testing::_) 里面的两个参数分别使用 ::testing::_ 代替了。

实际上,这连个参数是用来匹配参数的,_ 的意思是都匹配。

这个有什么用呢?
可以想象,我们的函数很复杂,会调用依赖接口很多次。
而依赖接口的返回值很巧与第一次输入有关。

第一次输入是 10 了,接下里预期行为有好几个。
第一次输入是 20 了,接下来的预期行为是另外好几个。

面对这种场景,使用常规的方法,我们需要枚举所有的预期函数。

但是我们使用参数匹配,就可以几行代码,轻松设置预期行为。

EXPECT_CALL(wrap, ExAdd(30))
.Times(5)
.WillOnce(Return(31))
.WillRepeatedly(Return(32));

EXPECT_CALL(wrap, ExAdd(40))
.Times(7)
.WillOnce(Return(41))
.WillRepeatedly(Return(42));

如上图,请求参数是 30 的都会命中第一个,而请求参数是 40 的都会命中第二个。

九、资源准备

单侧的环境分三个级别:函数级别、类级别、全局级别。

所以我们在运行三个级别的代码时,可以提前或者最后运行一段固定的代码,比如创建资源与回收资源。

函数级别是SetUpTearDown,每个测试用例都会执行一次。

class RouteApiTest : public ::testing::Test {
    virtual void SetUp() {}
    virtual void TearDown() {}

}
TEST_F(RouteApiTest, RequiredTables) {
    //...
}

类级别是SetUpTestCaseTearDownTestCase。每个类都会执行一次。

class RouteApiTest : public ::testing::Test {
    static void SetUpTestCase(){}
    static void TearDownTestCase(){}    
}
TEST_F(RouteApiTest, RequiredTables1) {
    //...
}
TEST_F(RouteApiTest, RequiredTables2) {
    //...
}

全局级别,则是整个程序只运行一次。

class TestEnvironment : public ::testing::Environment {
public:
    // Initialise the timestamp in the environment setup.
    virtual void SetUp() {  }    
    virtual void TearDown() {  }
};

int _main(int argc, char* argv[]) {
    ::testing::InitGoogleTest(&argc, argv);
    // gtest takes ownership of the TestEnvironment ptr - we don't delete it.
    ::testing::AddGlobalTestEnvironment(new TestEnvironment);
    return RUN_ALL_TESTS();
}

十、最后

好了,这里分享了九个知识点。

分别是基础检验、依赖 MOCK、私有函数、替换不可MOCK对象、预期行为、运行次数、返回值、参数匹配,以及资源准备与释放。

通过这九个知识点,你就可以轻松的写单元测试了。

加油,技术人。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越