12

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

 3 years ago
source link: https://zhuanlan.zhihu.com/p/393954237
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

已认证的官方帐号

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

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

什么是单元测试?

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

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

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

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

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

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

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

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

单元测试有什么好处?

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

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

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

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

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

低级错误造成重大损失的例子实在太多了:

有了单元测试,可以避免 面向运气开发,面向回滚发布,打破“不知道有没有BUG ~ 上线出事回滚 ~ 紧急修复 ~ 代码朝着屎山演化 ~ 不知道有没有新BUG” 的恶性循环。

黑盒与白盒

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

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

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

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

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

// 从 IPv4 报文中提取源 IP 地址
uint32_t GetSrcAddrFromIPv4Packet(const void *buffer, size_t size);

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

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

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

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

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

覆盖

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

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

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

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

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(假假)四组用例时可以达到。

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

桩与驱动

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

void foo(int a) {
    if (a > 0) {
        A();
    } else {
        B();
    }
}

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

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

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

单元测试简单实践

一个简单的单元测试

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

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

// 待测函数
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 来描述上一节的用例:

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

有些现代化的测试框架(例如 catch2)对 GWT 描述做了表达上的优化。下方粘贴了一段单元测试代码示例,有对 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() 即 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 );
            }
        }
    }
}

组织结构

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

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

// 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 隔离测试用例的资源依赖,以方便管理。

用例设计

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

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

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

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

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 | Catch 2 | Boost.Test | |-| | 依赖方式 | 静态库/动态库 | 纯头文件 | 静态库/动态库/纯头文件 | | 数据驱动 | 支持 | 支持 | 支持 | | 模板化用例 | 支持 | 支持 | 支持 | | 全局 Fixture | 支持 | 不支持 | 支持 | | 用例依赖 | 不支持 | 支持 | 支持 | | 捕获 Core Dump | 不支持 | 支持 | 支持 | | 超时 | 不支持 | 不支持 | 支持 | | Benchmark | 不支持 | 支持 | 不支持 |

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

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

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

一些 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 原则

最后

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

腾讯技术交流群已建立,交流讨论可加QQ 群:160315980(备注腾讯技术) ,微信交流群加:teg_helper


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK