UT单元测试

UT(Unit Test)单元测试,是对程序中的最小可测单元进行测试,也可以理解为针对代码的测试,通常采用白盒测试方法,此类测试要求测试人员具备很强的编码能力,目前大多数公司采用的是开发自测;

一、UT的特点

  1. 独立性:每个单元测试独立运行,不依赖其他测试或外部资源
  2. 自动化:可通过工具自动执行,适合持续集成。
  3. 快速反馈:执行速度快,能迅速发现代码问题。
  4. 针对性:专注于测试单个函数或方法,确保其功能正确。
  5. 可重复性:每次执行结果一致,便于验证代码修改。

优点:

  • 早期发现问题:在开发初期就能发现并修复问题,降低后期修复成本。
  • 提高代码质量:促使开发者编写更模块化、可测试的代码。
  • 简化调试:问题定位更简单,通常只需检查特定单元。
  • 文档作用:测试用例可作为代码功能的示例文档。

缺点:

  • 编写和维护成本高:需要额外时间和精力编写和维护测试用例。
  • 覆盖不全:难以捕捉集成或系统级问题。
  • 误报和漏报:可能出现误报或漏报,需仔细设计测试用例。
  • 依赖:测试复杂依赖时需使用桩对象,增加了复杂性。

二、UT 测试对象

UT的对象通常是软件中的最小可测试单元,具体包括:

  1. 函数或方法:验证输入输出、边界条件、异常处理等。
  2. 类:验证类的公有方法、属性、构造函数等。
  3. 模块或组件:验证模块或组件的接口和行为。
  4. 算法:验证算法的正确性和效率。
  5. 工具类或工具函数:验证工具类或工具函数的功能。

三、gtest框架使用

gtest是Google公司开发的一个开源测试框架,主要针对C/C++单元测试;

gtest是一个跨平台的单元测试框架,可以在不同平台上编写C++测试程序,如Linux、Windows、Mac OS、Cygwin等;

3.1 gtest特性

  1. 丰富的断言宏,如布尔、整型、浮点型、字符串等,并提供断言方法自定义扩展;
  2. 参数化测试,使用不同参数运行同一测试逻辑。
  3. 类型参数化测试,对不同数据类型运行相同测试逻辑。
  4. 死亡测试(Death Tests),验证程序在特定条件下是否崩溃或抛出异常。
  5. 生成 XML 格式的测试报告,便于集成。

3.2 gtest使用规则

TEST宏

函数原型:TEST(test_case_name,test_name),是GTest中用于定义单个测试用例的宏。每个测试用例属于一个测试套件(test case),test_case_name是测试套件的名称,test_name是测试用例的名称。

TEST(test_case_name, test_name) {
    // 测试逻辑
    // 使用断言宏(如 EXPECT_EQ, ASSERT_TRUE 等)验证结果
}

TEST宏的作用是定义一个测试函数,在这个函数里可以调用期望的C++代码,并使用gtest提供的断言来进行检查;

gtest断言

gtest中的断言分为两类: ASSERT_系列:检查失败则退出当前函数; EXPECT_系列:检查失败继续往下执行; 用例执行失败时会提供关键的错误信息;

通常都使用EXPECT_系列;

#include <gtest/gtest.h>

int add(int a, int b) {
    return a + b;
}

TEST(AddTest, HandlesPositiveInput) {
    EXPECT_EQ(add(1, 2), 3);  // 验证 1 + 2 = 3
    EXPECT_NE(add(1, 2), 4);  // 验证 1 + 2 != 4
    EXPECT_TRUE(add(1, 2) > 0);  // 验证结果大于 0
}

TEST(AddTest, HandlesNegativeInput) {
    EXPECT_EQ(add(-1, -1), -2);  // 验证 -1 + (-1) = -2
    EXPECT_LT(add(-1, -1), 0);  // 验证结果小于 0
}

TEST(AddTest, HandlesZeroInput) {
    EXPECT_EQ(add(0, 0), 0);  // 验证 0 + 0 = 0
    EXPECT_GE(add(0, 0), 0);  // 验证结果大于或等于 0
}

gtest事件机制

gtest提供了三种事件机制,用来给测试用例做初始化和清理工作:全局事件、TestSuite事件、TestCase事件

