研效优化实践:聊聊单元测试那些事儿

2021-07-26 10:10:07 浏览数 (1)

作者:ciuwaalu,腾讯安全平台部后台开发

研发效能提升是一个系统化的庞大工程,它涵盖了软件交付的整个生命周期,涉及到产品、架构、开发、测试、运维等各个环节。而单元测试作为软件中最小可测试单元的检查验证环节,可以说是这个庞大工程中最细致但又不可忽视的一个细节因素。本文内容梳理自安全平台部测试效能提升的经验实践,从零开始介绍探讨单测的方法论和优化思路,期望为大家带来参考,欢迎共同交流。

什么是单元测试?

在最开始,我们先看看大家认为的单元测试是什么:

在计算机编程中,单元测试是一种软件测试方法,通过该方法对源代码的各个单元(一个或多个计算机程序模块的集合以及相关的控制数据、使用过程和操作过程)进行测试以确定它们是否符合使用要求。—— 维基百科《Unit testing》

一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行检验。单元测试几乎都是用单元测试框架编写的。单元测试容易编写,能快速运行。单元测试可靠、可读,并且可维护。只要产品代码不发生变化,单元测试的结果是稳定的。—— Roy Osherove《单元测试的艺术》

以上这些定义为了严谨起见,都是长长的一大段。在这里,我们结合工程实践经验,给出一个“太长不看”版的定义,这个定义不太严谨但更为简单:

开发同学编码阶段函数方法 为粒度编写测试用例,检验 代码逻辑 的正确性。

在这个一句话定义里,有四个核心要素:

  • 角色:开发同学 单元测试是开发同学工作的一部分,而不是测试同学的工作内容。
  • 阶段:编码阶段 单元测试是在开发编码阶段进行的,而不是转测试之后才开始的。
  • 粒度:函数方法 单元测试主要针对函数方法,而不是整个模块或系统。
  • 检验:代码逻辑 单元测试主要验证函数方法中的代码逻辑实现,而不是模块接口、系统架构、用户需求。

结合测试 V 型图,可以清晰看到单元测试在项目周期中所处的位置阶段。

单元测试有什么好处?

我们不打算罗列《单元测试的N大优势》《写单元测试的N大好处》,只说一条最核心的:单元测试可以尽早发现编码中的低级错误。

越早发现问题,也越容易解决问题。很显然:

  • 如果问题在编码阶段、由开发同学通过单元测试发现,开发同学可以立即修复
  • 如果问题在转测之后、由测试同学发现,可能会走缺陷单,修复流程时间长,影响项目进展
  • 如果问题在测试阶段未被发现,而在上线后才触发,需要运维同学回滚,甚至可能会导致现网事故

来自微软的数据,不同测试阶段发现BUG的平均耗时,供参考:

  • 单元测试阶段,平均耗时 3.25 小时
  • 集成测试阶段,平均耗时 6.25 小时 ( 92%)
  • 系统测试阶段,平均耗时 11.5 小时 ( 254%)

低级错误造成重大损失的例子实在太多了。有了单元测试,可以避免 面向运气开发,面向回滚发布,打破“不知道有没有BUG ~ 上线出事回滚 ~ 紧急修复 ~ 代码质量逐渐劣化 ~ 不知道有没有新BUG” 的恶性循环。

黑盒与白盒

在软件测试理论中,常常将被测试对象视为一个盒子,这个神秘的盒子接受一些输入,并做某些处理工作,产生特定的输出结果。

在构造输入数据进行测试时:

  • 如果知道盒子的用途,但不知道盒子的构造,就是黑盒测试
  • 如果知道盒子的用途,也知道盒子的构造,就是白盒测试

白盒测试一般只在单元测试中使用,黑盒测试在单元测试、集成测试等各个阶段都可以使用。

我们以下方这个函数为例子,看看单元测试中如何应用黑盒与白盒测试。首先需要明确,设计单元测试,我们肯定是知道这个函数的具体用途、输入参数和返回结果的含义(即知道盒子的用途):

