专栏首页腾讯技术工程官方号的专栏研效优化实践:聊聊单元测试那些事儿

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

作者: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 是老牌测试框架,功能完善,用户很多。

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

最后

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

本文分享自微信公众号 - 腾讯技术工程(Tencent_TEG),作者:ciuwaalu

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-07-23

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 一文说尽Golang单元测试实战的那些事儿

    ? 导语 | 单元测试,通常是单独测试一个方法、类或函数,让开发者确信自己的代码在按预期运行,为确保代码可以测试且测试易于维护。腾讯后台开发工程师张力结合了公...

    腾小云
  • 本周末,来一场腾讯/京东/滴滴/华为等大厂产品实践年终盘点!

    2020年,从年初的直播电商火热,到年尾产业互联网持续升温,数字化时代,变是主旋律:商业逻辑在改变,行业在被重新定义。 作为产品人,怎样才能在高速发展的互联网...

    腾讯大讲堂
  • 专栏 | 聊天机器人:困境和破局

    我是一个聊天机器人的从业者,办公桌上和家里有各式各样的聊天机器人产品。和大多数用户的体验一样,对于一个刚刚到手的产品,最开始的感觉是新鲜兴奋,但当体验完功能之后...

    机器之心
  • 16位乘风破浪的产品操盘手,2天深聊产品经理还有什么新可能!

    2020年已经过去了一半,但一切却像刚开始一样,一场疫情改变了2020年的打开方式,线下火速转型线上,各种变化纷至沓来。 在当前的互联网大环境下,增长遭遇天花...

    腾讯大讲堂
  • AI 行业实践精选:2017年聊天机器人的现状(三)——未来

    【AI100 导读】在前两篇文章中,我们不仅了解了聊天机器人在投资方和企业中受欢迎的原因,还了解了当下聊天机器人的功能所在。那么聊天机器人具备哪些潜力呢?未来又...

    AI科技大本营
  • DTCC大会归来感想

    一年一度的中国数据库技术大会DTCC,迎来了第10届,从传统商业数据库各种开源数据库,从大数据到AI,从技术到管理,业界有的,大会上就有涉及的相关主题,议题相当...

    bisal
  • 微信多媒体团队梁俊斌访谈:聊一聊我所了解的音视频技术

    广州TIT创意园,这里是腾讯在广州的研发团队所在地,LiveVideoStack采访了微信多媒体内核中心音视频算法高级工程师梁俊斌(Denny)。从华为2012...

    JackJiang
  • 前端每周清单第 48 期:Slack Webpack 构建优化,CSS 命名规范与用户追踪,Vue.js 单元测试

    前端每周清单专注前端领域内容,以对外文资料的搜集为主,帮助开发者了解一周前端热点;分为新闻热点、开发教程、工程实践、深度阅读、开源项目、巅峰人生等栏目。欢迎关注...

    王下邀月熊
  • 数据是啥?数据都去哪儿了?

    大家应该都忙着给祖国庆生,根本无心上班,所以精心为各位打造一篇,一点都不用费脑的文章,一起聊聊数据及数据存储的那些事儿。敲黑板,讲重点,我们开始。

    一猿小讲
  • 产品经理年终盛会:腾讯/华为/京东/滴滴等大厂实战专家齐聚北京,2天深聊B端、商业化、业务新思考!

    12月26-27日,人人都是产品经理、起点学院重磅打造的2020年度产品经理大会最后一站,将在北京海航大厦万豪酒店开幕!与你一同探讨企业盈利模式的改变和商业文...

    腾讯大讲堂
  • 微软程骉:智能医疗产业化应用的挑战和解决之道

    【新智元导读】2016年12月18日,新智元百人峰会闭门论坛在微软亚洲研究院举行。微软亚太研发集团创新孵化总监程骉在会上带来了《对话即平台——智能医疗初探》的分...

    新智元
  • 微信团队分享:微信每日亿次实时音视频聊天背后的技术解密

    2012 年 7 月,微信 4.2 版本首次加入了实时音视频聊天功能,如今已发展了 5 年,在面对亿级微信用户复杂多变的网络和设备环境,微信多媒体团队在每个技术...

    JackJiang
  • 2017年终总结

    又到了写年终总结的时候了。每当这个时候思绪总是翻江倒海,因为太久没有反思和总结的缘故,一年才总结一次,确实是有点久,欠的账的太多,梳理起来有点费劲。这里依旧还是...

    codecraft
  • 4月深圳创新大会,腾讯,淘宝,京东等16位创新探索者都来了,等你赴约

    2018年,我们走过了北上广深杭、成都、厦门、南京等十多个城市,邀请了上百位来自腾讯、阿里、网易、美团点评、饿了么、小米、快手、携程、京东、爱奇艺、滴滴、美图...

    腾讯大讲堂
  • 自动化测试到底是什么

    偶然在群里有人问自动化测试到底是啥,搞不懂。qtp对象库好麻烦,jmeter怎么做测试。。。。一堆一堆的问题。其实说实话真心不知道该咋解答了,我的内心是累的~ ...

    企鹅号小编
  • 安全驱动下的数字新生活,第四届CSS互联网安全领袖峰会随笔

    做王妃可能挺爽(排除勾心斗角争宠夺嫡等一干桥段),但咱们可得好好算笔账。睡到中午起床的你叫了一份周末肥宅套餐,打开电脑先刷一遍社交网络,跟黑粉吵完架之后饭也到了...

    FB客服
  • 前端每周清单第 56 期: D3 5.0,深入 React 事件系统,SketchCode 界面生成

    前端每周清单专注大前端领域内容,以对外文资料的搜集为主,帮助开发者了解一周前端热点;分为新闻热点、开发教程、工程实践、深度阅读、开源项目、巅峰人生等栏目。欢迎关...

    王下邀月熊
  • 最全知乎专栏合集:编程、python、爬虫、数据分析、挖掘、ML、NLP、DL...

    上一篇文章《爬取11088个知乎专栏,打破发现壁垒》 里提到,知乎官方没有搜素专栏的功能,于是我通过爬取几十万用户个人主页所专注的专栏从而获取到11088个知乎...

    古柳_DesertsX
  • Android 开发网易面试凉凉经,面试官:基础不牢,技术不够深入,无缘offer

    网易的面试结果已经出来好几天了,一直拖着不是很想写面经,反正这会儿闲着无聊,又总是要写的(一来呢是当做一种记录吧,二来呢留给自己和需要的人看,好有个方向)就这会...

    Android技术干货分享

扫码关注云+社区

领取腾讯云代金券