像全局事件为gtest工程中的所有用例增加初始化和环境清理;具体实现:需要写一个类,继承testing::Environment类,实现里面的SetUp和TearDown方法;

如果需要使用TestCase增加初始化和环境清理;具体实现:需要写一个类,继承testing::Test类,实现里面的两个静态方法SetUpTestCase 和TearDownTestCase;

死亡测试

在测试过程中,需要考虑各种各样的输入,有些输入可能会导致程序崩溃,这时我们要检查程序是否按照预期的方式崩溃,这个就是死亡测试;

死亡测试用到的宏:ASSERT_DEATH、ASSERT_EXIT

3.3 基于gtest+gcov+lcov生成UT覆盖率报告

环境准备

  • x86_64-8.1.0-posix-seh-rt_v6-rev0(基于 GCC 8.1.0 的 MinGW-w64 工具链)
  • msys2-x86_64-20210419.exe(为Windows提供类Unix环境的软件分发和开发平台)
  • Python-3.5.0
  • googletest-1.8.x(gest测试框架)

具体步骤

  1. 配置环境变量:将..\x86_64-8.1.0-posix-seh-rt_v6-rev0\mingw64\bin放到PATH中
  2. 创建一个简易的C++工程
  3. 在MSYS2终端中执行如下命令,生成gtest框架文件
     # python /根目录/googletest-1.8.x/googletest/scriots/fuse_gtest_files.py 指定文件夹名称/
    
  4. 配置工程编译,略
  5. 运行编译,会在Debug目录下生成*.gcno
  6. 执行可执行程序Debug目录下生成*.gcda
  7. 执行lcov -d . -o 'unit_test.info' -c 生产覆盖率信息
  8. 执行genhtml -o result unit_test.info在result目录中生成的报告,打开index.html查看报告

四、gmock框架使用

gmock是由google公司开发的一种接口测试框架,在C++单元测试 中和gtest搭配使用。

测试一个模块时,如果涉及到与其他模块的交互,可以将模块之间的 接口mock起来;

4.1 桩

桩(stub),又称桩代码(stub code)或桩函数(stub function),是指用来代替关联代码或未实现代码的代码;

桩函数的主要作用是隔离,使UT测试代码能够不依赖真实的环境,并 且不破换被调用函数本身的执行逻辑

通常使用的手段有:mock、stub;

mock与stub的区别?

  • mock和stub都是采用替换的方式来实现被测函数中的依赖关系;
  • mock采用的是接口替换方式,对代码没有倾入性;
  • stub采用的是函数替换,代码倾入性较强;

4.2 gmock特性

  • 支持方便创建mock类;
  • 支持丰富的匹配器(Matcher)和行为(Action);
  • 支持有序、无序、部分有序的期望行为的定义;
  • 支持多平台,如Windows、Linux、Unix、OpenEuler;
  • 一般来说,gmock只能mock虚函数;

4.3 基本用法

  1. 定义模拟类
  2. 设置模拟行为,也就是希望怎么样被调用,应该怎样回应
#include <gmock/gmock.h>

class Calculator {
public:
    virtual int Add(int a, int b) = 0;
    virtual int Subtract(int a, int b) = 0;
};

// 创建模拟类
class MockCalculator : public Calculator {
public:
    MOCK_METHOD(int, Add, (int a, int b), (override));
    MOCK_METHOD(int, Subtract, (int a, int b), (override));
};
using ::testing::Return;

TEST(CalculatorTest, MockTest) {
    MockCalculator mock;

    // 设置模拟行为
    EXPECT_CALL(mock, Add(1, 2))  // 期望调用 Add(1, 2)
        .WillOnce(Return(3));     // 并返回 3

    EXPECT_CALL(mock, Subtract(5, 3))
        .WillOnce(Return(2));

    // 验证模拟行为
    EXPECT_EQ(mock.Add(1, 2), 3);
    EXPECT_EQ(mock.Subtract(5, 3), 2);
}

EXPECT_CALL用法:

    EXPECT_CALL(mock, Add())
        .Times(testing::AtLeast(5))             // 调mockTurtle的getX()方法这个方法会至少调用5次
        .WillOnce(testing::Return(100))         // 第一次被调用时返回100
        .WillOnce(testing::Return(150))         // 第二次被调用时返回150
        .WillRepeatedly(testing::Return(200))   // 从第3次被调用开始每次都返回200

