JUnit 5 – 早期试用体验 – 第1篇

来源:Infoq 作者:大愚若智
  

主要结论

  • 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)失败。

最后还有assertThrowsexpectThrows,如果所调用的方法没有指定异常,测试中它们都会失败。为了进一步断言异常的属性(例如消息中包含某些信息),此时还将返回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)
    );
}

假设可用于终止预设条件不满足的测试(assumeTrueassumeFalse),或可用于在某个条件成立时执行特定部分测试(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 PhilippStefan BechtoldMatthias Merdes,以及Sam Brennan

资助和众筹

有趣的是Andrena ObjectsNamics,以及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的演讲,随后将要进行的演讲包括:

下一个里程碑和最终版本

花光众筹获得的资金后,该团队在几个月前重新投入了自己的日常工作,但依然在空闲时间里继续完善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 集成。

模块化

这种想法认为需要提供一种去耦合的体系结构,不同角色(开发者、运行时、工具)可以使用不同的组件:

  1. 开发者通过API编写测试,针对的目标为
  2. 可供每个API发现、提供以及运行相应测试的引擎
  3. 所有引擎实施同一种API,以便能用统一的方式使用这些引擎
  4. 并通过某种机制对这些引擎进行编排

这就清晰地划分出“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测试或可选运行使用TestNGSpockCucumber等编写的测试。通过将自己注册至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和构建工具可以将其用作其他测试框架的“外立面”(假设这些框架能提供相应的引擎)。借助这种方法,将能用统一的方式发现、执行、评估测试各种工具,而无须针对不同框架实现所需的支持。

或者该问问,真能实现吗?

测试失败通常是用“异常”表示的。然而不同测试框架和断言库通常并未使用相同的类,而是实施了自己的变体(通常是对AssertionErrorRuntimeException的扩展)。这也使得互操作变得无谓的复杂,也使得我们无法用统一的方式使用这些工具。

为了解决这一问题,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。

诸如HamcrestAssertJ等测试库需要通过异常与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。

 

查看英文原文JUnit 5 - An Early Test Drive - Part 1


时间:2016-10-24 08:38 来源:Infoq 作者:大愚若智 原文链接

好文,顶一下
(0)
0%
文章真差,踩一下
(0)
0%
------分隔线----------------------------


把开源带在你的身边-精美linux小纪念品
无觅相关文章插件,快速提升流量