主要结论
- JUnit 5 就要来了!
- 其中包含改进的 API 和扩展模型将大幅完善“JUnit 工具”。
- 模块化的体系结构使得“JUnit 平台”可以用于其他测试框架。
- 虽然经过了彻底重写,但可在同一个代码基中与老版本 Junit 共存。
由专职开发者组成的团队目前正在着手开发 JUnit 5,这一 Java 世界最受欢迎程序库的下个版本。虽然表面上只是陆续提供一些细枝末节的改进,但内部创新非常多,甚至有望重新定义 JVM 测试。
2015 年 11 月发布原型并在2016 年2 月发布 Alpha 测试版后,7 月的第一周他们发布了 Milestone 1 版本。我们打算体验一下!
在该系列的第一篇文章中,我们将介绍如何开始编写测试,介绍新版本包含的所有细微改进,讨论 JUnit 团队为何决定现在进行重写,此外最重要的是,还将介绍新的体系结构如何彻底革新 JVM 测试。
该系列的第二篇文章将更深入地介绍如何运行测试,介绍 JUnit 提供的一些炫酷新功能,并演示如何对核心能力进行扩展。
编写测试
首先从媒体资源开始看看如何快速写出需要的测试。
五秒钟设置完毕
下文还将更为深入地介绍配置和体系结构。现在先看看如何将这些内容导入我们选择的构建工具:
org.junit.jupiter:junit-jupiter-api:5.0.0-M1 org.junit.jupiter:junit-jupiter-engine:5.0.0-M1 org.junit.platform:junit-platform-runner:1.0.0-M1
就这么简单,一旦完整的 JUnit 5 支持彻底实现,只需要junit-jupiter-api
就够了。(下文将进一步介绍)
随后需要一个用来包含这个测试的类:
package com.infoq.junit5; import org.junit.platform.runner.JUnitPlatform; import org.junit.runner.RunWith; @RunWith(JUnitPlatform.class) public class JUnit5Test { // tests go here }
至此已经全部搞定,可以开始编写 JUnit 5 测试并通过 IDE 或构建工具执行。
一个简单示例
对于较为简单的测试,几乎没什么太大变化:
@RunWith(JUnitPlatform.class) public class JUnit5Test { @BeforeAll static void initializeExternalResources() { System.out.println("Initializing external resources..."); } @BeforeEach void initializeMockObjects() { System.out.println("Initializing mock objects..."); } @Test void someTest() { System.out.println("Running some test..."); assertTrue(true); } @Test void otherTest() { assumeTrue(true); System.out.println("Running another test..."); assertNotEquals(1, 42, "Why wouldn't these be the same?"); } @Test @Disabled void disabledTest() { System.exit(1); } @AfterEach void tearDown() { System.out.println("Tearing down..."); } @AfterAll static void freeExternalResources() { System.out.println("Freeing external resources..."); } }
表面上 JUnit 5 只是陆续提供一些细枝末节的改进,但真正的革新都在内部,稍后会介绍这些内容。首先一起来看看这一版本中一些最显著的改进。
诸多改进
可见性
最显著的变化是,测试方法不再必须设置为公开的。这就针对程序包提供了足够的可见性(但私有测试无法实现),借此可以避免测试类充斥着乱七八糟的公开关键字。
理论上,测试类也可以使用默认可见性。但因为上文使用了简单的配置过程,我们的工具只能在公开的类中扫描注解(Annotation),JUnit 5 的支持彻底实现后这一情况将有所改善。
测试生命周期
@Test
JUnit 4 最基本的注解是@Test
,可用于标记作为测试运行的方法。
该注解几乎没什么变化,不过已经不再接受可选参数,预期异常可通过断言(Assertion)进行验证。(但目前还没有超时的替代品)
Before 和 After
为了运行代码以配置并取消测试,可以使用@BeforeAll
、@BeforeEach
、@AfterEach
,以及@AfterAll
。它们的名称变得更恰当,但与 JUnit 4 中的@BeforeClass
、@Before
、@After
,以及@AfterClass
使用了完全相同的语义。
因为每个测试都需要新建一个实例,并且@BeforeAll
/ @AfterAll
只能对它们统一调用一次,可能无法确定要使用哪个实例,因此只能将其设置为静态(与 JUnit 4 中的@BeforeClass
和@AfterClass
情况类似)。
如果使用同一个注解标注了不同方法,将刻意使用未指定的顺序执行。
禁用测试
测试可直接使用@Disabled
禁用,这一点类似于 JUnit 4 中的@Ignored
。但这只是特殊情况的 Condition,下文介绍 JUnit 的扩展时还将详细讨论。
断言
一切配置并执行完毕后,最后需要通过断言验证期望行为。这一领域也有很多细微的改进:
- 断言消息已经可以包含在参数列表中,这样即可用更为统一的方式执行包含或不包含消息的调用,因为前两个参数始终是预期的实际值,随后才是可选参数。
- 使用 Lambda 将能更自由地创建断言消息,如果创建过程耗时长久,这一点有助于提高性能。
- 布尔(Boolean)断言已经可以接受谓语。
另外还有新增的assertAll
,可用于检查一组相关调用(Invocation)的结果,并判断断言是否失败,虽然无法短路但可输出所有结果的值:
@Test void assertRelatedProperties() { Developer dev = new Developer("Johannes", "Link"); assertAll("developer", () -> assertEquals("Marc", dev.firstName()), () -> assertEquals("Philipp", dev.lastName()) ); }
上述代码可产生下列失败信息:
org.opentest4j.MultipleFailuresError: developer (2 failures) expected: <Marc> but was: <Johannes> expected: <Philipp> but was: <Link>
请注意,尽管断言中的姓氏(First name)已经失败,但上述输出结果中依然包含了名字(Last name)失败。
最后还有assertThrows
和expectThrows
,如果所调用的方法没有指定异常,测试中它们都会失败。为了进一步断言异常的属性(例如消息中包含某些信息),此时还将返回expectThrows
。
@Test void assertExceptions() { // assert that the method under test // throws the expected exception */ assertThrows(Exception.class, unitUnderTest::methodUnderTest); Exception exception = expectThrows( Exception.class, unitUnderTest::methodUnderTest); assertEquals("This shouldn't happen.", exception.getMessage()); }
假设
假设(Assumption)使得我们可以在某些条件满足预期时运行测试。假设必须解析为布尔表达式,如果条件不满足测试将退出。这种方式可减少运行所需时间并缩短测试结果,尤其是在测试失败的情况下。
@Test void exitIfFalseIsTrue() { assumeTrue(false); System.exit(1); } @Test void exitIfTrueIsFalse() { assumeFalse(this::truism); System.exit(1); } private boolean truism() { return true; } @Test void exitIfNullEqualsString() { assumingThat( // state an assumption (a false one in this case) ... "null".equals(null), // … and only execute the lambda if it is true () -> System.exit(1) ); }
假设可用于终止预设条件不满足的测试(assumeTrue
和assumeFalse
),或可用于在某个条件成立时执行特定部分测试(assumimgThat
)。主要差异在于终止的测试会报告为已禁用,尽管测试可能只是因为某个条件没有始终显示为绿色而为空。
一些历史
在测试的编写方法方面,JUnit 5 包含很多细节改进,此外还带来了不少新功能,下文将详细介绍。但是有趣的是,为了新版本的开发投入如此大量的努力其实是出于一些更深入的原因。
为何重写 JUnit?
JUnit 和工具
随着测试驱动的开发和持续集成等开发技术的广泛采用,测试对开发者日常工作显得愈加重要,开发者进而对 IDE 提出了更多要求。开发者需要简单具体的执行(深入到每个方法)、更快速的反馈,以及更易用的导航。构建工具和 CI 服务器也提出了自己的要求。
JUnit 4 面对这一切准备的如何?其实并不好。除了对 Hamcrest 1.3 的依赖,JUnit 4.12 是一种整体式产品,包含开发者编写测试所需的 API 和运行这些测试的引擎,仅此而已。例如若要发现测试,就只能分别针对希望执行该操作的每个工具分别实现。
然而这些还不足以支持某些高级工具的功能,工具开发者通常需要通过反射(Reflection)的方式访问 JUnit 的内部 API、非公开类,甚至私有属性(Private field),借此通过“拼凑”实现目的。具体的实现细节也可能因为公开 API 中实质存在(de facto)的部分而突然需要完全重构。这种做法会导致技术锁定,使得维护工作令人不快,而后续完善也变得困难重重。
目前重写倡议的发起人 Johannes Link称这是“永无止境的纠结”,并做了如下总结:
JUnit 即平台的成功使得 JUnit 的后续开发不再以“工具”为目标。
JUnit 的扩展
JUnit 4 最初以 Test runners 作为自己的扩展机制。我们可以创建自己的Runner
实现,并使用@RunWith(OurNewRunner.class)
标注测试类,借此让 JUnit 使用我们自己的实现。自定义的 Runner 需要实现完整的测试生命周期,包括实例化(Instantiation)、配置和停止、测试的运行、异常的处理、通知的发送等。
这就使得小规模扩展的创建会变得异常笨重和不便,并且还会遇到诸多局限,例如每个测试类只能使用一个 Runner,这样就无法将其结合在一起并同时从 Mockito 以及 Spring Runner 等功能中获益。
为了缓解这些局限,JUnit 4.7 引入了规则。JUnit 4 的默认Runner 可以将测试封装为 Statement
并将其传递给该测试应用的规则。随后即可执行创建临时文件夹、在Swing 的事件分发线程中运行测试,或如果运行时间太长就让测试超时等操作。
规则功能是一次重大改进,但通常来说限制了某些代码在测试运行之前、运行过程中,或运行之后执行的能力。但除了这些生命周期点,可以借助有限的支持实现要求更多的扩展。
另外还有一种情况,所有测试用例在开始执行之前必须是已知的。这就导致我们无法动态创建测试类,例如测试执行过程中可能需要对观察到的行为做出响应。
那么现在就有了两种截然不同的扩展机制,每种都有各自的局限,但在一定程度上也有重叠。这使得扩展的清理变得更困难。另外据称不同扩展的创作也会出现问题,通常可能无法按照预期执行。
召唤Lambda
JUnit 4 已经有十多年的历史,并且依然在使用 Java 5,因此无法使用 Java 语言后续的所有改进,最主要的可能就是 Lambda 表达式,该表达式可实现类似下面这样的构造:
test(“someTest”, () -> { System.out.println("Running some test..."); assertTrue(true); });
进入 JUnit Lambda 时代
至此我们已经了解了重写所能塑造的全新前景,而在 2015 年就以此为目标组建了 JUnit Lambda 团队。当时团队核心成员包括 Johannes Link (后退出该项目)、 Marc Philipp 、 Stefan Bechtold 、 Matthias Merdes ,以及 Sam Brennan 。
资助和众筹
有趣的是 Andrena Objects 、 Namics ,以及 Heidelberg Mobil 各自的雇员 Marc Philipp、Stefan Bechtold,以及 Matthias Merdes 均慷慨地为该项目贡献了自己为期六周的全部时间。但为了发起这个工作组,他们还需要更多的开发时间和资金。当时估计最少需要 25,000 欧元,于是以此为目标在 Indiegogo 发起了一次众筹活动。虽然一开始收获一般,但最终共筹得53,937 欧元(约60,000 美元)资金。
这样该团队就可以为这个项目贡献出大概两个月的全职开发时间,同时所筹资金的使用情况也是完全透明的。
原型、Alpha 版、Milestone 1
2015 年 10 月,JUnit Lambda 团队在德国卡尔斯鲁厄(Karlsruhe)举办了一场探讨会,随后用了一整月时间进行全职开发。最终获得的原型于四周后发布,其中演示了很多新功能,甚至包含当前版本依然未包含的实验性功能。
通过收集反馈,该团队开始着手开发下一个版本,将其更名为 JUnit 5,并在 2016 年 2 月发布了 Alpha 测试版。随后经过另一轮反馈以及为期五个月的密集开发,本文所介绍的 Milestone 1 版于 2016 年 7 月 7 日正式发布。六月时该项目还经历了一次革新,将 JUnit 5 拆分为 JUnit Jupiter、JUnit Platform,以及 JUnit Vintage,下文将介绍这三者的区别。
反馈
随着新版发布,该项目再次收到大量反馈。整个社区都迫不及待想要尝试 JUnit 5,并通过 GitHub 提交问题和 Pull 请求。我们可以借此契机进一步完善!
该团队以及一些早期的使用者也开始发表有关 JUnit 5 的演讲,随后将要进行的演讲包括:
- SpringOne Platform ,美国内华达州拉斯维加斯,2016 年 8 月 2 日。
- JavaZone 2016 ,挪威奥斯陆,2016 年 9 月 7 日。
- JAX London 2016 ,英国伦敦,2016 年 10 月 10 日。
下一个里程碑和最终版本
花光众筹获得的资金后,该团队在几个月前重新投入了自己的日常工作,但依然在空闲时间里继续完善 JUnit 5。目前的进展很不错!有关 Milestone 2 的工作正在进行中,计划于今年底发布。谁知道呢,也许到时候发布的就是正式版了。
体系结构
我们已经意识到 JUnit 4 的整体式体系结构会使得开发工作变得异常困难。
新版本将如何改变这一点?
分离关注点
测试框架需要承担两个重要任务:
- 帮助开发者编写测试
- 帮助工具运行测试
仔细考虑第二点,很明显各种测试框架都提供了类似功能。无论 JUnit、TestNg、Spock、Cucumber、ScalaTest 等,这些工具通常都要为测试提供名称和结果,以及执行测试的方式和要使用的报表层次结构等。
为什么要在不同框架中使用重复的编码来处理这些问题?为什么需要用工具为这种或那种框架(以及不同版本)实现特定的支持,从抽象的层面来看,这些功能是否总是相同的?
JUnit 即平台
JUnit 可能是使用率最广的 Java 库,也是 JVM 方面最受欢迎的测试框架。借助于 IDE 和构建工具的紧密集成,该工具的优势被人们口口相传。
与此同时其他测试框架开始探索更为有趣的全新测试方式,尽管集成能力的缺乏通常会使得开发者继续使用 JUnit。也许他们也能从 JUnit 的成功中获益,为自己的产品提供这样的集成能力?(有些类似于那么多种语言都在从 JVM 的成功种获益。)
迁移
但这不仅仅是理论层面的争议,对 JUnit 项目本身也很重要,因为还牵扯到有关迁移的一些重要问题。现有的和新的工具是否该并行支持第 4 和第 5 版?也许很难劝说工具供应商实施这么多工作,但如果不这样做,开发者可能根本没有升级测试框架的动机。
如果 JUnit 5 可以通过一套统一的 API 同时运行这两个版本,那无疑是最为强大和便利的,将可以借助工具彻底移除已被淘汰的 JUnit 4 集成。
模块化
这种想法认为需要提供一种去耦合的体系结构,不同角色(开发者、运行时、工具)可以使用不同的组件:
- 开发者通过 API 编写测试,针对的目标为
- 可供每个 API 发现、提供以及运行相应测试的引擎
- 所有引擎实施同一种 API,以便能用统一的方式使用这些引擎
- 并通过某种机制对这些引擎进行编排
这就清晰地划分出“JUnit 工具”(1 和 2)以及“JUnit 平台”(3 和 4)。为了让这个差别更明显,该项目还使用了不同的命名架构:
- 上文介绍过(并且下文将继续介绍)的新增 API 名为JUnit Jupiter,这是开发者使用最多的东西。
- 用于运行工具的平台名为JUnit Platform。
- 虽然尚未公布,但还有一个名为JUnit Vintage的子项目,该项目可用于通过 JUnit 5 运行 JUnit 3 和 4 的测试。
JUnit 5 是上述三部分的总称,其新增的体系结构正源自上述这些差异:
junit-jupiter-api (1)
开发者可通过该 API 编写测试,其中可包含我们在上文介绍过的注解、断言等内容。
junit-jupiter-engine (2)
junit-engine-api(见下文)的一种实现,可用于运行 JUnit 5 测试,例如使用junit-jupiter-api编写的测试。
junit-platform-engine (3)
为了用一致的方式访问,所有测试引擎都要实现的 API。引擎必须运行典型的 JUnit 测试或可选运行使用 TestNG 、 Spock 、 Cucumber 等编写的测试。通过将自己注册至 Java 的ServiceLoader
,它们将能通过启动器(Launcher,见下文)使用。
junit-platform-runner (4)
使用ServiceLoader
可以发现测试引擎的实现并对其执行进行编排。它还为 IDE 和构建工具与测试执行过程进行的交互提供了所需的 API,例如可以启动特定测试并查看其结果。
这种体系结构的优势非常直接和明显,我们只需要额外使用两个组件即可用其执行 JUnit 4 测试:
junit-4.12 (1)
这个 JUnit 4 组件充当了开发者实现测试时所需的 API,但也包含了用于决定测试如何运行的主要功能。
junit-vintage-engine (2)
junit-platform-engine的一种实现,可用于运行使用 JUnit 4 编写的测试。该组件可看作 JUnit 4 面向第 5 版的适配器。
其他框架已经提供了编写测试所需的 API,因此完整的 JUnit 5 集成就差测试引擎的实现了。
一图胜千言:
API 生命周期
下一个等待解决的问题是大家目前在使用的各种内部 API。因此该团队为自己的 API 创建了生命周期。开发团队针对生命周期的解释如下:
内部(Internal)
除了JUnit 本身,其他任何代码均不允许使用,可能会在不事先通知的情况下移除。
不赞成使用(Deprecated)
不应继续使用,可能会在下一个小版本更新中移除。
实验性(Experimental)
主要用于新增的实验性功能,目的是从中了解用户反馈。使用时应谨慎,虽然可能在未来的版本中变为维护版(Maintained)或稳定版(Stable),但也有可能不事先通知而直接移除。
维护版(Maintained)
主要用于至少在当前大版本的下一个小版本更新前不会因为变化导致出现向后兼容性问题的功能。如果计划将其移除,将首先切换至“不赞成使用”状态。
稳定版(Stable)
主要用于在当前大版本下不会因为变化导致出现向后兼容性问题的功能。
公开可见的类将带有 @API(usage)
的注解,其中“usage”是上文列出的某个值,例如@API(Stable)
。该方案有望帮助 API 调用方更好地了解自己所面临的情况,并使得 JUnit 团队可以自由地彻底更改或移除不再受到支持的 API。
开放测试联盟
如上文所述,JUnit 5 的体系结构使得 IDE 和构建工具可以将其用作其他测试框架的“外立面”(假设这些框架能提供相应的引擎)。借助这种方法,将能用统一的方式发现、执行、评估测试各种工具,而无须针对不同框架实现所需的支持。
或者该问问,真能实现吗?
测试失败通常是用“异常”表示的。然而不同测试框架和断言库通常并未使用相同的类,而是实施了自己的变体(通常是对AssertionError
或RuntimeException
的扩展)。这也使得互操作变得无谓的复杂,也使得我们无法用统一的方式使用这些工具。
为了解决这一问题,JUnit Lambda 团队又划分出一个独立的项目:适用于 JVM 的开放测试联盟。该项目的意图在于:
基于最近与 Eclipse、Gradle,以及 IntelliJ 等 IDE 和构建工具开发者的讨论,JUnit Lambda 团队正在研究一项有关开源项目的提议,希望借此为 JVM 的测试库提供一种最低范围的通用基础。
该项目的主要目标在于让诸如 JUnit、TestNG、Spock 等测试框架,和 Hamcrest、AssertJ 等第三方断言库使用一套通用的异常,借此 IDE 和构建工具即可在各种测试场景下提供一致的支持,举例来说,可以用一致的方式处理失败的断言和失败的假设,并能在 IDE 和报表中对测试的执行进行可视化。
目前这些项目还没有收到太多反馈。如果亲爱的读者你认为这是个好主意,我们鼓励你将开放测试联盟推荐给你所用框架的维护者。
兼容性
虽然 JUnit 可以同时运行第 4* 和 * 第 5 版测试引擎,但很多项目可能会同时包含这些版本的测试。确实,JUnit 5 使用了一个全新的名称空间:org.junit.gen5
,这意味着在并行运行不同版本的 JUnit 时不会产生冲突,这样就可以让开发者慢慢迁移到 JUnit 5。
诸如 Hamcrest 和 AssertJ 等测试库需要通过异常与 JUnit 通信,这些库依然可以在新版本中使用。
总结
我们对 JUnit 5 的试用体验文第一篇就是这些内容了。撰写本文的过程中我们配置了环境并编写和运行了测试,借此了解到 API 的表面经历了怎样的持续演进。其实你也可以开始试试了!
我们还介绍了目前的工具是如何在这样的程度上受制于 JUnit 4 的实现细节以至于需要一个新的开始。但原因不仅仅如此。JUnit 4 无法令人满意的扩展模型以及在定义测试时使用 Lambda 表达式的需求也使得 JUnit 需要进行重写。
新的体系结构主要是为了避免出现过去遇到过的错误。JUnit 已经划分为用于编写测试的JUnit Jupiter库和构建所需的平台工具JUnit Platform,明显已经解决了这两个困扰。此外“JUnit 平台”所获得的成功也可以扩展到其他能与 JUnit 集成的测试框架。
在计划于 8 月 9 日发布的下篇文章中,我们将深入介绍如何在 IDE 和构建工具,甚至从控制台中运行 JUnit 5 测试。最后,还将介绍新版本提供的几个炫酷的功能。新的扩展模型真的很让人期待…
关于本文作者
** Nicolai Parlog ** 是一位软件开发者兼 Java 传教士。他会经常阅读、思考并撰写有关 Java 的文章,在以写代码为生的同时也享受着写代码的乐趣。他是多个开源项目的长期贡献者,并维护了一个有关软件开发的博客: CodeFX 。你也可以在 Twitter 关注 Nicolai。
评论