CORRECT 边界条件
代码中的许多bug都出现在“边界条件”附近,也就是说,在那些条件下,代码的行为可能不同于平常的、每天都能运行到的程序路径。
在面向对象的编程中,对象的方法执行结果是对象内部字段和方法参数的函数。我们需要通过单元测试来确认,当字段和/或参数处于边界条件时,方法的执行结果符合我们的预期。
我们用首字母缩略词CORRECT来帮助你列举需要测试的边界条件(字段或参数的取值):
- Conformance(一致性)——值是否符合预期的格式?
- Ordering(有序性)——一组值应该是有序的,还是无序的?
- Range(区间性)——值是否在一个合理的区间之内?
- Reference(耦合性)——代码是否引用了不受控的外部因素?
- Existence(存在性)——值是否为空?
- Cardinality(基数性)——是恰好有足够的值?
- Time(时间性)——所有的事情是否都是按顺序发生的?是否在正确的时间?是否及时?
你需要好好回答的问题是:
还有什么可能出错?
并针对每个可能出错的地方编写一个测试。
1. Conformance 一致性
值格式是否符合要求?
例如,我们有个方法会从方法参数接收一个字符串形式的email地址,并随后向这个email地址发送一封标准化邮件。但是用户代码可能在调用这个方法时传入一个不包含“@”的字符串。如果实现代码中没有针对这种情况预先做好应对措施,就有可能在运行中出错。因此,应当针对这类情况预先编写单元测试,检验在传入不能转换成email地址的字符串时,方法的响应符合预期。
当然针对上面这个例子,更好的设计是设计一个EmailAddress类,并使用它作为方法的参数类型。这样就可以确保只有合法的email才能传递给方法,从而不需要针对这个方面编写单元测试。
2. Ordering 有序性
数据或调用是否遵循一定的顺序?
例如以下情况要测试顺序:
- 如果方法返回一个列表,列表中的元素是否确实按照我们设定的顺序排序?
- 如果方法执行中需要调用两个或两个以上不同的外部依赖(例如:先写入数据库,再写入缓存),调用时是否确实遵循我们要求的先后顺序?
3. Range 区间性
值是否位于合理的区间之内?
例如:
- 方法参数充值金额是不是正数?
- 两个方法参数,fromTime是否早于toTime?
- 数组的索引值是否超出范围?
这些都是需要进行单元测试检测的地方。
4. Reference 耦合性
如果被测工作单元有外部依赖(其他协作类、数据库、文件系统等等)或环境依赖,工作单元的执行结果依赖于这些外部依赖的存在以及它们的状态,那么就应该针对这些情况编写单元测试,保证在这些依赖未满足的情况下运行良好。
主要测试这些方面:
- 外部依赖存在/不存在,状态正常/异常时,代码的行为是否符合预期?
- 测试完成后,代码是否像预期的那样调用了这些外部依赖(例如充值成功后是否更新了缓存中的余额)?
5. Existence 存在性
对于你传入或维护的值,先询问自己如果值不存在——如果它为null, 空集合,或者等于0,方法的行为将会怎样?
针对上面各种“值不存在”的情况分别编写单元测试。确保你的产品代码在值不存在的情况下的响应符合预期。
6. Cardinality 基数性
在《Pragmatic Unit Testing: In Java with JUnit》一书中举了这样一个例子:假设你正在维护一个披萨店的十大最受欢迎的食品列表。每次有新订单下来,这个列表都会进行自动调整,并将更新后的列表发送到老板的手机上。此时,你需要测试哪些内容呢?
- 当列表条目不足10个时,能出报表吗?
- 当列表空无一物时,能出报表吗?
- 当列表只有1个条目时,能出报表吗?
- 当列表条目不足10个时,能添加新条目吗?
- 当列表空无一物时,能添加新条目吗?
- 当列表只有1个条目时,能添加新条目吗?
- 要是菜单本身就没有10个条目,怎么办?
- 要是菜单中没有任何条目,又怎么办?
这些都是和数量相关的问题。在大多数情况下,你只需要考虑下列三种值:
- 0
- 1
- n ( n > 1)
这称为0-1-n原则。
7. Time 时间性
你需要始终记得以下这些与时间相关的方面:
- 相对时间(时间上的顺序)
- 绝对时间(消耗的时间和钟表上的时刻)
- 并发问题
7.1 相对时间
一些对象会有自己的内部状态。为了维护这些内部状态,你期望login()会在logout()之前被调用,prepareStatement()会在executeStatement()之前被调用,connect()在read()之前被调用,而read()又在close()之前被调用,等等。
你应该编写单元测试,测试如果这些顺序条件不满足时,工作单元的响应是否符合预期。例如,在没有调用login()之前执行logout()。如果你的设计意图是未login()之前执行logout()会抛出LogoutBeforeLoginException异常:
if (!loggedIn) {
throw new LogoutBeforeLoginException();
}
那么你应该写这样的单元测试:
assertThrows(LogoutBeforeLoginException.class, () -> {
instance.logout();
});
相对时间还包括代码中的超时问题。你需要编写单元测试验证可能的超时出错问题。
7.2 绝对时间
与绝对时间/时刻相关的典型问题是这样一个问题。如果你在实现一个时间工具类。有一个静态方法是计算某个日期加若干个月之后是个什么日期:
public static LocalDate addMonths(LocalDate origDate, int months)
如果原始日期是2020年1月5日,我们期待这个方法加1个月后是2020年2月5日,加2个月后是2020年3月5日。但是如果原始日期是2020年1月30日,那么这个日期加1个月是个什么日期?2020年2月30日?没有这个日期!每年的2月一般只有20日,最长也只有29日。在这种情况下,我们必须首先定义好2020年1月30日加1个月是个什么日期,然后针对这个特殊点编写单元测试。
7.3 并发问题
时间带来的最棘手的问题是并发访问和同步访问的问题。在设计和编写代码的时候,要时刻询问自己:要是多个线程同时访问同一个对象,会出什么问题?然后针对这些可能的问题编写单元测试。