最近公司越来越多的项目开始推动单元测试,而我在公司里很早就在进行单元测试实践。就用这篇文章作为一次内部技术分享的主题,同时也代表我自己对单元测试的认识和实践。
单元测试的概念
单元测试是软件测试的一种类型,测试对象是最基础的代码单元(函数、类、模块),属于白盒测试。在经典的测试金字塔中,单元测试处于最底层。
最简单的单元测试:
单元测试的意义
确保代码实现符合预期
单元测试是唯一有可能触达所有代码流程分支的测试手段
提前发现错误,并以最小的成本修复
越早发现错误,修复时间越短。
单元测试的一次发现错误、修复、测试验收循环的周期为数分钟。
集成(验收)测试的循环周期为小时级。
线上错误的发现,排查问题,修复,测试环境验证到上线的周期一般半天起步。
测试代码即文档
测试代码本身可以诠释业务代码的意图
放心重构
单元测试是代码重构的前提
编写高质量的代码(可测试、无副作用)
单元测试引导开发人员编写更容易测试的代码。
更容易测试的代码往往意味着质量更高(SRP,无副作用,圈复杂度低)。
自动化执行
单元测试的高运行速度使之可以集成到自动化流水线中。
范例
下面的代码有一个不明显的逻辑错误。
我为这段代码编写了单元测试。
单元测试执行失败了,原因是/list
接口调用find_by_page
函数是传参顺序颠倒了。
这个问题在线上是不容易发现的,尤其是在分页是从 0 开始并且页面是自动加载下一页的情况。
此时实际调用的传参是find_by_page(page_no=30,page_size=0)
,数据库查询语句指定的是skip(0).limit(0)
。前端会一次性加载出所有的数据出来,由于前端本身就是自动加载下一页,导致问题难以被发现。
我之前在线上就遇到过类似的问题,原始的错误是页面加载不出来(接口返回数据太大,超过了 grpc
默认的 message 大小)。
调整了grpc
的设置后发现前端还是加载不出页面,这时才发现接口返回了三千多条数据,随后研究了数据库查询的逻辑才发现了问题。
如何进行单元测试
单元测试的基本流程
- 准备测试数据和环境
- 执行被测试代码单元
- 检查代码单元行为是否符合预期
- 清理环境
Given->When->Then
测试代码的行为
单元测试需要验证的是代码的行为符合预期。在简单的情况下,只需要检查函数的返回值是否符合预期。
分支与边界
处理分支和边界是代码逻辑的重要组成部分。
单元测试也需要照顾到这些边界情况,不能只测试主流程。
覆盖率
有时候很难直观的判断代码的所有分支都有被测试到
通过代码测试覆盖率报告可以快速找到没有被测试到代码分支与边界情况
覆盖率也分为不同的类型
- 行覆盖率(coverage)
- 分支覆盖率
- 语句覆盖率
内部调用
大部分函数内部都会调用其他函数。
直接测试
单元测试的粒度
在上个例子中,我们直接测试了run_commands
函数,过程中间接测试了run
函数的行为,那么要不要单独为run
函数编写单元测试呢?
我的建议是根据实际情况来决定。
- 如果子函数只被父函数调用过,可以连同父函数一起进行测试。这种情况子函数往往是重构较为复杂的父函数时编写的。
- 如果子函数被不同的函数调用过,就应该单独测试这个子函数。
重构
有些函数的内部调用不直接反映在父函数的返回值里。这往往代表着函数的纯度不够,有副作用。
可以通过重构来消除这些副作用。
mock
也可以通过对子函数进行 mock 来测试父函数的行为。
副作用
纯函数是很好做单元测试的,测试有副作用的代码情况就会变得十分复杂。
避免副作用
大多数副作用都是可以避免的。
无法避免的副作用
不过也存在一些避免不了的副作用
stub
stub 指的是使用一个替身来替代一些在测试过程中的指定对象,这些对象通常会开销比较大(进行了数据库查询或网络连接),或者行为难以控制(返回结果不确定)。
Mock.side_effect
转移副作用
有时候可以将函数的副作用转移到外部,从而只需要测试函数的核心逻辑
参数化测试
在需要测试多种输入参数的时候,可以考虑使用参数化测试
测试异步代码
在 IO 密集型的场景下,异步代码可以显著提高运行效率。
异步代码的单元测试也有一些技巧。
更多 mock
系统函数
测试系统函数基本上是通过mock.patch
函数打补丁。
网络请求
数据库
针对数据库查询的单元测试并不需要进行实际的查询,只需要验证代码的行为符合预期。
文件系统
这里使用了 StubClass 和 mock 两种方式来进行文件系统的单元测试。
测试哪些代码
核心业务逻辑
- 登录注册
- 充值转账
- 业务流程
数据库查询
对外接口
- 身份认证
- 参数校验
一个例子
何时编写单元测试
建议在完成单个模块时编写模块的单元测试,模块的粒度可以因人而异,可以是一个函数,一个类,或一系列用于完成某个特性的代码片段
测试驱动开发
- 确定接口
- 编写测试
- 运行失败的测试
- 编写业务代码,通过测试