If you write code, write tests. – The Way of Testivus
目录 Table of Contents
背景
近期由于工作需要,遂矛头瞄准了单元测试这片“无人之地”(bushi。单元测试的对比及意义将不会在本文赘述,一些概念的辨析也不会深究,但我个人认为最理想的观感至少应该满足两点:① 可以快速并重复地验证结果是否符合预期(毕竟不会有人写完代码不自测吧,汗.jpg);② 将来某一天面临重构时不至于心慌慌。
提出的这两点要求看似简单明了实则十分模糊,所以单元测试是没有固定套路的,需要在方法论的指导下和团队的实践中不断完善。值得注意的是,在很多情况下我们都会”先上车后补票“(which 不是一种好习惯,因此我们也会面临额外的难题。
本文将以 Python 工程为例,预研单元测试的可行方案(仍有很大的进步空间,to be continued… 主要讨论以下内容:
- 测试流程:3A 原则;使用 Stub 和 Mock
- 测试用例:测试哪些内容;如何设计测试用例;如何管理测试数据;用例的规范性
- 技术选型:pytest、mock 及第三方的插件
- 工程实践:如何设计脚手架;脱敏案例实践与分析
- 常见问题:被测单元具有不可测性怎么办;单元测试如何保证质量
测试流程
遵循 3A 原则
编写单元测试用例,流程遵循 3A 原则:
- Arrange:准备测试数据
- Action:调用被测单元
- Assert:判断测试结果
Stub 和 Mock
我们的工程很可能涉及到数据库的操作或外部服务调用等情况,如果我们认为单元测试应当是尽可能独立的(不同的声音见:Mock 七宗罪),那么我们就需要替换它们:
- Stub:一般指数据上的模拟
- Mock:一般指数据上和行为上的模拟
测试用例
测试粒度和优先级
优先测试业务层,而不是接口层,也不是持久层(牺牲了覆盖率,但最具性价比)
优先测试正在被频繁使用的函数
优先测试重要程度要更高的函数
优先测试代码逻辑更复杂的函数
优先测试经常会发生变更的函数
设计测试用例
首先我们应该明确,单元测试本质上是一种白盒测试,所以代码分支的分析是必要的。
其次感谢来自测试同事的技术分享,帮助回顾了在软件测试课程中学习到的几种常见方法,还引入了正交试验的概念。下面主要关注其中三种:
- 等价类/边界值:适用于参数化
- 正交试验:多分支情况下,如何选出最优用例组合
- 流程分析:处理异常情况
管理测试数据
需要管理的测试数据大致可以分为两类:
- 全局通用:作为全局常量写入统一的文件中
- 局部可用:在单元测试类中定义,可使用装饰器组织输入、输出和模拟的数据
用例的规范性
存放位置
- 功能测试类代码:放在主要测试的代码目录的
/tests/functional
中 - 单元测试类代码:放在主要测试的代码目录的
/tests/unit
中
用例命名
- 测试文件命名:以
test_
开头或以_test
结尾(遵循下划线命名法) - 测试类命名:以
Test
开头(遵循驼峰命名法) - 测试方法命名:以
test_
开头(遵循下划线命名法) - 命名格式建议:为提高测试用例的可读性,测试方法可参考
test_[测试单元名称]_[测试场景]_[测试期望结果]
来命名,如test_create_user_in_openstack_success
create_user
为测试单元名称in_openstack
为测试场景success
为测试期望结果
技术选型
单测框架
通过以下对比,Python工程项目建议选用使用更简洁、功能更丰富且兼容性更好的 pytest
测试框架。
unittest
- Python语言的标准单元测试框架
- 提供了 test cases、test suites、test fixtures、test runner 等功能
nose
- 基于
unittest
扩展插件 - 兼容
unittest
的测试集
pytest
- 支持比
unittest
更简单的断言和众多的装饰器 - 支持测试用例分类标记和运行
- 支持测试数据输入的参数化
- 兼容
unittest
和nose
的测试集 - 丰富的插件和活跃的社区
此处补充如何让 PyCharm 支持 pytest 的使用,至此 pytest 已经可以同时使用 IDE 或 CLI 来运行测试用例。
具体方法:修改 PyCharm 设置,file -> Setting -> Tools-> Python Integrated Tools -> ${ProjectName} -> Default test runner -> Choose
pytest
(unittest
by default)
模拟框架
在单元测试的过程中,需要对数据库访问和使用的外部服务进行 mock,我们希望可以实现:
- 模拟期望的返回数据
- 模拟期望的调用行为
- 模拟出错时抛出异常
由于 Python 作为一门动态语言,在运行时替换函数方法和成员变量是很容易实现的,即我们相对而言可以在不改动原有代码的情况下就实现单元测试的编写;为了更方便和更规范地使用mock功能,我们可以选用 Python 的 mock
库。
mock
- Python 最广泛被使用的
mock
第三方库,Python 3.3 后被列入标准库中 - 支持模拟返回值和副作用,以及进行调用的断言
其它工具
控制台打印的测试日志既不直观也不能持久地保存,我们需要一个可以帮助输出测试报告的工具。由于测试框架已选用 pytest
,测试报告工具可使用 pytest
的插件 pytest-html
。
pytest-html
- 可输出多种文件格式的测试报告
- 可作为插件集成到
pytest
使用
统计代码覆盖率有助于反推被测代码和单元测试设计的合理性。代码覆盖率高不能说明代码质量高,但是代码覆盖率低那么代码质量一般都不高。由于测试框架已选用 pytest
,覆盖统计工具可使用 pytest
的插件 pytest-cov
。
pytest-cov
- 兼容
coverage
库 - 可作为插件集成到
pytest
使用
工程实践
脚手架设计
单元测试脚手架可以封装一些公共行为,提供以下功能:
- 管理公共和临时的测试数据
- 封装正常调用和异常调用的流程
- 完备的报错提醒和日志记录
- …
某案例分析
Step 1:源码分析
阅读源码,整理出代码流程图
Step 2:用例设计
根据前述的设计建议,分别采取等价类/边界值、正交试验和流程分析等方法合理设计测试用例。应当记录(必要时可以进行合并的操作):
- 输入数据
- 输出期望
- 场景总数
- 异常分支
Step 3:运行用例
PyCharm 内置支持运行测试或 Terminal 输入命令运行测试
pytest 常用命令:pytest [options] [file_or_dir] [file_or_dir] […]
- -s:控制台输出被测单元print的数据
- -k:运行包含关键字的用例
- -m:运行包含分类标记的用例
- -v:打印用例执行的详细过程
- -q:打印用例执行的简略过程
Step 4:分析结果
获得测试结果报告(pytest-html)
- 安装 pytest-html 插件:pip install pytest-html
- 执行 pytest 命令:pytest [options] [test_file_or_dir] [test_file_or_dir] […] –html=[file],如 pytest ./venus –html=./venus/tests/unit/htmlrept.html
- 测试结果以更美观和持久化的形式被保存下来
获取覆盖情况报告(pytest-cov)
- 安装 pytest-cov 插件:pip install pytest-cov
- 执行 pytest 命令:pytest [options] [test_file_or_dir] [test_file_or_dir] […] –cov=[src_dir_or_module] –cov-report=html,如 pytest ./venus –cov=venus.cloud.node_network –cov-report=html
- 白色为被单元测试覆盖到的代码,红色表示未被单元测试覆盖的代码
常见问题
不可测性
单元测试的工作量比开发程序的工作量要大几乎是肯定的,但是如果你发现在掌握了一定的科学方法之后仍觉得写的十分痛苦,那么你该思考是不是因为这个被测单元本身就不具备可测性。
对现有的项目进行单元测试补充,常常会遇到这样的问题:函数代码逻辑冗杂、架构层次设计不够合理导致无法注入替换等等。这时,我想我们不得不对其进行重构了(本文对此不展开讨论,感兴趣可以阅读宝典《重构:改善既有代码的设计》)。
单测质量
只要是代码都将面临质量的考验,单元测试也不例外。
从程序本身而言,我们所能做的可以是遵循一些实用的原则:
- 单元测试不要掺杂逻辑
- 每个用例针对单一情景
- 使用经得起考验的脚手架和工具
- …
对开发人员来说,一些必要的意识是应该培养的:
- 单元测试重视起来,条件允许的话考虑 TDD 开发
- 单元测试也应该和业务代码一样接受代码评审
- 循序渐进,先会写,再写好,最后优化
- …
参考链接
以下文章对本文亦有贡献 :)
附录
使用文档和最佳实践 :)