写点什么

原创 | 使用 JUnit、AssertJ 和 Mockito 编写单元测试和实践 TDD (七)CORRECT 边界条件

发布于: 2020 年 05 月 15 日
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (七)CORRECT边界条件


上一章讲了“对一个工作单元需要测试内容:Right-BICEP”,这一章我们讲讲“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 并发问题

时间带来的最棘手的问题是并发访问和同步访问的问题。在设计和编写代码的时候,要时刻询问自己:要是多个线程同时访问同一个对象,会出什么问题?然后针对这些可能的问题编写单元测试。


这一章我们讲到这里,下一章将讲讲“好单元测试的特质”!



发布于: 2020 年 05 月 15 日阅读数: 104
用户头像

高级架构师,技术顾问,交流公号:编程道与术 2020.04.28 加入

杨宇于2020年创立编程道与术,致力于研究领域分析与建模、测试驱动开发、架构设计、自动化构建和持续集成、敏捷开发方法论、微服务、云计算等顶尖技术领域。 了解更多公众号:编程道与术

评论

发布
暂无评论
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (七)CORRECT边界条件