单元测试
目标
根本目标:
软件的可持续发展
原因:代码会随着时间腐化。每次改动都会增加软件熵,增加无序性。代码是负债,而不是资产。
解决方案:
- 持续清理和重构
- 编写单元测试进行校正
收益:
- 减少调试时间
- 改善代码质量
- 帮助理解功能
- 增加重构自信
代价:单元测试也是代码,需要消耗时间和精力,因此需要平衡成本和收益。
如何收益最大化:
- 代码的价值不同。应区分核心代码和边缘代码
- 对核心代码编写单元测试
- 编写有用的单元测试
开发人员应当:
- 学会辨别单元测试的好坏
- 通过重构使代码恢复价值
宁愿不写单元测试也不要写没有价值的单元测试。
评估方法
单元测试的评估指标是代码质量的晴雨表。
代码覆盖率
分支覆盖率l
高覆盖率不代表代码质量高,但低覆盖率一定意味着代码质量低。单元测试不是为了提高评估指标,而是为了提高代码质量。
定义
单元测试是开发者编写的一小段代码,用于检验被测代码单元的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或场景)下某个特定函数(或功能)的行为。单元测试是对软件基本组成单元进行的测试,所谓单元指的是:最小的被测功能模块,它可以是一个类,也可以是一个方法。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的最小单元在与其他部分相隔离的情况下进行测试。
单元测试的三个基本特点:
- 小
- 快
- 独
伦敦派
隔离性是指被测组件或系统与其依赖之间的隔离。
好处:
- 方便快速定位测试失败原因
- 方便构造依赖对象
- 方便组织测试代码
经典派
隔离性是指功能的隔离,亦即应当隔离单元测试代码本身,而不是被测试的代码单元。测试的目的是验证功能是否正确,而不是验证代码单元是否正确。因此,代码结构应当按照功能单元来组织,测试也应该如此。如此一来,不同的单元测试可以以任意顺序执行(串行、并行),并且互不影响。
两种学派的对比
伦敦派:
- 关注代码单元,粒度更细
- 所有依赖都应该使用测试提替身
经典派:
- 过分关注代码单元没有意义,应该聚焦业务价值(功能)
- 测试应该面向功能,而非代码单元
- 依赖关系复杂意味着设计不合理
两种学派在做TDD时也截然不同。伦敦派可以自顶向下做TDD,经典派只能自底向上做TDD。
方法论
实施策略
- 单元测试伴随整个代码开发过程
- 单元测试只针对最有价值的代码
- 单元测试提供最高的性价比
编写原则
- F(Fast):测试用例要能够简单快速的运行起来
- I(Isolate):测试用例要尽量独立,不能够相互影响或依赖
- R(Repeatable):测试用例要能够重复运行
- S(Self-verifying):测试用例要能够自己检测输出,判断结果
- T(Timely):测试用例要及时写
构建模式
使用AAA模式,将单元测试分为三个部分:arrange、act和assert。
- arrange:将SUT和其依赖设置到指定状态
- act: 执行SUT的方法,获取输出
- assert: 通过断言验证输出结果
实用建议:
- 单元测试代码不应该包含任何分支语句
- 尽量避免多个arrange,act和assert
- act部分应当只有一行代码
- arrange部分应当是代码量最多的部分
- 将被测方法命名为sut
反例:
- 不要测试私有方法
- 不要暴露和检测sut的内部状态
- 不要mock具体实现类
- 不要让测试代码污染业务代码
健壮性
单元测试的核心作用:
- 当代码存在bug时,单元测试应当能够发现bug
- 当代码没有bug时,单元测试应该显示没有bug
- 检测应当迅速且容易实施和维护
这里可以把单元测试看做是对人进行核酸检测。把人看做是被测的代码单元,把核酸检测看做是单元测试代码的执行。上面对单元测试的要求可以对应以下三个对核酸检测的要求:
1. 如果一个人阳了,核酸检测却显示阴性,则核酸检测无法正确检出小阳人,此时出现假阴性。
2. 如果一个人没阳,核酸却显示阳了,则核酸检测误报,此时出现假阳性。
3. 要降低核酸检测的成本,如果核酸成本高,就难以及时检测,也难以大规模检测,也就无法及时发现小阳人了
1.和2 两种情况我们都不希望出现,但是应该杜绝假阴性,可以容忍假阳性。假阴性导致的后果很严重(无法检出小阳人,会导致病毒扩散),假阳性不会向假阴性一样引起那么严重的后果,但同样会降低人们对核酸的信任(单元测试出现假阳性,开发人员会觉得单元测试代码不靠谱,那么在开发中就不会执行单元测试了)。
单元测试代码应当抑制功能退化:防止假阴性。
好的单元测试可以很好的确保新特性不会影响旧功能。
具体来说,sut的代码量越大、复杂度越高,业务价值越大,添加新特性、新功能时影响旧功能的可能性越大。单元测试应当聚焦这些代码,而非边缘样板代码。在对这些代码做单元测试时,设计出的单元测试代码应当尽可能的检测出这些业务中的bug,尽量避免代码中有bug,测试代码却没有检测出来的情况.特别是在对旧功能添加新特性的时候,如果新增的代码影响了旧有的功能,单元测试应当立即能够检测出来问题。
单元测试代码应当免疫重构:可以防止假阳性
好的单元测试应当增加重构自信,它只会在重构破坏了原有功能时不通过,在没有破坏原有功能时正常通过。如果经常出现假阳性,开发人员会对单元测试失去信心。
快速和易维护
单元测试应当能够快速执行并给出结果(外部依赖很少)。
单元测试代码应当易于维护(易于理解)。
存不存在能够同时满足以上三点要求的单元测试?答案是不存在那么完美的单元测试。我们只能做抉择。
- 不出现假阳性(对重构免疫)是基本要求
- 在检测速度与灵敏度之间做抉择l
黑盒测试
黑盒测试:在不知道软件内部结构和实现的情况下,对软件的功能进行测试。只关心软件的功能是否正确实现,不关心软件的功能是如何实现的。
白盒测试:与黑盒测试相反,了解软件内部实现的情况下,对其内部实现进行测试验证,更关心软件的内部行为。
白盒测试:更全面,但与代码实现耦合更紧密,更容易出现假阳性。黑盒测试:没有白盒测试脆弱和敏感,抑制假阳性方面做的更好,测试灵敏度又在可接受范围。
单元测试推荐使用黑盒测试。
Mock
Mock vs Stub
Mock用于模拟和验证输出方向的交互。Stub用于模拟输入方向的交互。尽量不要验证sut与stub的交互,一旦验证意味着验证了实现细节,而不是功能。
Spy vs Mock
框架自动生成的叫Mock,自己手动创建的叫Spy。
依赖
受控依赖:只被自己系统使用的依赖,自己有完全控制权
非受控依赖:各系统共享的依赖,自己没有完全控制权
验证
好的测试代码应该只验证sut的功能,而非其实现细节。亦即验证sut做了什么,做对了没有,而不是验证它是怎么做的。如何区分功能和实现细节?
所有的代码都可以分为两类:公共API和私有API。公共API通过暴露函数或者状态给调用者,帮助其实现目标。私有API则不被调用者感知。暴露给使用者的公共API是可以并且需要验证的,其他的都不应该去验证。
为了使单元测试能够做到验证行为,不与实现细节耦合,需要业务代码具有良好的设计。业务代码需要合理的封装,将实现细节隐藏起来,只暴露能够帮助使用者实现其目标的API和状态,并确保这些API和状态不会违反一致性。好的单元测试与好的设计有着天然的联系。
验证的三种途径:
- 验证结果
- 验证状态
- 验证行为
验证结果的单元测试具有更好的重构免疫力,验证行为的单元测试重构免疫力最差。
集成测试
集成测试关注代码与外部依赖(进程外的依赖)一起工作时的状态。
特征:
- 测试的代码范围更广
- 对外部有依赖
- 与业务代码耦合性更低,防止功能退化能力更强
- 集成测试专注于代码与其依赖一起工作能否成功
集成测试用于测试controller,单元测试用于测试业务逻辑。
集成测试在防止功能退化和免疫重构方面做得更好,而单元测试更快速且更容易维护。
集成测试应当更关注单元测试做不到的地方:单元测试尽可能多的进行边界测试,集成测试尽可能少的进行边界测试。集成测试应当尽可能多的关注sut和其依赖能否协作,因此集成测试的功能需要把所有的依赖都贯穿进来。
参考资料
- Manning.Unit.Testing.Principles.Practices.and.Patterns.
- Practical-Unit-Testing-with-JUnit-and-Mockito
- https://junit.org/junit5/docs/current/user-guide/
- https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
- https://hamcrest.org/JavaHamcrest/tutorial
- https://docs.spring.io/spring-boot/docs/2.7.12/reference/htmlsingle/#features.testing
- https://howtodoinjava.com/spring-boot2/testing/