JUnit 5系列之扩展模型(Extension Model)介绍

概述

(如果不喜欢看文章,你可以戳这里看我的演讲,或者看一下最近的 vJUG 讲座,或者我在 DevoxxPL 上的 PPT。

本系列文章都基于 Junit 5发布的先行版 Milestone 2。它可能会有变化。如果有新的里程碑(milestone)版本发布,或者试用版正式发行时,我会再来更新这篇文章。

这里要介绍的多数知识你都可以在 JUnit 5 用户指南 中找到(这个链接指向的是先行版 Milestone 2,想看的最新版本文档的话请戳这里),并且指南还有更多的内容等待你发掘。下面的所有代码都可以在 我的 Github 上找到。

目录

  • JUnit 4 的扩展模型

* Runners(运行器)

* Rules(规则)

* 现状

  • JUnit 5 的扩展模型

* 扩展点

* 无状态

* 应用扩展

* 自定义注解

  • 例子
  • 回顾总结
  • 分享&关注

「译者注:本篇的 Runner,统一译为“运行器”;Rule,统一译为“规则”。虽不一定完全达义,但语义未损失太多。在每小节第一次出现处会以中英标注,其后全部使用中文。」

JUnit 4 的扩展模型

我们先来看看 JUnit 4 中是如何实现扩展的。在 JUnit 4 中实现扩展主要是通过两个,有时也互有重叠的扩展机制:运行器(Runners)和规则(Rules)。

运行器(Runners)

测试运行器负责管理诸多测试的生命周期,包括它们的实例化、setup/teardown 方法的调用、测试运行、异常处理、发送消息等。在 JUnit 4 提供的运行器实现中,它负责了这所有的事情。

在 JUnit 4 中,扩展 JUnit 的唯一方法是:创建一个新的运行器,然后使用它标记你新的测试类:@Runwith(MyRunner.class)。这样 JUnit 就会识别并使用它来运行测试,而不会使用其默认的实现。

这个方式很重,对于小定制小扩展来说很不方便。同时它有个很苛刻的限制:一个测试类只能用一个运行器来跑,这意味着你不能组合不同的运行器。也即是说,你不能同时享受到两个以上运行器提供的特性,比如说不能同时使用 Mockito 和 Spring 的运行器,等。

规则(Rules)

为了克服这个限制,JUnit 4.7 中引入了规则的概念,它是指测试类中特别的注解字段。 JUnit 4 会把测试方法(与一些其他的行为)包装一层传给规则。规则因此可以在测试代码执行前后插入,执行一些代码。很多时候在测试方法中也会直接调规则类上的方法。

这里有一个例子,展示的是 temporary folder (临时文件夹)规则:


  1. public static class HasTempFolder { 
  2.     @Rule 
  3.     public TemporaryFolder folder= new TemporaryFolder(); 
  4.   
  5.     @Test 
  6.     public void testUsingTempFolder() throws IOException { 
  7.         File createdFile= folder.newFile("myfile.txt"); 
  8.         File createdFolder= folder.newFolder("subfolder"); 
  9.         // ... 
  10.     } 

因为 @Rule 注解的存在,JUnit 会先把测试方法 testUsingTempFolder 包装成一个可执行代码块,传给
folder 规则。这个规则的作用是执行时, 由 folder
创建一个临时目录,执行测试,测试完成后删除临时目录。因此,在测试内部可以放心地在临时目录下创建文件和文件夹。

当然还有其他的规则,比如允许你在 Swing 的事件分发线程中执行测试 的规则,负责连接和断开数据库的规则,以及让运行过久的测试直接超时的规则等。

规则特性其实已经是个很大的改进了,不过仍有局限,它只能在测试运行之前或之后定制操作。如果你想在此之外的时间点进行扩展,这个特性也无能为力了。

现状

总而言之,在 JUnit 4 中存在两种不同的扩展机制,两者均各有局限,并且功能还有重叠的部分。在 JUnit 4 下编写干净的扩展是很难的事。此外,即使你尝试组合两种不同的扩展方式,通常也不会一帆风顺,有时它可能根本不按照开发者期望的方式工作。

JUnit 5 的扩展模型

Junit Lambda 项目成立伊始便有几点核心准则,其中一条便是“扩展点优于新特性”。这个准则其实也就是新版本 JUnit 中最重要的扩展机制了——并非唯一,但无疑是最重要之一。

扩展点

JUnit 5 扩展可以声明其主要关注的是测试生命周期的哪部分。JUnit 5 引擎在处理测试时,它会依次检查这些扩展点,并调用每个已注册的扩展。大体来说,这些扩展点出现次序如下:

  • 测试类实例 后处理
  • BeforeAll 回调
  • 测试及容器执行条件检查
  • BeforeEach 回调
  • 参数解析
  • 测试执行前
  • 测试执行后
  • 异常处理
  • AfterEach 回调
  • AfterAll 回调

(如果上面有你觉得不甚清晰或理解的点,请不用担心,我们接下来会挑其中的一些来讲解。)

每个扩展点都对应一个接口。接口方法会接受一些参数,一些扩展点所处生命周期的上下文信息。比如,被测实例与方法、测试的名称、参数、注解等信息。

一个扩展可以实现任意个以上的接口方法,引擎会在调用它们时传入相应的上下文信息作为参数。有了这些信息,扩展就可以放心地实现所需的功能了。

无状态

这里我们需要考虑一个重要的细节:引擎对扩展实例的初始化时间、实例的生存时间未作出任何规约和保证,因此,扩展必须是无状态的。如果一个扩展需要维持任何状态信息,那么它必须使用 JUnit 提供的一个仓库(store)来进行信息读取和写入。

这样做的原因有几个:

  • 扩展的初始化时机和方式对引擎是未知的(每个测试实例化一次?每个类实例化一次?还是每次运行实例化一次?)。
  • JUnit 不想额外维护和管理每个扩展创建的实例。
  • 如果扩展之间想要进行通信,那么无论如何 JUnit 都必须提供一个数据交互的机制。

应用扩展

创建完扩展后,接下来需要做的就仅仅是告诉 JUnit 它的存在。这可以通过在需要使用该扩展的测试类或测试方法上添加一个@ExtendWith(MyExtension.class) 简单实现。

其实,还有另一种更简明的方式。不过要理解那种方式,我们必须先看一下 JUnit 的扩展模型中还有哪些内容。

自定义注解

JUnit 5 的 API
大部分是基于注解的,而且引擎在检查注解时还做了些额外的工作:它不仅会查找字段、类、参数上应用的注解,还会注解上的注解。引擎会把找到的所有注解都应用到被注解元素上。注解另一个注解可以通过所谓的元注解做到,酷的是
Junit 提供的所有注解都说得上是元注解了。

它的意义在于,JUnit 5 中我们就能够创建并组合不同的注解了,并且它们具备组合多个注解特性的能力:


  1. /** 
  2.  * We define a custom annotation that: 
  3.  * - stands in for '@Test' so that the method gets executed 
  4.  * - has the tag "integration" so we can filter by that, 
  5.  *   e.g. when running tests from the command line 
  6.  */ 
  7. @Target({ElementType.TYPE, ElementType.METHOD}) 
  8. @Retention(RetentionPolicy.RUNTIME) 
  9. @Test 
  10. @Tag("integration") 
  11. public @interface IntegrationTest { }  

这个自定义的“集成测试”注解 @IntegrationTest 可以这样使用:


  1. @IntegrationTest 
  2. void runsWithCustomAnnotation() { 
  3.     // this gets executed 
  4.     // even though `@IntegrationTest` is not defined by JUnit 
  5. }  

进一步我们可以为扩展使用更简明的注解:


  1. @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) 
  2. @Retention(RetentionPolicy.RUNTIME) 
  3. @ExtendWith(ExternalDatabaseExtension.class) 
  4. public @interface Database { }  

现在我们可以直接使用 @Database
注解了,而不需要再声明测试应用了特定的扩展@ExtendWith(ExternalDatabaseExtension.class)。并且由于我们把注解类型
ElementType.ANNOTATION_TYPE 也添加到扩展支持的目标类型中去了,因此该注解也可以被我们或他人进一步的使用、组合。

例子

假设现在有个场景,我想量化一下测试运行花费的时间。首先,可以先创建一个我们想要的注解:


  1. @Target({ TYPE, METHOD, ANNOTATION_TYPE }) 
  2. @Retention(RetentionPolicy.RUNTIME) 
  3. @ExtendWith(BenchmarkExtension.class) 
  4. public @interface Benchmark { }  

注解声明其应用了 BenchmarkExtension 扩展,这是我们接下来要实现的。TODOLIST 如下:

  • 计算所有测试类的运行时间,在所有测试执行前保存其起始时间
  • 计算每个测试方法的运行时间,在每个测试方法执行前保存其起始时间
  • 在每个测试方法执行完毕后,获取其结束时间,计算并输出该测试方法的运行时间
  • 在所有测试类执行完毕后,获取其结束时间,计算并输出所有测试的运行时间
  • 以上操作,仅对所有注解了 @BenchMark 的测试类或测试方法生效

最后一点需求可能不是一眼便能发现。如果一个方法并未注解 @Benchmark 注解,它有什么可能被我们的扩展处理?
一个语法上的原因是,如果一个扩展被应用到了一个类上,那么它默认也会应用到类中的所有方法上。因此,如果我们的需求是计算整个测试类的运行时间,但不需具体到类中每个单独方法的运行时间时,类中的测试方法就必须被手动排除。这点我们可以通过单独检查每个方法是否应用了注解来做到。

有趣的是,需求的前四点与扩展点中的其中四个是一一对应的:BeforeAll、BeforeTestExecution、AfterTestExecution
与 AfterAll。因此我们要做的任务便是实现这四个对应的接口。具体实现很简单,把上面说的翻译成代码即是:


  1. public class BenchmarkExtension implements 
  2.         BeforeAllExtensionPoint, BeforeTestExecutionCallback, 
  3.         AfterTestExecutionCallback, AfterAllExtensionPoint { 
  4.   
  5.     private static final Namespace NAMESPACE = 
  6.             Namespace.of("BenchmarkExtension"); 
  7.   
  8.     @Override 
  9.     public void beforeAll(ContainerExtensionContext context) { 
  10.         if (!shouldBeBenchmarked(context)) 
  11.             return; 
  12.   
  13.         writeCurrentTime(context, LaunchTimeKey.CLASS); 
  14.     } 
  15.   
  16.     @Override 
  17.     public void beforeTestExecution(TestExtensionContext context) { 
  18.         if (!shouldBeBenchmarked(context)) 
  19.             return; 
  20.   
  21.         writeCurrentTime(context, LaunchTimeKey.TEST); 
  22.     } 
  23.   
  24.     @Override 
  25.     public void afterTestExecution(TestExtensionContext context) { 
  26.         if (!shouldBeBenchmarked(context)) 
  27.             return; 
  28.   
  29.         long launchTime = loadLaunchTime(context, LaunchTimeKey.TEST); 
  30.         long runtime = currentTimeMillis() - launchTime; 
  31.         print("Test", context.getDisplayName(), runtime); 
  32.     } 
  33.   
  34.     @Override 
  35.     public void afterAll(ContainerExtensionContext context) { 
  36.         if (!shouldBeBenchmarked(context)) 
  37.             return; 
  38.   
  39.         long launchTime = loadLaunchTime(context, LaunchTimeKey.CLASS); 
  40.         long runtime = currentTimeMillis() - launchTime; 
  41.         print("Test container", context.getDisplayName(), runtime); 
  42.     } 
  43.   
  44.     private static boolean shouldBeBenchmarked(ExtensionContext context) { 
  45.         return context.getElement() 
  46.                 .map(el -> el.isAnnotationPresent(Benchmark.class)) 
  47.                 .orElse(false); 
  48.     } 
  49.   
  50.     private static void writeCurrentTime( 
  51.             ExtensionContext context, LaunchTimeKey key) { 
  52.         context.getStore(NAMESPACE).put(key, currentTimeMillis()); 
  53.     } 
  54.   
  55.     private static long loadLaunchTime( 
  56.             ExtensionContext context, LaunchTimeKey key) { 
  57.         return (Long) context.getStore(NAMESPACE).remove(key); 
  58.     } 
  59.   
  60.     private static void print( 
  61.             String unit, String displayName, long runtime) { 
  62.         System.out.printf("%s '%s' took %d ms.%n", unit, displayName, runtime); 
  63.     } 
  64.   
  65.     private enum LaunchTimeKey { 
  66.         CLASS, TEST 
  67.     } 
  68.  
  69. 「译者:啊这代码让人心旷神怡。」  

上面代码有几个地方值得留意。首先是 shouldBeBenchmarked 方法,它使用了 JUnit 的 API
来获取当前元素是否(被元)注解了@Benchmark 注解;其次, writeCurrentTime / loadLaunchTime
方法中使用了 Junit 提供的 store 以写入和读取运行时间。

源代码在 Github 上可以找到。

下篇博文我会探讨条件执行的测试以及参数注入部分的内容,同时为你展示如何使用其对应的扩展点。如果你已经迫不及待了,那么请先参考这篇博客,它展示了将应用了两个规则(条件性禁用测试 及 临时目录)的 Junit 4 测试改装成 JUnit 5 测试的方法。

总结回顾

通过本文我们了解到,在创建整洁、强大及可组合的扩展上,JUnit 4 提供的运行器和规则特性不够理想。为了超越这些限制,JUnit 5
引入了一个更通用的概念:扩展点。它允许自定义的扩展主动声明,它需要在一个测试的什么节点上去介入。同时,我们还看到如何使用元注解来轻松地自定义注解。

我希望听到你的想法和反馈。

作者:Linesh

来源:51CTO

时间: 2025-01-30 14:35:33

JUnit 5系列之扩展模型(Extension Model)介绍的相关文章

JUnit 5系列之架构体系介绍

现在,我们已经知道了 如何配置 JUnit 5 环境 及 如何写一些测试,接下来就来看一点封面下的内容吧.本篇我们将讨论 JUnit 5 的架构体系,以及它之成形如此的原因. 概述 本文章是这个 JUnit 5 系列的一部分: 环境搭建 基础入门 架构体系 扩展模型(Extension Model) 条件断言 注入 动态测试 ... (如果不喜欢看文章,你可以戳这里看我的演讲,或者看一下最近的 vJUG 讲座,或者我在 DevoxxPL 上的 PPT. 本系列文章都基于 Junit 5发布的先行

JUnit 5系列之基础入门介绍

上周我们刚刚搭建好了 JUnit 5 的环境,现在我们可以写测试了.这节就让我们来写它几个吧! 概述 本文章是这个 JUnit 5 系列的一部分: 环境搭建 基础入门 架构体系 扩展模型(Extension Model)  条件断言 注入 动态测试 ... (如果不喜欢看文章,你可以戳这里看我的演讲,或者看一下最近的 vJUG 讲座,或者我在 DevoxxPL 上的 PPT. 本系列文章都基于 Junit 5发布的先行版 Milestone 2.它可能会有变化.如果有新的里程碑(mileston

1.3. 执行模型 Execution Model

1.3. 执行模型 Execution Model The OpenGL API is focused on drawing graphics into frame buffer memory and, to a lesser extent, in reading back values stored in that frame buffer. It is somewhat unique in that its design includes support for drawing threed

ZooKeeper源码研究系列(3)单机版服务器介绍

1 系列目录 ZooKeeper源码研究系列(1)源码环境搭建 ZooKeeper源码研究系列(2)客户端创建连接过程分析 ZooKeeper源码研究系列(3)单机版服务器介绍 ZooKeeper源码研究系列(4)集群版服务器介绍 2 单机版服务器启动方式 单机版的服务器启动,使用ZooKeeperServerMain的main函数来启动,参数分为两种: 只有一个参数:表示为一个配置文件地址 有2~4个参数:分别表示端口.dataDir.tickTime.maxClientCnxns 详细介绍见

ThinkPHP 的模型使用详细介绍--模型的核心(七)

原文:ThinkPHP 的模型使用详细介绍--模型的核心(七) 注意:本节是ThinkPhp框架对数据操作的核心处理部分      大家还是在这里看清楚可以将其剪切放到代码编辑器中查看 本章节给大家着重介绍模型的: 一.普通查询方式    . 二.表达式查询方式 . 三.区间查询      .  四.统计查询  .    五.SQL直接查询 回顾初步模型的-"增删改查": // 直接连接数据库,但是得先去配置文件中配置下才行 class IndexAction extends Acti

系列二VS项目软件配置工具介绍

原文:系列二VS项目软件配置工具介绍 Svn和VisualSvn介绍      在使用TortoiseSvn(SVN客户端)+ AnkhSvn(VS2008插件) +VisualSvn Server(版本控制服务器)进行源代码版本控制前,有必要先了解下Subversion(Svn).     Svn(Subversion)是近年来崛起的版本管理工具,是CVS的接班人.目前,绝大多数开源软件都使用Svn作为代码版本管理软件.      Svn客户端: Subversion的客户端有两类,一类是we

backbone学习笔记:模型(Model)(2)属性验证

Backbone的属性验证有2种方法:   1.Backbone自带简单的验证方法,但是验证规则需要自己实现      通过validate()方法进行验证,验证规则写在此方法里.      var RoomModel = Backbone.Model.extend({ validate:function(attrs){ //验证规则 if(!attrs.name){ return "名字不能为空"; } } });   那么什么时候调用validate()完成验证呢?    在调用s

Swift扩展(Extension)

在现有类和结构体的类型基础上,扩展新的功能. 语法: extension SomeType{ // new functionality to add to SomeType goes here } An extension can extend an existing type to make itadopt one or more protocols.Where this is the case, the protocol names are written in exactly the sa

Backbone.js系列教程十一:Backbone.Model

Backbone.Model概论 一个Backbone 模型可以比作一张表单结构,头部类似表单的列,数据类似表单的行.Backbone.Model对象定义列标签,再用预定义和自定义的方法包裹每一行里的 数据(即属性),以便进行数据转换.验证和访问控制.由Backbone.Model创建模型实例或者一个继承的Backbone.Model,提供对象 到实际数据的存储.例如,一个联系应用程序中的联系模型就像下面这样:  你可以把一个Backbone.Model构造函数比作表单的列标题,其方法和属性通用