前言
前面四篇文章认识了单元测试,了解了单元测试的简单使用方法,后面的文章将会介绍单元测试的更加全面的使用准则以及具体技巧
单元测试简介
什么是单元测试
- 单元测试是对软件基本单元进行测试
- 代码中是对pubic函数进行测试
为什么要做单元测试
- 自信重构,确保准确性
- 为了可测试下,改进代码
- 为预期的行为(API)文档化
- 启用代码覆盖率及其他指标
- 减少测试时间
- 自动化测试
开始单元测试
单元测试工具:XCTest+OCMock
XCTest
- 使用系统自带的
XCTest
-
XCTest
定义了一系列的断言来帮助代码的正确性(https://developer.apple.com/documentation/xctest)
OCMock
- Mock: 创建一个模拟对象,我们可以验证,修改它的行为
- Stub: Mock对象的函数返回特定的值
- Partial Mock: 重写Mock对象的方法
- OCMock 官网文献资料地址(OCMock 官网地址)
单元测试步骤
1. 找到测试场景
- 逻辑测试
- 性能测试
- 异步测试
2. 准备测试数据
- 边界测试数据
- 正确测试数据
- 错误测试数据
3. 验证结果
- 使用断言验证
单元测试注意事项
- 不是所有的方法都需要测试。
- 例如:私有方法不需要测试!只有暴露在 .h 中的方法需要测试!面向对象有一个原则:
开闭原则!
- 所有跟 UI 有关的都不需要测试,也不好测试。
- 把 业务逻辑 代码封装出来!变成可以测试的代码,让程序更加健壮!
- 一般而言,代码的覆盖度大概在
50% ~ 70%
- 从github上得知:
YYModel测试覆盖度为83%
,AFNetworking测试覆盖度为77%
,两者都是比较高的。
单元测试规范
类名规范
:需求测试的类+Tests命名命名规范
:必须以test
开头。test_函数名测试场景期望值(三段式命名)-
代码规范
:Given-When-Then
分段, 每个case其实都可以分为三步走:- mock对象,准备测试数据;
- 调用目标API;
- 验证输出和行为;
单元测试准则
这里的内容是转载、抄录其他文章的,具体地址找不到了,因为整理的很全,所以这里做了记录,如果发生侵权行为,请联系我。
-
保持单元测试小巧, 快速
理论上, 任何代码提交前都应该完整跑一遍所有测试套件. 保持测试代码执行迅捷能够缩短迭代开发周期.
-
单元测试应该是全自动且无交互
测试套件通常是定期执行的, 执行过程必须完全自动化才有意义. 需要人工检查输出结果的测试不是一个好的单元测试.
-
让单元测试很容易跑起来
对开发环境进行配置, 最好是敲条命令或是点个按钮就能把单个测试用例或测试套件跑起来.
-
对测试进行评估
对执行的测试进行覆盖率分析, 得到精确的代码执行覆盖率, 并调查哪些代码未被执行.
-
立即修正失败的测试
每个开发人员在提交前都应该保证新的测试用例执行成功, 当有代码提交时, 现有测试用例也都能跑通.
如果一个定期执行的测试用例执行失败, 整个团队应该放下手上的工作优先解决这个问题. -
把测试维持在单元级别
单元测试即类 (Class) 的测试. 一个 "测试类" 应该只对应于一个 "被测类", 并且 "被测类" 的行为应该被隔离测试. 必须谨慎避免使用单元测试框架来测试整个程序的工作流, 这样的测试既低效又难维护. 工作流测试 (译注: 指跨模块/类的数据流测试) 有它自己的地盘, 但它绝不是单元测试, 必须单独建立和执行.
-
由简入繁
最简单的测试也远远胜过完全没有测试. 一个简单的 "测试类" 会促使建立 "被测类" 基本的测试骨架, 可以对构建环境, 单元测试环境, 执行环境以及覆盖率分析工具等有效性进行检查, 同时也可以证明 "被测类" 能够被整合和调用.
下面便是单元测试版的 Hello, world! :void testDefaultConstruction() { Foo foo = new Foo(); assertNotNull(foo); }
-
保持测试的独立性
为了保证测试稳定可靠且便于维护, 测试用例之间决不能有相互依赖, 也不能依赖执行的先后次序。
-
合理的命名测试用例
确保每个方法只测试 "被测类" 的一个明确特性, 并相应的命名测试方法. 典型的命名俗定是 test[what], 比如 testSaveAs(), testAddListener(), testDeleteProperty() 等.
-
只测公有接口
单元测试可以被定义为 通过类的公有 API 对类进行测试. 一些测试工具允许测试一个类的私有成员, 但这种做法应该避免, 它让测试变得繁琐而且更难维护. 如果有私有成员确实需要进行直接测试, 可以考虑把它重构到工具类的公有方法中. 但要注意这么做是为了改善设计, 而不是帮助测试。
-
看成是黑盒
站在第三方使用者的角度, 测试一个类是否满足规定的需求. 并设法让它出问题.
-
看成是白盒
毕竟被测试类是程序员自写自测的, 应该在最复杂的逻辑部分多花些精力测试.
-
芝麻函数也要测试
通常建议所有重要的函数都应该被测试到, 一些芝麻方法比如简单的 setter 和 getter 都可以忽略. 但是仍然有充分的理由支持测试芝麻函数:芝麻函数很难定义. 对于不同的人有不同的理解.
从黑盒测试的观点看, 是无法知道哪些代码是芝麻级别的。即便是再芝麻的函数, 也可能包含错误, 通常是 "复制粘贴" 代码的后果:
private double weight_; private double x_, y_; public void setWeight(int weight) { weight = weight_; // error } public double getX() { return x_; } public double getY() { return x_; // error }
因此建议测试所有方法. 毕竟芝麻用例也容易测试.
-
先关注执行覆盖率
区别对待
执行覆盖率
和实际测试覆盖率
. 测试的最初目标应该是确保较高的执行覆盖率. 这样能保证代码在 少量 参数值输入时能执行成功. 一旦执行覆盖率就绪, 就应该开始改进测试覆盖率了. 注意, 实际的测试覆盖率很难衡量 (而且往往趋近于 0%).思考以下公有方法:
void setLength(double length);
调用 setLength(1.0) 你可能会得到 100% 的执行覆盖率. 但要达到 100% 的实际测试覆盖率, 有多少个 double 浮点数这个方法就必须被调用多少次, 并且要一一验证行为的正确性. 这无疑是不可能的任务.
-
覆盖边界值
确保参数边界值均被覆盖. 对于数字, 测试负数, 0, 正数, 最小值, 最大值, NaN (非数字), 无穷大等. 对于字符串, 测试空字符串, 单字符, 非 ASCII 字符串, 多字节字符串等. 对于集合类型, 测试空, 1, 第一个, 最后一个等. 对于日期, 测试 1月1号, 2月29号, 12月31号等. 被测试的类本身也会暗示一些特定情况下的边界值. 要点是尽可能彻底的测试这些边界值, 因为它们都是主要 "疑犯".
-
提供一个随机值生成器
当边界值都覆盖了, 另一个能进一步改善测试覆盖率的简单方法就是生成随机参数, 这样每次执行测试都会有不同的输入.
想要做到这点, 需要提供一个用来生成基本类型 (如: 浮点数, 整型, 字符串, 日期等) 随机值的工具类. 生成器应该覆盖各种类型的所有取值范围.
如果测试时间比较短, 可以考虑再裹上一层循环, 覆盖尽可能多的输入组合. 下面的例子是验证两次转换 little endian 和 big endian 字节序后是否返回原值. 由于测试过程很快, 可以让它跑上个一百万次.void testByteSwapper() { for (int i = 0; i < 1000000; i++) { double v0 = Random.getDouble(); double v1 = ByteSwapper.swap(v0); double v2 = ByteSwapper.swap(v1); assertEquals(v0, v2); } }
-
每个特性只测一次
在测试模式下, 有时会情不自禁的滥用断言. 这种做法会导致维护更困难, 需要极力避免. 仅对测试方法名指示的特性进行明确测试.
因为对于一般性代码而言, 保证测试代码尽可能少是一个重要目标. -
使用显式断言
应该总是优先使用 assertEquals(a, b) 而不是 assertTrue(a == b), 因为前者会给出更有意义的测试失败信息. 在事先不确定输入值的情况下, 这条规则尤为重要, 比如之前使用随机参数值组合的例子.
-
提供反向测试
反向测试是指刻意编写问题代码, 来验证鲁棒性和能否正确s的处理错误.
假设如下方法的参数如果传进去的是负数, 会立马抛出异常:
void setLength(double length) throws IllegalArgumentExcepti
可以用下面的方法来测试这个特例是否被正确处理:
try { setLength(-1.0); fail(); // If we get here, something went wrong } catch (IllegalArgumentException exception) { // If we get here, all is fine }
-
代码设计时谨记测试
编写和维护单元测试的代价是很高的, 减少代码中的公有接口和循环复杂度是降低成本, 使高覆盖率测试代码更易于编写和维护的有效方法.
一些建议:
- 使类成员常量化, 在构造函数中进行初始化. 减少 setter 方法的数量.
- 限制过度使用继承和公有虚函数.
- 通过使用友元类 (C++) 或包作用域 (Java) 来减少公有接口.
- 避免不必要的逻辑分支.
- 在逻辑分支中编写尽可能少的代码.
- 在公有和私有接口中尽量多用异常和断言验证参数参数的有效性.
- 限制使用快捷函数. 对于黑箱而言, 所有方法都必须一视同仁的进行测试.
思考以下简短的例子:
public void scale(double x0, double y0, double scaleFactor) { // scaling logic } public void scale(double x0, double y0) { scale(x0, y0, 1.0); }
删除后者可以简化测试, 但用户代码的工作量也将略微增加.
-
不要访问预设的外部资源
单元测试代码不应该假定外部的执行环境, 以便在任何时候/任何地方都能执行. 为了向测试提供必需的资源, 这些资源应该由测试本身提供.
比如一个解析某类型文件的类, 可以把文件内容嵌入到测试代码里, 在测试的时候写入到临时文件, 测试结束再删除, 而不是从预定的地址直接读取.
-
权衡测试成本
不写单元测试的代价很高, 但是写单元测试的代价同样很高. 要在这两者之间做适当的权衡, 如果用执行覆盖率来衡量, 业界标准通常在 80% 左右.
很典型的, 读写外部资源的错误处理和异常处理就很难达到百分百的执行覆盖率. 模拟数据库在事务处理到一半时发生故障并不是办不到, 但相对于进行大范围的代码审查, 代价可能太大了.
-
安排测试优先次序
单元测试是典型的自底向上过程, 如果没有足够的资源测试一个系统的所有模块, 就应该先把重点放在较底层的模块.
-
测试代码要考虑错误处理
考虑下面的这个例子:
Handle handle = manager.getHandle(); assertNotNull(handle); String handleName = handle.getName(); assertEquals(handleName, "handle-01");
如果第一个断言失败, 后续语句会导致代码崩溃, 剩下的测试都无法执行. 任何时候都要为测试失败做好准备, 避免单个失败的测试项中断整个测试套件的执行. 上面的例子可以重写成:
Handle handle = manager.getHandle(); assertNotNull(handle); if (handle == null) return; String handleName = handle.getName(); assertEquals(handleName, "handle-01");
-
写测试用例重现 bug
每上报一个 bug, 都要写一个测试用例来重现这个 bug (即无法通过测试), 并用它作为成功修正代码的检验标准.
-
了解局限
单元测试永远无法证明代码的正确性!!一个跑失败的测试可能表明代码有错误, 但一个跑成功的测试什么也证明不了.单元测试最有效的使用场合是在一个较低的层级验证并文档化需求, 以及 回归测试: 开发或重构代码时,不会破坏已有功能的正确性.