over 1 year ago

Translated from "A Deep Dive into JUnit 5’s Extension Model", Nicolai Parlog, Java Magazine November/December 2016, page 25. Copyright Oracle Corporation.

深入探討 JUnit 5 的擴充模型

JUnit 如何執行測試以及如何和函式庫與框架互動的內幕

JUnit 的下一個釋出是第五版,是 Java 最廣泛使用的測試函式庫,一個重要的釋出,這重要的釋出主要提供一個新的架構,將 JUnit 工具與平台分開,以及一個新的擴充模型排除過去架構的關鍵限制。

在本文中,我探討第三方函式庫與框架用來與 JUnit 互動或擴充它的擴充模型,這主題主要適合工具與函式庫的開發者,同樣適合想精通 JUnit 如何運作的開發者,要跟上,您將需要對 JUnit 4 有足夠的了解。

我應該加上,會想花時間了解擴充模型的應用程式開發者是那種想減少樣板程式與提升他們測試的可讀性與維護性的開發者。

測試的生命週期

擴充與測試的生命週期息息相關,以下面的測試為例,所以我們先看測試的生命週期:

// @Disabled <1.>

class LifecycleTest {

    LifecycleTest() { /* <2.> */ }

    @BeforeAll
    static void setUpOnce() { /* <4.> */ }

    @BeforeEach
    static void setUp() { /* <5.> */ }

    @Test
    // @Disabled <3.>

    void testMethod(String parameter /* <6.> */)

    { /* <7. then 8.> */ }
    @AfterEach
    static void tearDown() { /* <9.> */ }

    @AfterAll
    static void tearDownOnce() { /* <10.> */ }
}

測試的生命週期中有幾個步驟 (依數字參考範例註解中的位置):

  1. 檢查是否應該執行測試類別 (JUnit 稱之為容器) 中的測試
  2. 建立容器的實體
  3. 檢查是否要執行各別測試 (從生命週期角度看是這時候檢查,但從程式撰寫的角度看,在先前的步驟已經完成)
  4. 如果是要執行容器的第一個測試,先呼叫 @BeforeAll (以前是 @BeforeClass) 加註的函式
  5. 呼叫 @BeforeEach (以前是 @Before) 加註的函式
  6. 解析測試函式所需的參數 (現在測試函式可以有參數)
  7. 執行測試
  8. 處理可能拋出的例外
  9. 呼叫 @AfterEach (以前是 @After) 加註的函式
  10. 如果該測試是容器中最後一個測試,呼叫 @AfterAll (以前是 @AfterClass) 加註的函式。現在我們看怎麼與這樣的生命週期互動。

擴充點

當 JUnit 5 專案在 2015 起草時,主要的設計師們決定幾個核心原則,其中之一是:擴充點優先於新功能,如字面的意思,JUnit 5 提供擴充點,因此當一個測試經過剛剛所述生命週期的步驟時,JUnit 會暫停在定義好的擴充點,檢查是否有擴充程式想在特定的步驟與正在執行的測試互動,以下是擴充點的列表:

  • ContainerExecutionCondition
  • TestInstancePostProcessor
  • TextExecutionCondition
  • BeforeAllCallback
  • BeforeEachCallback
  • BeforeTestExecutionCallback
  • ParameterResolver
  • TestExecutionExceptionHandler
  • AfterTestExecutionCallback
  • AfterEachCallback
  • AfterAllCallback

注意到它們與測試的生命週期息息相關,在這之中,只有 BeforeTestExecutionCallbackAfterTestExecutionCallback 是新的,它們來自技術性的需求,想盡可能貼近測試,例如量測一個測試。

什麼是擴充,以及它如何與擴充點互動?每個擴充點都有一個相同名稱的 Java 介面,這些介面都相當簡單,通常只有一個或兩個(偶爾) 函式,在每個擴充點,JUnit 收集大量的情境資訊 (我待會提到),存取已註冊並實作對應介面的擴充,呼叫其函式,根據其回傳值改變測試的行為。

簡單的量測