五、常见问题&解决办法

5.1 函数全覆盖,函数覆盖率达不到100%问题

问题背景:在最后生成覆盖率报告的时候,我们发现我们已经覆盖了所以分支,但是最终统计的时候提示我们还有一个函数没有覆盖到

解决办法一:我们发现该虚函数析构的时候会调用父类的析构函数,我们直接将父类的析构函数删掉可以解决这个多的函数分支,因为父类的析构函数为虚函数,所以没所谓

解决办法二: 在测试用例中,new一个对象出来,再释放掉,这样就可以把父类析构函数覆盖到

5.2 宏定义复杂函数UT问题

问题背景:因为日志系统使用了log4的接口,我们用宏定义的方式包了一层,为了区分日志、应对多线程问题,加了一些逻辑判断和线程锁。这样用起来会方便很多,但是对于UT来说工程量就很大,每次调用宏,就有十几个重复分支

解决办法:Log主要是用到了第三方的log4的函数,不好打桩且确实没有必要做UT。于是只能修改源码,通过再套一层宏定义。

修改makefile,在编译过程中可以直接定义该宏,让代码执行空的宏定义函数即可

# project defined MACROs
USER_DEFINATIONS = -D xxx

5.3 当函数头文件中有对该函数的实现时,再对函数打桩,编译时会产生重定义问题

5.4 if 分支计算规则

当出现 if(表达式a || 表达式b)时,它会被当做四个分支来计算 就会有如下四种情况:

表达式a 表达式b
0 0
1 1
0 1
1 0

一般需要写三个分支,才能覆盖四种情况 当前一个表达式满足时,则不会执行后一个表达式。多写了就会gmock waring 与、或不算分支,不需要考虑组合情况,只考虑表达式情况即可

5.5 遇到私有变量无法造数据问题

问题背景:函数对私有便令内数据进行访问,而私有对象内没有东西。导致程序没有办法继续往下走。

解决办法一:对这些个map系统函数操作进行打桩替换,要改源码

解决办法二:通过在测试用例包含源码的头文件前添加#define private public,将源码头文件中私有变量编程共有变量,即可对便令进行操作,且不影响源码。

取巧罢了,不合理 :(

5.6 打桩函数参数传值方法

问题背景:要给指针传值,指针是从打桩函数中传进来的

解决办法一: 按正常方式打桩

.WillRepeatedly(DoAll(SetArgReferee<1>(files), Return(0))); DoAll同时设置引用参数返回值和函数返回值

  • <1>:参数位置,从0开始 files:需要返回的参数

  • SetArrayArgument<1>(uiMessage+0,uiMessage+8) 设置返回的数组参数 uiMessage[9]

  • SetArgPointee 设置指针

解决办法二:偷懒的方式打桩,不设置入参

设置memset桩,想要给函数传值时,先调用memset桩。

使用前判断指针是否为空,将想要的值通过memset方法传递给指针。

5.7 链接时出现undefined reference to ‘XXXX’错误告警、

被测代码引用了该文件以及所包含头文件以外实现的函数,接口,以及方法,一般情况下这告警指示的接口或者方法是需要在桩里面进行实现的。

简而言之,就是调用第三方接口,找不到,需要打桩处理

5.8 链接时出现undefined reference to ‘vtable for xxx’错误告警

这个错误是表示指定类里面有未实现且必须实现的virtual方法,这个方法可能是从父类继承来的,也可能是父类继承来的,需要从头找到这些继承的类的抽象方法,在桩里面实现

5.9 循环分支规则

数字变量循环

[ + - + + ]: 2 : for (int i = 0; i < request->pin_size(); i++) {

迭代对象循环

[ + + + - ]: 2 : for (auto cycleRes: res) {

可以看到在数字循环中编译器认为第二个条件没有满足过,迭代器循环中最后个条件没有满足过,实际状况是两者都是同一个原因,不满足进入循环的分支没有测试到,这里数字变量循环需要构造循环最大值小于等于初始值的状况,迭代器需要构造迭代器为空的状况

results matching ""

    No results matching ""