「譯」JUnit 5 系列:擴展模型(Extension Model)

原文地址:http://blog.codefx.org/design/architecture/junit-5-extension-model/
原文日期:11, Apr, 2016
譯文首發:Linesh 的博客:「譯」JUnit 5 系列:擴展模型(Extension Model)
個人 Github:http://github.com/linesh-simplicityhtml

概述

(若是不喜歡看文章,你能夠戳這裏看個人演講,或者看一下最近的 vJUG 講座,或者我在 DevoxxPL 上的 PPTjava

本系列文章都基於 Junit 5發佈的先行版 Milestone 2。它可能會有變化。若是有新的里程碑(milestone)版本發佈,或者試用版正式發行時,我會再來更新這篇文章。git

這裏要介紹的多數知識你均可以在 JUnit 5 用戶指南 中找到(這個連接指向的是先行版 Milestone 2,想看的最新版本文檔的話請戳這裏),而且指南還有更多的內容等待你發掘。下面的全部代碼均可以在 個人 Github 上找到。github

目錄

  • JUnit 4 的擴展模型
    • Runners(運行器)
    • Rules(規則)
    • 現狀
  • JUnit 5 的擴展模型
    • 擴展點
    • 無狀態
    • 應用擴展
    • 自定義註解
  • 例子
  • 回顧總結
  • 分享&關注

「譯者注:本篇的 Runner,統一譯爲「運行器」;Rule,統一譯爲「規則」。雖不必定徹底達義,但語義未損失太多。在每小節第一次出現處會以中英標註,其後所有使用中文。」數據庫

JUnit 4 的擴展模型

咱們先來看看 JUnit 4 中是如何實現擴展的。在 JUnit 4 中實現擴展主要是經過兩個,有時也互有重疊的擴展機制:運行器(Runners)和規則(Rules)。api

運行器(Runners)

測試運行器負責管理諸多測試的生命週期,包括它們的實例化、setup/teardown 方法的調用、測試運行、異常處理、發送消息等。在 JUnit 4 提供的運行器實現中,它負責了這全部的事情。架構

在 JUnit 4 中,擴展 JUnit 的惟一方法是:建立一個新的運行器,而後使用它標記你新的測試類:@Runwith(MyRunner.class)。這樣 JUnit 就會識別並使用它來運行測試,而不會使用其默認的實現。ide

這個方式很重,對於小定製小擴展來講很不方便。同時它有個很苛刻的限制:一個測試類只能用一個運行器來跑,這意味着你不能組合不一樣的運行器。也便是說,你不能同時享受到兩個以上運行器提供的特性,好比說不能同時使用 Mockito 和 Spring 的運行器,等。post

規則(Rules)

爲了克服這個限制,JUnit 4.7 中引入了規則的概念,它是指測試類中特別的註解字段。 JUnit 4 會把測試方法(與一些其餘的行爲)包裝一層傳給規則。規則所以能夠在測試代碼執行先後插入,執行一些代碼。不少時候在測試方法中也會直接調規則類上的方法。測試

這裏有一個例子,展現的是 temporary folder (臨時文件夾)規則:

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 包裝成一個可執行代碼塊,傳給 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 中咱們就可以建立並組合不一樣的註解了,而且它們具有組合多個註解特性的能力:

/**
 * 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
}

進一步咱們能夠爲擴展使用更簡明的註解:

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

如今咱們能夠直接使用 @Database 註解了,而不須要再聲明測試應用了特定的擴展 @ExtendWith(ExternalDatabaseExtension.class)。而且因爲咱們把註解類型 ElementType.ANNOTATION_TYPE 也添加到擴展支持的目標類型中去了,所以該註解也能夠被咱們或他人進一步的使用、組合。

例子

假設如今有個場景,我想量化一下測試運行花費的時間。首先,能夠先建立一個咱們想要的註解:

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

註解聲明其應用了 BenchmarkExtension 擴展,這是咱們接下來要實現的。TODOLIST 以下:

  • 計算全部測試類的運行時間,在全部測試執行前保存其起始時間
  • 計算每一個測試方法的運行時間,在每一個測試方法執行前保存其起始時間
  • 在每一個測試方法執行完畢後,獲取其結束時間,計算並輸出該測試方法的運行時間
  • 在全部測試類執行完畢後,獲取其結束時間,計算並輸出全部測試的運行時間
  • 以上操做,僅對全部註解了 @BenchMark 的測試類或測試方法生效

最後一點需求可能不是一眼便能發現。若是一個方法並未註解 @Benchmark 註解,它有什麼可能被咱們的擴展處理? 一個語法上的緣由是,若是一個擴展被應用到了一個類上,那麼它默認也會應用到類中的全部方法上。所以,若是咱們的需求是計算整個測試類的運行時間,但不需具體到類中每一個單獨方法的運行時間時,類中的測試方法就必須被手動排除。這點咱們能夠經過單獨檢查每一個方法是否應用了註解來作到。

有趣的是,需求的前四點與擴展點中的其中四個是一一對應的:BeforeAllBeforeTestExecutionAfterTestExecutionAfterAll。所以咱們要作的任務即是實現這四個對應的接口。具體實現很簡單,把上面說的翻譯成代碼便是:

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 來獲取當前元素是否(被元)註解了 @Benchmark 註解;其次, writeCurrentTime / loadLaunchTime 方法中使用了 Junit 提供的 store 以寫入和讀取運行時間。

源代碼在 Github 上能夠找到。

下篇博文我會探討條件執行的測試以及參數注入部分的內容,同時爲你展現如何使用其對應的擴展點。若是你已經火燒眉毛了,那麼請先參考這篇博客,它展現了將應用了兩個規則(條件性禁用測試 及 臨時目錄)的 Junit 4 測試改裝成 JUnit 5 測試的方法。

總結回顧

經過本文咱們瞭解到,在建立整潔、強大及可組合的擴展上,JUnit 4 提供的運行器和規則特性不夠理想。爲了超越這些限制,JUnit 5 引入了一個更通用的概念:擴展點。它容許自定義的擴展主動聲明,它須要在一個測試的什麼節點上去介入。同時,咱們還看到如何使用元註解來輕鬆地自定義註解。

我但願聽到你的想法和反饋。

相關文章
相關標籤/搜索