在開始深入之前,我們先看一個簡單的例子,比如說我想量測我的測試,將消耗時間列印在終端機上。如您預期的,在測試開始執行前記錄測試開始的時間,在執行後列印消耗的時間。

看一下剛剛的擴充點列表,有兩個擴充點顯然能用:BeforeTestExecutionCallbackAfterTestExecutionCallback,它們的定義如下:

public interface BeforeTestExecutionCallback extends Extension {

    void beforeTestExecution(TestExtensionContext context) throws Exception;
}

public interface AfterTestExecutionCallback extends Extension {

    void afterTestExecution(TestExtensionContext context) throws Exception;
}

擴充的程式看起來像這樣:

public class BenchmarkExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private long launchTime;

    @Override
    public void beforeTestExecution(TestExtensionContext context) {
        launchTime = System.currentTimeMillis();
    }

    @Override
    public void afterTestExecution(TestExtensionContext context) {
        long elapsedTime = System.currentTimeMillis() - launchTime;
        System.out.printf("Test took %d ms.%n", elapsedTime);
    }
}

註冊擴充

這樣其實不足以實作一個擴充,JUnit 也知道這一點,一個擴充可以用 @ExtendWith 註冊,可以加註在類別或函式上,並將擴充的類別當作參數,執行測試的期間,JUnit 會尋找這些在類別或函式上的註釋,然後執行所有找到的擴充。

在一個容器或是函式上註冊擴充是等冪的,也就是說在相同元件上註冊相同的擴充多少次效果都是一樣的,那在不同的元件上註冊相同的擴充呢?

擴充是繼承的,這意味著一個函式會繼承套用在容器上的所有擴充,一個類別會繼承所有其超類別的擴充,它們是由外而內套用的,例如,一個註冊在容器上的 before-each 擴充會比註冊在函式上相同擴充點的擴充先執行。

與由上而下的策略相比,由外而內的策略,意指擴充在 “after” 的行為會以相反的順序執行,也就是註冊在函式上的擴充會比註冊在對應容器的擴充先執行 [譯註:before container -> before method -> after method -> after container]。

在相同的擴充點註冊不同的擴充,當然是可行的,它們套用的方式依舊是由外而內的,如同他們宣告的順序。

註冊一個量測的擴充 有了相關的認識後,我們套用一個量測的擴充:

// this is the way all methods are benchmarked

@ExtendWith(BenchmarkExtension.class)
class BenchmarkedTest {

    @Test
    void benchmarked() throws InterruptedException {
        Thread.sleep(100);
    }
}

在容器上註冊這擴充後,JUnit 套用這擴充到所有容器中的測試,並執行 benchmarked 函式,會看到測試大概花 100 ms。

如果您再一次在另一個函式註冊相同的擴充會發生什麼事?

@Test
@ExtendWith(BenchmarkExtension.class)
void benchmarkedTwice() throws InterruptedException {
    Thread.sleep(100);
    assertTrue(true);
}

根據稍早的解釋,這擴充將會再被套用,因此,您會看到二次量測的輸出。

解析擴充 讓我們感受一下註冊是如何實作的,當一個測試節點 (可能是一個容器或一個函式) 準備執行時,在萃取出真正的擴充類別前,JUnit 取得包覆節點 (類別或函式) 的 AnnotatedElement,用反射 (reflection) 的方式存取 @ExtendWith 註釋。

感謝便利的工具函式與 stream,讓 JUnit 用不顯著的程式片段便完成了這件事。

List<Class<? extends Extension>> extensionTypes =
    findRepeatableAnnotations(annotatedElement, ExtendWith.class)
        .stream()
        .map(ExtendWith::value)
        .flatMap(Arrays::stream)
        .collect(toList());

回傳的 List 用來建立 ExtensionRegistry,將 list 轉換成一個 set 確保等冪性,這註冊表不只知道擴充在哪個元件 (例如函式) 上,還有握有一個參考指向上層節點的註冊表,當擴充請求一個註冊表時,它存取自己的上層註冊表,並將其擴充套用在其結果中 [譯註:註冊表],上層註冊表同樣也會呼叫上層註冊表。