代码语言:javascript复制
// 从 IPv4 报文中提取源 IP 地址
uint32_t GetSrcAddrFromIPv4Packet(const void *buffer, size_t size);

如果我们手上只有编译好的二进制库文件,不知道函数的内部实现方式,通过想象这个函数在上线后会遇到什么类型的输入,设计了一些合法和非法的 IP 报文来做验证,此时是 黑盒测试

如果我们手上有函数源代码,一边看着函数实现,一边根据代码里的分支、逻辑构造各种输入,此时是 白盒测试

比如看到函数内部的 if (buffer == nullptr) return -1; 设计了一个空缓冲区的用例;

比如看到函数内部的 if (size < sizeof(iphdr)) return -1;  设计了缓冲区大小为 19Bytes 的用例。

在大部分情况下,我们是自己给自己写的函数做单元测试,当运用黑盒测试的思路时,要 假装 被测函数是别人写的。

覆盖

在单元测试中,覆盖率是一个常用的评估指标。

所谓覆盖,可以简单理解为 “被执行过”。具体来说:在某个测试用例中,执行了某行代码,则可以说这行代码“被覆盖”;同样,当某个分支的真/假条件都被取到时,则可以说这个分支“被覆盖了”。

常见的覆盖可以分为这几种:

  • 语句覆盖
  • 分支覆盖
  • 条件覆盖

假设我们有一个这么一个待测函数:

代码语言:javascript复制
int foo(int a, int b, int c, int d) {
    int result = 0;
    if (a && b)                        // 分支 1
        result  = a;
    if (c || d)                        // 分支 2
        result  = c;
    return result;
}

语句覆盖 是指 每条语句都被执行一次。当输入 a=1, b=1, c=1, d=1  一组用例时可以达到。

分支覆盖 是指 每个分支 真/假 条件都被执行一次。当输入 a=1, b=1, c=1, d=1 以及 a=0, b=0, c=0, d=0 两组用例时可以达到。

条件覆盖 是指 每个分支的条件组合方式都被执行一次。当输入 a=1, b=1, c=1, d=1(真真)、a=1, b=0, c=1, d=0(真假)、a=0, b=1, c=0, d=1(假真)、a=0, b=0, c=0, d=0(假假)四组用例时可以达到。

语句覆盖是最容易达到、也是最弱的覆盖方式。在工程实践中,考虑到测试成本及测试效果,分支覆盖的覆盖率是最常使用的考察指标。

桩与驱动

假设我们还有这么一个待测函数:

代码语言:javascript复制
void foo(int a) {
    if (a > 0) {
        A();
    } else {
        B();
    }
}

foo() 调用了外部函数 A() B()

假设 A() 是一个很重的函数(操作 DB、文件或者网络通信……),进行单元测试时,我们不希望引入这些外部依赖,而是希望调用 A() 时立即返回一些提前准备好的“假数据”,这时需要“仿冒”一个 A(),这个伪造过程就叫做 插桩,假冒的 A() 就称为 桩函数(stub)

在做测试时,需要写一个函数来调用 foo(),这个调用者就是 驱动(driver)

单元测试简单实践

一个简单的单元测试

一个单元测试用例至少包含:

  • 断言
  • 输入数据
  • 预期输出

一个简单但完整的单元测试看起来会是这样的:

代码语言:javascript复制
// 待测函数
int add(int a, int b) {
    return a   b;
}

// 测试用例
void TestAdd() {
//       被测对象      预期输出
//         |||          |
    assert(add(1, 2) == 3);
//  ||||||     |  |
//   断言      输入数据
}

// 执行测试
int main() {
    TestAdd();
}
Given-When-Then

单元测试中 被测函数、断言、输入数据、预期输出 几个要素,可以通过经典模板 Given-When-Then(GWT) 来做一些严谨的描述。

  • Given 描述测试的前置条件或初始状态
  • When 描述测试过程中发生的行为
  • Then 描述测试结束后断言输出结果

