谈谈我都是怎么进行单元测试的?(下)

发表于:2024-4-18 09:13

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:七阶子    来源:知乎

  三、单元测试用例运行与管理
  写完单元测试用例,下一步就是编译、链接生成可执行测试程序,并让它跑起来。用 couttast 库编写的单元测试程序,它就是一个普通的命令行程序。最简单的运行方式 就是不带任何参数运行,它就会按一定顺序依次执行测试源码中用 DEF_TAST 定义的用 例。如果 main() 转发 RUN_TAST 返回值是 0 ,表示失败用例数为 0 ,则程序 退出码也是 0 ,也就是所有测试通过。
  在很多正常情况下,比如集成到自动化流程后,这个默认行为也就够了,很平淡无奇。但 在某些情况下,可能就要有选择性地执行某个或某几个测试用例了。比如自动化测试报告 某个测试用例失败了,那就要单独拎出这个测试用例来,在个人的开发环境中重新跑一遍 ,排查问题。又比如在开发过程中,不想每次全量跑所有测试用例,那可能耗时比较长, 只想跑自己新加的几个测试用例。
  所以,我认为作为内含许多测试用例的命令行程序,它的命令行参数最重要的作用就是指 定运行哪个或哪些测试用例。在 couttast 中,对命令行位置参数就是这么解释的。位 置参数就是不带 -- 前导的纯参数,带 -- 的参数(对)也经常叫选项。此外,为了 方便用户,couttast 不要求输入测试用例的全名,可以只输入部分字符串,测试用例 中包含这个子串的视为匹配用例,会被执行。
  我对 gTest 的一个痛点记忆,就是要指定运行某个(某些)测试用例比较麻烦, --gtest_filter 选项参数格式还挺复杂。而且测试用例是按随机顺序执行的,它的 随机哲学是想保证测试用例的独立性,避免特定执行次序的依赖性。初看起来这没什么毛 病,但这个组合王炸就曾在我当年工作中造成很大麻烦。
  事情是这样的,有 n 个单元测试用例的测试程序,不带参数默认跑,会报一两个不通 过的失败用例,但单独跑那个失败用例却又能通过。那原因应该是某些用例互相影响了, 关键是如何更方便地找出互有影响的用例。程序员容易想到用二分法找问题。假设 n=10 ,第 50 号用例失败了,单测 50 号没问题。那问题就在前 50 个用例,再二分 验一下 26-50 以及 1-24,50 用例。但悲剧的是 gTest 它没有很好的办法筛选出 特定的 25 个用例,而即使恰巧能通过什么通配语法筛出来,它还是随机顺序跑的,也就 意味着可能复现不了。因为原因可能是需要某两个(甚至某三个用例)按特定顺序执行才 会触发 bug 。最后我们只能采用巨麻烦的笨办法,修改 main() 函数,调用 gTest 内部的 api 加入特定的测试用例,先猜哪两个用例可能有互有影响,修改 main() 重 编译跑一下,猜错了重新修改、编译、运行……
  我不知道现在 gTest 对此类问题有没更好的解决办法了,反正当年是对此事印象深刻 。若非此事,我也没足够的动力放着 gTest 不用转而自己从头造个单元测试的轮子。 如果用 couttast 遇到类似问题,只要把猜测有影响的用例名按顺序粘贴到命令行重跑 就能排查——我觉得这是很符合直觉的尝试方案。
  同时我也不觉得随机顺序是重要特性。我在 couttast 内部只是采用最常用的 std::map 保存测试用例,所以恰好有序,默认就有序运行,那就让它有序,没必要费 劲特地随机化。用户难不成还能利用这“漏洞”使原来不通过的测试用例变成通过不成, 动机收益何在?以及可能反向的收益,还能期望利用随机顺序的测试用例来发现被测目标 软件(或库)的潜在 bug ,听起来也不怎么靠谱吧。
  除了最重要的位置参数用于筛选指定测试用例外,couttast 也支持一些选项参数。比 如 --list 列出所有测试用例名,也是有序的,与默认运行的所有用例顺序一样。大写 的 --List 则列出更详细的用例信息,包括 DEF_TAST 定义时传入的描叙性第二参数 。这就使得 couttast 有了基本的管理用例功能,可以较方便地导出测试用例一览表。
  此外,couttast 的默认输出信息可能冗余度比较大,因为我认为单元测试也是开发的 一个辅助工具,所以默认输出详尽一点。但开发与自测完成,提交到测试阶段,可能就没 必要打印太多输出了。因此有选项 --cout=[fail|silent|none] 来精减输出:其中 fail 表示 COUT 双参数断言时只打印失败的语句,不打印成功的语句;silent 输 出得比 fail 还少,但失败的语句还是有必要打印的;none 是真的什么都不打印了 ,但仍可以通过退出码零与非零来判断测试是否通过。在集成到自动化测试流程中,调用 脚本加上 --cout=silent 参数可能是比较合适的。
  四、单元测试库的设计与集成
  对于普通开发,对这层可不必太关注,重点应关注前两层单元测试用例的编写,以及了解 所用测试框架(或库)的使用,如命令行参数的意义与用法。
  所以我也只简单谈谈开发 couttast 单元测试库过程的一些想法。缘由前面几节已有涉 及,最开始为了简单方便,就写了个单个头文件的 header-only 。刚开始了解到 C++ 的 header-only 库时,也像面向对象一样,觉得它很酷很方便。但后来又有了不同的反思, 面向对象不是唯一,header-only 库也有它的不足,对维护与使用也都有它特定的麻烦。
  所以我在后来为 couttast 补充一些非核心的扩展功能时,就没再坚持 header-only ,而是写到独立的 .cpp 中编译成静态库。但核心功能还是保持在一个单头文件 tinytast.hpp 中,也能单独使用。这符合二八定律,用少量代码完成大部分功能。
  所以即使是扩展了静态库,也保持了轻量与无依赖。因为我觉得单元测试库的设计与单元 测试用例的设计一样,都要保持简单。如果测试代码本身复杂了,那就增加引入 bug 的 风险,大大削减了测试的作用与意义。其他库可能是功能越强大越好,但单元测试库,可 能未必如此。
  尤其是,C++ 的库依赖也一直是老大难的问题,菱形依赖更是天坑。这才是单元测试工 具库需要保持轻量无依赖的重要原因。因此,即使你觉得 couttast 不合你的口味,也 应尽量选用无依赖的库,避免后面不经意间引入菱形依赖导致库冲突的错误,那就很冤。 这种情况一旦出现,就很难排查,因为被测代码没问题,测试代码也没问题,合在一起就 出问题了。
  集成是指将单元测试程序集成至其他高阶流程中,例如自动化 CICD ,自动化测试应是其 中一步。基于 linux 开发的命令行程序,都是很容易集成的。
  五、可测试程序的一般原则
  最后,再简单聊一点非技术的观点。
  其实,单元测试本身是没有价值的,有价值的是被测程序。这是一种依附关系,正所谓皮 之不存,毛之焉附。如果被测程序本身是一坨,单元测试再怎么玩也难以屎上雕花。
  所以单元测试最最重要的一点,是被测程序具有可测试性。可测性是可维护性代码的重要 质量指标。可测试性,从微观上讲也不难理解,就是不要写成一坨,需要恰当的分解,每 个子函数(类、模块)职责单一明确,有易控制的输入输出。
  对于分解的粒度,我推崇一个简单粗暴的数量级划分。对于 shell ,就适合写单行命令 行,对于脚本语言,每个函数适合写 10 行数量级,而对于 C++ 语言,每个函数适合写 100 行量级,每个源文件在 1000 行左右。
  对于 TDD ,测试驱动开发,我认为也只是个美好的理论,实际项目中严格遵循 TDD 未必 合适。我更推荐双向奔赴的过程,先做基本框架设计,再写单元测试用例,再完善代码设 计……如此交错,直到完成开发目标。一份良好设计的软件代码,与其单元测试代码,理 想情况下,就正如 DNA 双螺旋那样,相辅相成,互为补益纠正,共同保障软件朝正确可 控的方向演化与进化。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号