為實作我剛描述的由外而內的語意,ExtensionRegistry 提供二個函式:getExtensionsgetReversedExtensions,前者列出在自己之前上層有的擴充,因此,適合用先前提到的 “before” 順序,後者單純將前者的順序反轉,因此用在 “after” 的使用情境上。

無縫的擴充

@ExtendWith 套用擴充是可行,但太過技術性與麻煩,幸運地,JUnit 團隊也是如此認為,於是他們實作一個簡單卻有強大結果的功能,輔助函式尋找 超註釋 (meta-annotations),也就是套用在別的註釋上的註釋。

這意思是不一定要用 @ExtendWith 加註在一個型別或函式上,不論是直接,或是用同樣的方式間接加註 @ExtendWith 就足夠了,這對可讀性有重要的好處,讓您能寫出與函式庫特性無縫整合的擴充,我們來看二個使用案例。

無縫的量測 為量測的擴充建立一個更優雅的變形相當容易:

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

感謝 Java 的 ElementType@Target 的規範,我可以像其他註釋一樣,套用 @Benchmark 在測試容器與函式上,也可以進一步的組合,這讓我重寫先前的例子讓它看起來更加親切:

@Benchmark
class BenchmarkedTest {

    @Test
    void benchmarked() throws InterruptedException {
        Thread.sleep(100);
    }
}

注意看一下是否更簡單了。

組合功能與擴充 另一個 JUnit meta-annotations 開啟的實用樣式 (pattern) 是能將既有的功能與擴充組合成新的、能揭示意圖的註釋,一個簡單的例子是 IntegrationTest [譯註:原文的註釋名稱 Benchmark 應該是誤植]:

@Target(METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Benchmark
@Tag("integration")
@Test
public @interface IntegrationTest { }

這是一個客制的註釋,一個專案可以建立來滿足整合測試的共同需求,在這情況下,所有這類加註 @Test 的測試會被量測並加上 integration 標籤,讓它們可以被過濾出來,更重要的是,可以讓您用 @IntegrationTest 取代 @Test

class ServerTest {

    @IntegrationTest
    void testLogin {
        // long running test

        Thread.sleep(10000);
    }
}

擴充情境

擴充模型中的一個基石是 ExtensionContext,是一個介面,有二個實作:ContainerExtensionContextTestExtensionContext,這讓擴充能獲得容器或測試目前狀態相關的資訊,它也提供一些 API 與 JUnit 機制互動,看一下它提供哪些函式:

Optional<ExtensionContext> getParent();
String getUniqueId();
String getDisplayName();
Set<String> getTags();
Optional<AnnotatedElement> getElement();
Optional<Class<?>> getTestClass();
Optional<Method> getTestMethod();
void publishReportEntry(Map<String, String> map);
Store getStore();
Store getStore(Namespace namespace);

JUnit 為測試節點建立一個樹狀結構,每個節點產生各自的情境,由於節點有上層節點 (例如,一個測試類別對應的節點,是測試類別中函式對應節點的上層節點),這讓它們的擴充情境參考到上層的情境。

為了讓您能識別與過濾容器與測試,這些項目都有 ID、更可讀的顯示名稱及標籤,能用情境物件的函式存取這些項目,非常重要的是,這些情境提供測試類別與函式的存取方式,這讓擴充能用反射 API 取得測試的註釋或類別的欄位,讓我們用實例來了解這特性:強化量測的擴充可以在日誌訊息中顯示測試的顯示名稱:

@Override
public void afterTestExecution(TestExtensionContext context) {
    long elapsedTime = System.currentTimeMillis() - launchTime;
    System.out.printf("Test '%s' took %d ms.%n", context.getDisplayName(), elapsedTime);
}

您可以更進一步,與其粗略地列印在終端機上,您可以呼叫 publishReportEntry 函式使用 JUnit 的報表基礎建設:

@Override
public void afterTestExecution(TestExtensionContext context) {
    long elapsedTime = System.currentTimeMillis() - launchTime;
    String message = String.format("Test '%s' took %d ms.", context.getDisplayName(), elapsedTime);
    context.publishReportEntry(createMapWithPair("Benchmark", message));
}

我不會深入討論 JUnit 報表功能,但足以說它能紀錄訊息到不同的輸出,像是終端機或 XML 報表,publishReportEntry 函式讓擴充能與報表互動,最後,有一個資料容器用來保存擴充的狀態,我很快會談到。

如同我剛提到的,JUnit 負責尋找與套用擴充,這也意味它也同樣管理擴充的實體,它是如何做到?如果您想將收集到的資訊指派到欄位,如我剛在 BenchmarkExtension 中做的,您需要瞭解擴充的範圍與生命週期。

就結果來說,那是刻意未規範的實作細節,定義擴充實體的生命週期或在測試過程中追蹤它,不只是令人討厭的,從壞處想,對可維護性來說是一個威脅,所以一切都是未知數,JUnit 不保證關於擴充實體生命週期的任何事,因此,擴充必須是無狀態的,並將任何資訊儲存在 JUnit 為這目的提供的資料結構中,稱為 保存庫 (store)

保存庫 是一個有命名空間、階層式的 key-value 資料結構,我們各別來看這些屬性。

要透過擴充情境存取保存庫,一個命名空間是需要的,情境回傳一個保存庫,管理這命名空間下的所有項目,這麼做能避免不同擴充在操作相同節點時的衝突,可能導致意外的資訊分享與狀態修改 (有趣的是,透過命名空間存取可以刻意地去存取另一個擴充的狀態,能和另一個擴充溝通與互動,可能促成跨函式庫的有趣功能)。

因為是為每一個擴充情境建立保存庫,所以保存庫是階層式的,這意味測試樹狀結構中每個節點都有一個保存庫。每個測試容器與函式都有自己的保存庫,如同節點繼承擴充一樣,保存庫繼承狀態,更確切地說,當一個節點建立一個保存庫,這節點將指向上層節點保存庫的參考交給該保存庫,因此,例如一個屬於測試函式的保存庫,有一個參考指向所屬測試類別的保存庫,查詢時 (不含編輯),在委託給上層的保存庫前,保存庫先檢查自己擁有的內容,這讓自己所有子保存庫能讀取自己的狀態。

因為是為每一個擴充情境建立保存庫,所以保存庫是階層式的,這意味測試樹狀結構中每個節點都有一個保存庫。

關於 key-value 資料結構,保存庫是一個簡化過的 map,key 和 value 可以是任何型別,以下是最不可或缺的函式:

interface Store {

    void put(Object key, Object value);
    <V> V get(Object key, Class<V> requiredType);
    <V> V remove(Object key, Class<V> requiredType);
}

getremove 函式用型別參數避免呼叫者的程式充滿轉型,其實這沒什麼魔術,保存庫只是單純在內部做轉型,同樣也有不需要型別參數的多載函式。

無狀態的量測 要讓量測是無狀態的,我需要幾件事:

  • 一個能讓擴充存取保存庫的命名空間
  • 一個識別啟動時間的 key
  • 能讀寫保存庫而不是欄位的能力

為了前二個,我宣告二個常數:

private static final Namespace NAMESPACE = Namespace.create("org", "codefx", "Benchmark");
private static final String LAUNCH_TIME_KEY = "LaunchTime";

讀寫是二個簡單的函式:

private static void storeNowAsLaunchTime(ExtensionContext context) {
    context.getStore(NAMESPACE).put(LAUNCH_TIME_KEY, currentTimeMillis());
}

private static long loadLaunchTime(ExtensionContext context) {
    return context.getStore(NAMESPACE).get(LAUNCH_TIME_KEY, long.class);
}

有了這些函式,我將可以移除的存取欄位 launchTime 的程式取代,在每個測試前後執行的函式變成:

@Override
public void beforeTestExecution(TestExtensionContext context) {
    storeNowAsLaunchTime(context);
}

@Override
public void afterTestExecution(TestExtensionContext context) {
    long launchTime = loadLaunchTime(context);
    long runtime = currentTimeMillis() - launchTime;
    print(context.getDisplayName(), runtime);
}

如您所見,新的函式用保存庫取代欄位來保存與存取情境的狀態。

翻新 @Test

我們來看能善用我先前提到的素材的新例子。

假設我想從 JUnit 4 轉到 JUnit 5,首先,感謝新架構的設計,同時執行新版與舊版的測試相當簡單,這意指不必特別去轉移測試,這雖然讓下面的內容毫無意義,但也少了點樂趣。

我想將 JUnit 4 的 @Test 換成新版的,讓加註的函式變成 JUnit 5 的測試,我會換成 JUnit 5 的 @Test,一個簡單的尋找取代 import 就可以完成,這方法可以用在太部分的情況。(注意:這只是一個想法實驗,不是實際的建議)

但 JUnit 5 的註釋不支援 JUnit 4 選擇性的參數 expected (當特定例外沒有拋出時視為失敗) 以及 timeout (當測試執行太久視作失敗),JUnit 5 透過 assertThrows 及即將推出的 assertTimeout 提供這些功能,但我想找一個不用手動介入的新方式,不用將測試升級到新的 API。

同時執行新版與舊版的測試相當簡單,這意指不必特別去轉移測試。

所以為什麼不建立我自己的 @Test,讓 JUnit 5 能夠識別並執行,且實作想要的功能呢?

最重要的事情優先,我宣告一個新的 @Test 註釋:

@Target(METHOD)
@Retention(RetentionPolicy.RUNTIME)
@org.junit.jupiter.api.Test
public @interface Test { }

這相當簡單:我只是宣告註釋,並在註釋上加上 JUnit 5 的 @Test,因此 JUnit 能辨別出加註的函式是個測試並執行它們。

期待例外 為管理預期中的例外,首先我需一個方法讓使用者能宣告它們,為此,我用從 JUnit 4 的實作中獲得靈感的程式擴充我的註釋:

public @interface Test {

    class None extends Throwable {

        private static final long serialVersionUID = 1L;

        private None() { }
    }

    Class<? extends Throwable> expected() default None.class;
}

現在,使用者可以用 expected 指定預期的例外,預設是 None。擴充本身是一個稱作 ExpectedExceptionExtension 的類別,程式碼顯示於下方,要將它註冊到 JUnit,我可以加註 @Test@ExtendWith(ExpectedExceptionExtension.class)

接著,我需要實際實作想要的行為,這裡簡單描述我要如何完成它:

  1. 如果有測試拋出一個例外,檢查它是否是預期中的例外,若是則將它確實拋出的事實記錄下來,否則將不如預期的錯誤紀錄下來,並將攔截下來的例外拋出 (因為擴充不負責處理例外)。
  2. 測試執行後,檢查預期的例外是否拋出,如果是,則什麼事都沒發生因為事情和計劃的一樣,若不是則讓測試失敗。

為了完成這邏輯,我需要與二個擴充點互動:TestExecutionExceptionHandlerAfterTestExecutionCallback,因此我實作對應的介面:

public class ExpectedExceptionExtension implements TestExecutionExceptionHandler, AfterTestExecutionCallback {

    @Override
    public void handleTestExecutionException(TestExtensionContext context, Throwable throwable) throws Throwable { }

    @Override
    public void afterTestExecution(TestExtensionContext context) { }
}

開始第一步,檢查拋出的例外是否如預期,為此,我使用一個小的輔助函式 expectedException,存取 @Test 註釋,取出預期的例外類別,回傳結果是一個 Optional (因為可能沒有預期的例外)。

為了捕捉觀察到的行為,我建立一個列舉 EXCEPTION,並寫了一個 storeExceptionStatus 以保存觀察到的結果在保存庫中,有了這些輔助,我可以實作第一個擴充點:

@Override
public void handleTestExecutionException(TestExtensionContext context, Throwable throwable) throws Throwable {
    boolean throwableMatchesExpectedException = expectedException(context)
        .filter(expected -> expected.isInstance(throwable))
        .isPresent();
    if (throwableMatchesExpectedException) {
        storeExceptionStatus(context, EXCEPTION.WAS_THROWN_AS_EXPECTED);
    } else {
        storeExceptionStatus(context, EXCEPTION.WAS_THROWN_NOT_AS_EXPECTED);
        throw throwable;
    }
}

注意,透過不拋出例外,我告知 JUnit 我已經處理它且一切正常,因此 JUnit 不會呼叫額外的例外處理器,也不會讓測試失敗,到目前為止一切順利。

現在,當測試執行後,我需要檢查發生什麼事以做對應的處理,另一個輔助函式 loadExceptionStatus 將取出狀態並進一步幫我一點小忙:若沒有例外拋出,我剛實作的擴充點不會被執行,這意味沒有 EXCEPTION 的實體放到保存庫中,在這情況,loadExceptionStatus 會回傳 EXCEPTION.WAS_NOT_THROWN,實作如下:

@Override
public void afterTestExecution(TestExtensionContext context) {
    switch(loadExceptionStatus(context)) {
    case WAS_NOT_THROWN:
        expectedException(context)
            .map(expected -> new IllegalStateException("Expected exception " + expected + " was not thrown."))
            .ifPresent(ex -> { throw ex; });
    case WAS_THROWN_AS_EXPECTED:
        // the exception was thrown as expected, 

        // so there is nothing to do

    case WAS_THROWN_NOT_AS_EXPECTED:
        // an exception was thrown but of the

        // wrong type; it was rethrown in

        // handleTestExecutionException,

        // so there is nothing to do here

    }
}

這方法有二個細節值得討論:

  • 是否有比 IllegalStateException 更合適的例外?例如,AssertionFailedError 也許更好。
  • 如果有非預期的例外拋出,我是否應該讓測試當下就視作失敗?

我在 handleTestExecutionException 中重新拋出捕捉到的例外,所以它可能讓測試失敗,或被其他能讓測試通過的擴充給捕捉,所以讓測試就地失敗,可能破壞其他擴充。

這二個議題都值得在未來繼續完成,但除此之外,我們已經完成能捕捉預期例外的擴充。

逾時 原本的逾時設計保證 JUnit 4 會在指定的時間用完時中斷測試,這需要將測試丟到另一個獨立的執行緒執行,不幸地,JUnit 5 沒有擴充點能處理執行緒,所以這不可能。悲慘的結果是沒有任何擴充能讓測試在特定的執行緒上執行,像是 Swing 測試需要的事件派送執行緒,或是 JavaFX 測試需要的應用程式執行緒,JUnit 團隊已充分理解這限制,希望他們能盡快處理。

您可以實作一個替代的版本,量測測試執行花多少時間,這暗示測試必須要能結束,當時間超過指定門檻時讓測試失敗,如果前面所述,這應該相當容易。

Conclusion

我們已經理解 JUnit 5 提供特定的擴充點,就是一些界面,擴充的開發者可以實作這些介面,直接用 @ExtendWith 註冊他們的實作,或是無縫地使用客制的註釋。

在測試的生命週期中,JUnit 會在每個擴充點暫停,尋找可以套用在目前測試節點的擴充,收集情境資訊,由外而內的順序呼叫擴充,擴充操作情境以及他們儲存在保存庫中的任何狀態,JUnit 根據呼叫函式的回傳值做出反應,並改改變測試的行為。

我們同樣看到您可以如何用這些完成一個簡單的量測擴充,以及更進一步,完成 JUnit 4 @Test 註釋的複刻,您能在我的 GitHub 找到這些及其他更多的例子。

如果您有任何問題或評論想分享,請讓我知道。

在測試的生命週期中,JUnit 會在每個擴充點暫停,尋找可以套用在目前測試節點的擴充,收集情境資訊,由外而內的順序呼叫擴充。

譯者的告白
這一篇文章很長,而且代名詞與子句超多,看是看得懂,但要翻成通順的中文卻很難。話說,我為什麼要在三連假翻譯和寫文章啊~完全沒有放鬆的感覺,嗚~ (友藏內心獨白:反正你也是一個人過節不是嗎?)

← 本周雜記 (2016/12/11 ~ 2016/12/17) 本周雜記 (2016/12/17 ~ 2016/12/24) →