使用 GWT 来描述上一节的用例:

代码语言:javascript复制
assert(
  add(      // When  - 测试过程发生的行为 - 调用被测函数 add()
    1, 2    // Given - 测试前置条件和初始状态 - 用例输入参数
  )
  == 3      // Then  - 测试结束断言输出结果 - 断言预期输出
);

有些现代化的测试框架(例如 catch2)对 GWT 描述做了表达上的优化。下方粘贴了一段单元测试代码示例,有对 GWT 更为具体的描述:

代码语言:javascript复制
SCENARIO( "vectors can be sized and resized", "[vector]" ) {
    GIVEN( "A vector with some items" ) {
        std::vector<int> v( 5 );

        REQUIRE( v.size() == 5 );  // REQUIRE() 即 assert()
        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 );
            }
        }
    }
}
组织结构

原则:单元测试尽可能以函数方法等较小粒度进行组织。

假设我们有下边一个类,设计单元测试时,最好以各个功能函数为测试目标,而不是将类本身为测试目标:

代码语言:javascript复制
// IPv4 报文解析
struct IPv4Parser {
    IPv4Parser(const void *buffer, size_t size);

    size_t   GetHeaderSize();   // 获取头部大小
    uint32_t GetSrcAddr();      // 获取源 IP
    uint32_t GetDstAddr();      // 获取目的 IP
};

建议:为 GetHeaderSize() GetSrcAddr() GetDstAddr() 分别构造不同的测试输入数据。

不建议:为 IPv4Parser 类构造测试输入数据,然后对 GetHeaderSize() GetSrcAddr() GetDstAddr() 使用同样的数据进行单元测试。

常见的测试框架都支持通过测试套件(TestSuite)对测试用例(TestCase)在逻辑上进行组织,测试套件可以嵌套,整个单元测试可以组织为树状结构。

常见的测试框架还支持 Fixture。Fixture 是对测试环境进行组织,通过 SetUp() TearDown() 函数,以方便进行测试开始前的准备工作,以及测试完成后的清理工作。Fixture 一般会与测试套件结合使用。

组织单元测试的几点准则:

  • 轻量:不要有过多的前置条件或外部依赖

轻量的测试用例易于重复执行,方便重现和定位问题。

  • 独立:同一个测试套件的不同的用例相互独立 测试用例之间尽量独立,避免依赖,可乱序执行,结果稳定复现。
  • 隔离:使用测试套件隔离资源 使用测试套件与 Fixture 隔离测试用例的资源依赖,以方便管理。
用例设计

设计单元测试用例中有很多方法:等价类划分、边界值分析、路径测试……

在实践中,我们可以设计覆盖 正常流程 & 异常流程 两大类用例:

  • 正常流程通过输入合法的 典型数据、边界值 看基本功能是否正确实现
  • 异常流程通过输入非法数据看异常处理流程是否符合预期

一个函数的内部实现可能是 异常处理-正常流程-异常处理-正常流程 的重复,比如这样:

代码语言:javascript复制
size_t IPv4Parser::GetHeaderSize() {
  // 异常处理
  if (buffer_size < sizeof(iphdr)) return 0;

  // 正常流程
  auto ip = (const iphdr*) buffer;

  // 异常处理
  if (ip->version != 4) return false;

  // ...
}

因此我们在设计测试用例时,可以:

  1. 首先设计覆盖 正常流程 的用例,构造一些合法的输入:一个典型的 IP 报文,一个有扩展头部的 IP 报文,一个带有 TCP/UDP payload 的 IP 报文……
  2. 其次设计覆盖 异常流程 的用例,构造一些非法的输入:空指针,不完整的 IP 头,非 IP 协议……
  3. 最后再考虑一些边界情况:一个不带 payload 的 IP 报文,一个大小为 64K 上限的 IP 报文,一个头部完整但payload 不完整的 IP 报文……

