JUnit 5系列之?dāng)U展模型(Extension Model)介紹
概述
(如果不喜歡看文章,你可以戳這里看我的演講,或者看一下最近的 vJUG 講座,或者我在 DevoxxPL 上的 PPT。
本系列文章都基于 Junit 5發(fā)布的先行版 Milestone 2。它可能會有變化。如果有新的里程碑(milestone)版本發(fā)布,或者試用版正式發(fā)行時,我會再來更新這篇文章。
這里要介紹的多數(shù)知識你都可以在 JUnit 5 用戶指南 中找到(這個鏈接指向的是先行版 Milestone 2,想看的***版本文檔的話請戳這里),并且指南還有更多的內(nèi)容等待你發(fā)掘。下面的所有代碼都可以在 我的 Github 上找到。
目錄
- JUnit 4 的擴(kuò)展模型
* Runners(運行器)
* Rules(規(guī)則)
* 現(xiàn)狀
- JUnit 5 的擴(kuò)展模型
* 擴(kuò)展點
* 無狀態(tài)
* 應(yīng)用擴(kuò)展
* 自定義注解
- 例子
- 回顧總結(jié)
- 分享&關(guān)注
「譯者注:本篇的 Runner,統(tǒng)一譯為“運行器”;Rule,統(tǒng)一譯為“規(guī)則”。雖不一定完全達(dá)義,但語義未損失太多。在每小節(jié)***次出現(xiàn)處會以中英標(biāo)注,其后全部使用中文?!?/p>
JUnit 4 的擴(kuò)展模型
我們先來看看 JUnit 4 中是如何實現(xiàn)擴(kuò)展的。在 JUnit 4 中實現(xiàn)擴(kuò)展主要是通過兩個,有時也互有重疊的擴(kuò)展機(jī)制:運行器(Runners)和規(guī)則(Rules)。
運行器(Runners)
測試運行器負(fù)責(zé)管理諸多測試的生命周期,包括它們的實例化、setup/teardown 方法的調(diào)用、測試運行、異常處理、發(fā)送消息等。在 JUnit 4 提供的運行器實現(xiàn)中,它負(fù)責(zé)了這所有的事情。
在 JUnit 4 中,擴(kuò)展 JUnit 的唯一方法是:創(chuàng)建一個新的運行器,然后使用它標(biāo)記你新的測試類:@Runwith(MyRunner.class)。這樣 JUnit 就會識別并使用它來運行測試,而不會使用其默認(rèn)的實現(xiàn)。
這個方式很重,對于小定制小擴(kuò)展來說很不方便。同時它有個很苛刻的限制:一個測試類只能用一個運行器來跑,這意味著你不能組合不同的運行器。也即是說,你不能同時享受到兩個以上運行器提供的特性,比如說不能同時使用 Mockito 和 Spring 的運行器,等。
規(guī)則(Rules)
為了克服這個限制,JUnit 4.7 中引入了規(guī)則的概念,它是指測試類中特別的注解字段。 JUnit 4 會把測試方法(與一些其他的行為)包裝一層傳給規(guī)則。規(guī)則因此可以在測試代碼執(zhí)行前后插入,執(zhí)行一些代碼。很多時候在測試方法中也會直接調(diào)規(guī)則類上的方法。
這里有一個例子,展示的是 temporary folder (臨時文件夾)規(guī)則:
- public static class HasTempFolder {
- @Rule
- public TemporaryFolder folder= new TemporaryFolder();
- @Test
- public void testUsingTempFolder() throws IOException {
- File createdFile= folder.newFile("myfile.txt");
- File createdFolder= folder.newFolder("subfolder");
- // ...
- }
- }
因為 @Rule 注解的存在,JUnit 會先把測試方法 testUsingTempFolder 包裝成一個可執(zhí)行代碼塊,傳給 folder 規(guī)則。這個規(guī)則的作用是執(zhí)行時, 由 folder 創(chuàng)建一個臨時目錄,執(zhí)行測試,測試完成后刪除臨時目錄。因此,在測試內(nèi)部可以放心地在臨時目錄下創(chuàng)建文件和文件夾。
當(dāng)然還有其他的規(guī)則,比如允許你在 Swing 的事件分發(fā)線程中執(zhí)行測試 的規(guī)則,負(fù)責(zé)連接和斷開數(shù)據(jù)庫的規(guī)則,以及讓運行過久的測試直接超時的規(guī)則等。
規(guī)則特性其實已經(jīng)是個很大的改進(jìn)了,不過仍有局限,它只能在測試運行之前或之后定制操作。如果你想在此之外的時間點進(jìn)行擴(kuò)展,這個特性也無能為力了。
現(xiàn)狀
總而言之,在 JUnit 4 中存在兩種不同的擴(kuò)展機(jī)制,兩者均各有局限,并且功能還有重疊的部分。在 JUnit 4 下編寫干凈的擴(kuò)展是很難的事。此外,即使你嘗試組合兩種不同的擴(kuò)展方式,通常也不會一帆風(fēng)順,有時它可能根本不按照開發(fā)者期望的方式工作。
JUnit 5 的擴(kuò)展模型
Junit Lambda 項目成立伊始便有幾點核心準(zhǔn)則,其中一條便是“擴(kuò)展點優(yōu)于新特性”。這個準(zhǔn)則其實也就是新版本 JUnit 中最重要的擴(kuò)展機(jī)制了——并非唯一,但無疑是最重要之一。
擴(kuò)展點
JUnit 5 擴(kuò)展可以聲明其主要關(guān)注的是測試生命周期的哪部分。JUnit 5 引擎在處理測試時,它會依次檢查這些擴(kuò)展點,并調(diào)用每個已注冊的擴(kuò)展。大體來說,這些擴(kuò)展點出現(xiàn)次序如下:
- 測試類實例 后處理
- BeforeAll 回調(diào)
- 測試及容器執(zhí)行條件檢查
- BeforeEach 回調(diào)
- 參數(shù)解析
- 測試執(zhí)行前
- 測試執(zhí)行后
- 異常處理
- AfterEach 回調(diào)
- AfterAll 回調(diào)
(如果上面有你覺得不甚清晰或理解的點,請不用擔(dān)心,我們接下來會挑其中的一些來講解。)
每個擴(kuò)展點都對應(yīng)一個接口。接口方法會接受一些參數(shù),一些擴(kuò)展點所處生命周期的上下文信息。比如,被測實例與方法、測試的名稱、參數(shù)、注解等信息。
一個擴(kuò)展可以實現(xiàn)任意個以上的接口方法,引擎會在調(diào)用它們時傳入相應(yīng)的上下文信息作為參數(shù)。有了這些信息,擴(kuò)展就可以放心地實現(xiàn)所需的功能了。
無狀態(tài)
這里我們需要考慮一個重要的細(xì)節(jié):引擎對擴(kuò)展實例的初始化時間、實例的生存時間未作出任何規(guī)約和保證,因此,擴(kuò)展必須是無狀態(tài)的。如果一個擴(kuò)展需要維持任何狀態(tài)信息,那么它必須使用 JUnit 提供的一個倉庫(store)來進(jìn)行信息讀取和寫入。
這樣做的原因有幾個:
- 擴(kuò)展的初始化時機(jī)和方式對引擎是未知的(每個測試實例化一次?每個類實例化一次?還是每次運行實例化一次?)。
- JUnit 不想額外維護(hù)和管理每個擴(kuò)展創(chuàng)建的實例。
- 如果擴(kuò)展之間想要進(jìn)行通信,那么無論如何 JUnit 都必須提供一個數(shù)據(jù)交互的機(jī)制。
應(yīng)用擴(kuò)展
創(chuàng)建完擴(kuò)展后,接下來需要做的就僅僅是告訴 JUnit 它的存在。這可以通過在需要使用該擴(kuò)展的測試類或測試方法上添加一個@ExtendWith(MyExtension.class) 簡單實現(xiàn)。
其實,還有另一種更簡明的方式。不過要理解那種方式,我們必須先看一下 JUnit 的擴(kuò)展模型中還有哪些內(nèi)容。
自定義注解
JUnit 5 的 API 大部分是基于注解的,而且引擎在檢查注解時還做了些額外的工作:它不僅會查找字段、類、參數(shù)上應(yīng)用的注解,還會注解上的注解。引擎會把找到的所有注解都應(yīng)用到被注解元素上。注解另一個注解可以通過所謂的元注解做到,酷的是 Junit 提供的所有注解都說得上是元注解了。
它的意義在于,JUnit 5 中我們就能夠創(chuàng)建并組合不同的注解了,并且它們具備組合多個注解特性的能力:
- /**
- * We define a custom annotation that:
- * - stands in for '@Test' so that the method gets executed
- * - has the tag "integration" so we can filter by that,
- * e.g. when running tests from the command line
- */
- @Target({ElementType.TYPE, ElementType.METHOD})
- @Retention(RetentionPolicy.RUNTIME)
- @Test
- @Tag("integration")
- public @interface IntegrationTest { }
這個自定義的“集成測試”注解 @IntegrationTest 可以這樣使用:
- @IntegrationTest
- void runsWithCustomAnnotation() {
- // this gets executed
- // even though `@IntegrationTest` is not defined by JUnit
- }
進(jìn)一步我們可以為擴(kuò)展使用更簡明的注解:
- @Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
- @Retention(RetentionPolicy.RUNTIME)
- @ExtendWith(ExternalDatabaseExtension.class)
- public @interface Database { }
現(xiàn)在我們可以直接使用 @Database 注解了,而不需要再聲明測試應(yīng)用了特定的擴(kuò)展@ExtendWith(ExternalDatabaseExtension.class)。并且由于我們把注解類型 ElementType.ANNOTATION_TYPE 也添加到擴(kuò)展支持的目標(biāo)類型中去了,因此該注解也可以被我們或他人進(jìn)一步的使用、組合。
例子
假設(shè)現(xiàn)在有個場景,我想量化一下測試運行花費的時間。首先,可以先創(chuàng)建一個我們想要的注解:
- @Target({ TYPE, METHOD, ANNOTATION_TYPE })
- @Retention(RetentionPolicy.RUNTIME)
- @ExtendWith(BenchmarkExtension.class)
- public @interface Benchmark { }
注解聲明其應(yīng)用了 BenchmarkExtension 擴(kuò)展,這是我們接下來要實現(xiàn)的。TODOLIST 如下:
- 計算所有測試類的運行時間,在所有測試執(zhí)行前保存其起始時間
- 計算每個測試方法的運行時間,在每個測試方法執(zhí)行前保存其起始時間
- 在每個測試方法執(zhí)行完畢后,獲取其結(jié)束時間,計算并輸出該測試方法的運行時間
- 在所有測試類執(zhí)行完畢后,獲取其結(jié)束時間,計算并輸出所有測試的運行時間
- 以上操作,僅對所有注解了 @BenchMark 的測試類或測試方法生效
***一點需求可能不是一眼便能發(fā)現(xiàn)。如果一個方法并未注解 @Benchmark 注解,它有什么可能被我們的擴(kuò)展處理? 一個語法上的原因是,如果一個擴(kuò)展被應(yīng)用到了一個類上,那么它默認(rèn)也會應(yīng)用到類中的所有方法上。因此,如果我們的需求是計算整個測試類的運行時間,但不需具體到類中每個單獨方法的運行時間時,類中的測試方法就必須被手動排除。這點我們可以通過單獨檢查每個方法是否應(yīng)用了注解來做到。
有趣的是,需求的前四點與擴(kuò)展點中的其中四個是一一對應(yīng)的:BeforeAll、BeforeTestExecution、AfterTestExecution 與 AfterAll。因此我們要做的任務(wù)便是實現(xiàn)這四個對應(yīng)的接口。具體實現(xiàn)很簡單,把上面說的翻譯成代碼即是:
- public class BenchmarkExtension implements
- BeforeAllExtensionPoint, BeforeTestExecutionCallback,
- AfterTestExecutionCallback, AfterAllExtensionPoint {
- private static final Namespace NAMESPACE =
- Namespace.of("BenchmarkExtension");
- @Override
- public void beforeAll(ContainerExtensionContext context) {
- if (!shouldBeBenchmarked(context))
- return;
- writeCurrentTime(context, LaunchTimeKey.CLASS);
- }
- @Override
- public void beforeTestExecution(TestExtensionContext context) {
- if (!shouldBeBenchmarked(context))
- return;
- writeCurrentTime(context, LaunchTimeKey.TEST);
- }
- @Override
- public void afterTestExecution(TestExtensionContext context) {
- if (!shouldBeBenchmarked(context))
- return;
- long launchTime = loadLaunchTime(context, LaunchTimeKey.TEST);
- long runtime = currentTimeMillis() - launchTime;
- print("Test", context.getDisplayName(), runtime);
- }
- @Override
- public void afterAll(ContainerExtensionContext context) {
- if (!shouldBeBenchmarked(context))
- return;
- long launchTime = loadLaunchTime(context, LaunchTimeKey.CLASS);
- long runtime = currentTimeMillis() - launchTime;
- print("Test container", context.getDisplayName(), runtime);
- }
- private static boolean shouldBeBenchmarked(ExtensionContext context) {
- return context.getElement()
- .map(el -> el.isAnnotationPresent(Benchmark.class))
- .orElse(false);
- }
- private static void writeCurrentTime(
- ExtensionContext context, LaunchTimeKey key) {
- context.getStore(NAMESPACE).put(key, currentTimeMillis());
- }
- private static long loadLaunchTime(
- ExtensionContext context, LaunchTimeKey key) {
- return (Long) context.getStore(NAMESPACE).remove(key);
- }
- private static void print(
- String unit, String displayName, long runtime) {
- System.out.printf("%s '%s' took %d ms.%n", unit, displayName, runtime);
- }
- private enum LaunchTimeKey {
- CLASS, TEST
- }
- }
- 「譯者:啊這代碼讓人心曠神怡。」
上面代碼有幾個地方值得留意。首先是 shouldBeBenchmarked 方法,它使用了 JUnit 的 API 來獲取當(dāng)前元素是否(被元)注解了@Benchmark 注解;其次, writeCurrentTime / loadLaunchTime 方法中使用了 Junit 提供的 store 以寫入和讀取運行時間。
源代碼在 Github 上可以找到。
下篇博文我會探討條件執(zhí)行的測試以及參數(shù)注入部分的內(nèi)容,同時為你展示如何使用其對應(yīng)的擴(kuò)展點。如果你已經(jīng)迫不及待了,那么請先參考這篇博客,它展示了將應(yīng)用了兩個規(guī)則(條件性禁用測試 及 臨時目錄)的 Junit 4 測試改裝成 JUnit 5 測試的方法。
總結(jié)回顧
通過本文我們了解到,在創(chuàng)建整潔、強(qiáng)大及可組合的擴(kuò)展上,JUnit 4 提供的運行器和規(guī)則特性不夠理想。為了超越這些限制,JUnit 5 引入了一個更通用的概念:擴(kuò)展點。它允許自定義的擴(kuò)展主動聲明,它需要在一個測試的什么節(jié)點上去介入。同時,我們還看到如何使用元注解來輕松地自定義注解。
我希望聽到你的想法和反饋。