在设计测试用例过程中,可能会遇到被测函数需要与外部 DB、文件、网络交互的情况,这时候需要使用 Fakes/Stubs/Mocks 进行模拟:

  • Fakes:包含了生产环境下具体实现的简化版本的对象 比如模拟的数据库对象、文件描述符、网络连接等。
  • Stubs:包含了预定义好的数据并且在测试时返回给调用者的对象 比如很多组预定义好的输入、输出数据,比如数据库查询结果。
  • Mocks:仅记录它们的调用信息的对象 比如模拟的文件保存接口、数据发送接口等。

在实践中通常并不纠结这几个词语的区别,常被统称为 插桩,对应的工具也一般被称作 Mock 工具

C 单元测试
常见单元测试框架

GoogleTest 是老牌测试框架,功能完善,用户很多。

Catch2 是现代化测试框架,提供了很多特色功能,依赖简单,可以一试。

Boost.Test 是 Boost 自带的测试框架,依赖 Boost 的程序可以直接使用,功能强大。

一些 Mock 工具
  • GoogleMock
    • 通过 C 多态实现对虚函数进行 Mock
    • 不支持 Free Function 以及非虚函数
    • 目前已经合并为 GoogleTest 的一个子模块
  • 《效能优化实践:C/C 单元测试万能插桩工具》
    • 通过 Hook 函数入口实现用 Mock 函数无缝替换原始函数
    • 内部开源工具
  • MySQL Server Mock
    • MySQL 官方提供的服务端 Mock 工具
编译参数选项
  • 开启调试信息:
    • -g
  • 关闭优化和代码保护:
    • -O0
    • -fno-inline
    • -fno-access-control
  • 覆盖率:
    • --coverage
    • -fprofile-arcs
    • -ftest-coverage
Python 单元测试

点击阅读《研效优化实践:Python单测——从入门到起飞》。

小经验分享
三条准则

单元测试必须经常跑

  • 错误做法:为了完成 KPI 写了一堆测试,跑一次就不管了
  • 正确做法:持续集成,自动化运行

从增量到存量,从主要到次要

  • 从覆盖新模块、新功能做起,单元测试先跑起来再说
  • 不要追求 100% 的覆盖率,但主要功能逻辑要完成覆盖测试

测试用例需要逐步积累

  • 上线前已经有了第一批用例,每次迭代都会增加新用例来覆盖变更
实践经验

思路:以黑盒指导功能验证,以白盒提升覆盖率

黑盒测试为主:

  • 黑盒测试验证功能逻辑实现是否正确
  • 不关心内部实现方式,代码优化重构用例仍可复用

白盒测试为辅:

  • 白盒测试关注黑盒测试用例遗漏的分支、路径
  • 可以聚焦于异常处理逻辑是否合理
  • 项目工期紧时可推迟进行
可能踩到的坑

不要被高覆盖率骗了

  • 单元测试的目标是发现问题,不是追求高覆盖率
  • 宏、模板等语法功能可能会使得覆盖率虚高

Debug/Release 目标结果不一致

  • Debug 目标关闭优化,启用堆栈保护,某些错误代码可正常执行
  • 单测在 Debug 下跑完后,建议在 Release 下再跑一次

代码合并导致单测失败

  • 小A和小B分别开发新功能,push 前单测都通过了,MR 后单测却挂了
  • 使用持续集成发现问题
提高代码的可测性

在编码过程中,多多考虑代码的可测性,可以让单元测试事半功倍:

  • 开发过程及时编写测试用例,边开发边测试,不要等全部开发完毕了才开始写测试用例
  • 函数功能简单,避免随机性,以免测试结果不稳定
  • 函数减少输入输出,使简单的输入数据组合可以完成测试覆盖
  • 遵循 SOLID 原则

最后

在实际研发与测试工作中,单元测试是保证代码质量的有效手段,也是效能优化实践的重要一环。安平研效团队仍在持续探索优化中,若大家在工作中遇到相关问题,欢迎一起交流探讨,共同把研效工作做好、做强。

0 人点赞