「譯」JUnit 5 系列:條件測試

原文地址:http://blog.codefx.org/libraries/junit-5-conditions/
原文日期:08, May, 2016
譯文首發: Linesh 的博客:「譯」JUnit 5 系列:條件測試
個人 Github:http://github.com/linesh-simplicityjava

上一節咱們瞭解了 JUnit 新的擴展模型,瞭解了它是如何支持咱們向引擎定製一些行爲的。而後我還預告會爲你們講解條件測試,這一節主題就是它了。react

條件測試,指的是容許咱們自定義靈活的標準,來決定一個測試是否應該執行。條件(condition) 官方的叫法是條件測試執行git

概述

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

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

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

目錄

  • 相關的擴展點

  • 動手實現一個@Disabled 註解

  • @DisabledOnOs

    • 一種簡單的實現方式

    • 更簡潔的API

    • 代碼重構

  • @DisabledIfTestFails

    • 異常收集

    • 禁用測試

    • 集成

  • 回顧總結

  • 分享&關注

相關的擴展點

還記得 拓展點 一節講的內容嗎?不記得了?好吧,簡單來講,JUnit 5 中定義了許多擴展點,每一個擴展點都對應一個接口。你本身的擴展能夠實現其中的某些接口,而後經過 @ExtendWith 註解註冊給 JUnit,後者會在特定的時間點調用你的接口實現。

要實現條件測試,你須要關注其中的兩個擴展點: ContainerExecutionCondition (容器執行條件)和 TestExecutionCondition (測試執行條件)。

public interface ContainerExecutionCondition extends Extension {
 
    /**
     * Evaluate this condition for the supplied ContainerExtensionContext.
     *
     * An enabled result indicates that the container should be executed;
     * whereas, a disabled result indicates that the container should not
     * be executed.
     *
     * @param context the current ContainerExtensionContext; never null
     * @return the result of evaluating this condition; never null
     */
    ConditionEvaluationResult evaluate(ContainerExtensionContext context);
 
}
 
public interface TestExecutionCondition extends Extension {
 
    /**
     * Evaluate this condition for the supplied TestExtensionContext.
     *
     * An enabled result indicates that the test should be executed;
     * whereas, a disabled result indicates that the test should not
     * be executed.
     *
     * @param context the current TestExtensionContext; never null
     * @return the result of evaluating this condition; never null
     */
    ConditionEvaluationResult evaluate(TestExtensionContext context);
 
}

ContainerExecutionCondition 接口將決定容器中的測試是否會被執行。一般狀況下,你使用 @Test 註解來標記測試,此時測試所在的類就是容器。同時,單獨的測試方法是否執行則是由 TestExecutionCondition 接口決定的。

(這裏,我說的是「一般狀況下」,由於其餘測試引擎可能對容器和測試有大相徑庭的定義。但通常狀況下,測試就是單個的方法,容器指的就是測試類。)

嗯,基本知識就這麼多。想實現條件測試,至少須要實現以上兩個接口中的一個,並在接口的 evalute 方法中執行本身的條件檢查。

動手實現一個@Disabled 註解

最簡單的「條件」就是判斷都沒有,直接禁用測試。若是在方法上發現了 @Disabled 註解,咱們就直接禁用該測試。

讓咱們來寫一個這樣的 @Disabled 註解吧:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(@DisabledCondition.class)
public @interface Disabled { }

對應的擴展以下:

public class DisabledCondition
        implements ContainerExecutionCondition, TestExecutionCondition {
 
    private static final ConditionEvaluationResult ENABLED =
            ConditionEvaluationResult.enabled("@Disabled is not present");
 
    @Override
    public ConditionEvaluationResult evaluate(
            ContainerExtensionContext context) {
        return evaluateIfAnnotated(context.getElement());
    }
 
    @Override
    public ConditionEvaluationResult evaluate(
            TestExtensionContext context) {
        return evaluateIfAnnotated(context.getElement());
    }
 
    private ConditionEvaluationResult evaluateIfAnnotated(
            Optional<AnnotatedElement> element) {
        Optional<Disabled> disabled = AnnotationUtils
                .findAnnotation(element, Disabled.class);
 
        if (disabled.isPresent())
            return ConditionEvaluationResult
                    .disabled(element + " is @Disabled");
 
        return ENABLED;
    }

}

寫起來小菜一碟吧?在 JUnit 真實的產品代碼中,@Disabled 也是這麼實現的。不過,有兩個地方有一些細微的差異:

  • 官方 @Disabled 註解不須要再使用 @ExtendWith 註冊擴展,由於它是默認註冊了的

  • 官方 @Disabled 註解能夠接收一個參數,解釋測試被忽略的理由。它會在測試被忽略時被記錄下來

使用時請注意,AnnotationUtils 是個內部 API。不過,官方可能很快就會將它提供的功能給開放出來

接下來讓咱們寫點更有意思的東西吧。

@DisabledOnOs

若是有些測試咱們只想讓它在特定的操做系統上面運行,這個要怎麼實現呢?

一種簡單的實現方式

固然,咱們仍是從註解開始咯:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(OsCondition.class)
public @interface DisabledOnOs {
 
    OS[] value() default {};
 
}

這回註解須要接收一個或多個參數值,你須要告訴它想禁用測試的操做系統有哪些。 OS 是個枚舉類,定義了全部操做系統的名字。同時,它還提供了一個靜態的 static OS determine() 方法,你可能已經從名字猜到了,它會推斷並返回你當前所用的操做系統。

如今咱們能夠着手實現 OsCondition 擴展類了。它必須檢查兩點:註解是否存在,以及當前操做系統是否在註解聲明的禁用列表中。

public class OsCondition 
        implements ContainerExecutionCondition, TestExecutionCondition {
 
    // both `evaluate` methods forward to `evaluateIfAnnotated` as above
 
    private ConditionEvaluationResult evaluateIfAnnotated(
            Optional<AnnotatedElement> element) {
        Optional<DisabledOnOs> disabled = AnnotationUtils
                .findAnnotation(element, DisabledOnOs.class);
 
        if (disabled.isPresent())
            return disabledIfOn(disabled.get().value());
 
        return ENABLED;
    }
 
    private ConditionEvaluationResult disabledIfOn(OS[] disabledOnOs) {
        OS os = OS.determine();
        if (Arrays.asList(disabledOnOs).contains(os))
            return ConditionEvaluationResult
                    .disabled("Test is disabled on " + os + ".");
        else
            return ConditionEvaluationResult
                    .enabled("Test is not disabled on " + os + ".");
    }

}

而後使用的時候就能夠像這樣:

@Test
@DisabledOnOs(OS.WINDOWS)
void doesNotRunOnWindows() {
    assertTrue(false);
}

棒。

更簡潔的API

但代碼還能夠寫得更好!JUnit 的註解是可組合的,基於此咱們可讓這個條件註解更簡潔:

@TestExceptOnOs(OS.WINDOWS)
void doesNotRunOnWindowsEither() {
    assertTrue(false);
}

@TestExceptionOnOs完美的實現方案是這樣的:

@Retention(RetentionPolicy.RUNTIME)
@Test
@DisabledOnOs(/* 經過某種方式取得註解下的 `value` 值 */)
public @interface TestExceptOnOs {
 
    OS[] value() default {};
 
}

測試實際運行時, OsCondition::evaluateIfAnnotated 方法會掃描 @DisabledOnOs 註解,而後咱們發現它又是對 @TestExceptOnOs 的註解,前面寫的代碼就能夠如期工做了。但我不知道如何在 @DisabledOnOs 註解中獲取 @TestExceptOnOs 中的value()值。:((你能作到嗎?)

次佳的選擇是,簡單地在 @TestExceptOnOs 註解上直接聲明應用的擴展就能夠了:

@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(OsCondition.class)
@Test
public @interface TestExceptOnOs {
 
    OS[] value() default {};
 
}

而後直接把 OsCondition:evaluateIfAnnotated 方法拉過來改改便可:

private ConditionEvaluationResult evaluateIfAnnotated(
        Optional<Annotatedelement> element) {
    Optional<DisabledOnOs> disabled = AnnotationUtils
        .findAnnotation(element, DisabledOnOs.class);
    if (disabled.isPresent()) 
        return disabledIfOn(disabled.get().value());
        
    Optional<TestExceptOnOs> testExcept = AnnotationUtils
        .findAnnotation(element, TestExceptOnOs.class);
    if (testExcept.isPresent()) 
        return disabledIfOn(testExcept.get().value());
            
    return ConditionEvaluationResult.enabled("");
}

收工。如今咱們能夠如期使用這個註解了。

代碼重構

咱們還須要建立一個意義恰好相反的註解(即如今變爲,當前操做系統不在提供列表時,才禁用測試),工做是相似的,可是註解名會更表意,再加入靜態導入後,咱們的代碼最終能夠整理成這樣:

@TestOn(WINDOWS)
void doesNotRunOnWindoesEither() {
    assertTrue(false);
}

還挺好看的,是不?

「譯者注:英文中condition有多個意思:「條件;空調」。做者這裏配圖取雙關」

@DisabledIfTestFails

咱們再考慮一種場景——我保證此次能夠接觸更有意思的東西!假設如今有許多(集成)測試,若是其中有一個拋出了特定的異常而失敗,那麼其餘測試也必須會掛。爲了節省時間,咱們但願在這種狀況下直接禁用掉其餘的測試。

那麼咱們須要作些什麼工做呢?首先第一反應不難想到,咱們 必須先能收集測試執行過程拋出的異常。這確定須要在單個測試類級別的生命週期中進行處理,不然就可能由於其餘測試類中拋出的異常而影響到本測試類的運行。其次,咱們須要一個實現一個條件:它會檢查某個特定的異常是否已被拋出過,如果,禁用當前測試。

異常收集

翻閱一下文檔中提供的 擴展點列表,不難發現有一項「異常處理」,看起來就是咱們想要的東西:

/**
 * TestExecutionExceptionHandler defines the API for Extensions that wish to react to thrown exceptions in tests.
 * 
 * [ ... ]
 */
public interface TestExecutionExceptionHandler extends ExtensionPoint { 
    /**
     * Handle the supplied throwable.
     * 
     * Implementors must perform one of the following.
     * 
     * - Swallow the supplied throwable, thereby preventing propagation
     * - Rethrow the incoming throwable as is 
     * - Throw a new exception, potenially wrapping the supplied throwable 
     *  
     * [ ... ]
     */
    void handleTestExecutionException(
        TestExtensionContext context, Throwable throwable) 
        throws Throwable;    
}

讀完發現,咱們的任務就是實現 handleException 方法,存儲起接收到的異常並從新拋出。

你可能還記得我提過的關於擴展點和無狀態的一些結論:

引擎對擴展實例的初始化時間、實例的生存時間未做出任何規約和保證,所以,擴展必須是無狀態的。若是一個擴展須要維持任何狀態信息,那麼它必須使用 JUnit 提供的一個倉庫(store)來進行信息讀取和寫入。

看來咱們是必須使用這個 store 了。store 其實就是存放咱們但願保存的一些東西,一個可索引的資源集合。它能夠在擴展上下文對象中取得,後者會被傳給大多數擴展點接口方法做爲參數。不過須要注意的是,每一個不一樣的上下文對象都有本身一個獨立的 store,因此咱們還必須決定使用哪一個 store。

每一個測試方法有一個本身的上下文對象(TestExtensionContext),同時,測試類也有一個本身的上下文對象(ContainerExtensionContext)。還記得咱們的需求嗎?保存測試類中任何測試方法可能拋出的異常,僅此而已。也即,咱們不會保存其餘測試類中拋出的異常。這樣一來,容器級別的上下文 ContainerExtensionContext 恰好就是咱們須要的了。

接下來,咱們可使用這個容器上下文,經過它來存儲全部測試過程拋出的異常:

private static final Namespece NAMESPACE = Namespace
    .of("org", "codefx", "CollectExceptions");
private static final String THROWN_EXCEPTIONS_KEY = "THROWN_EXCEPTION_KEY";

@SuppressWarnings("unchecked")
private static Set<Exception> getThrown(ExtensionContext context) {
    ExtensionContext containerContext = getAncestorContainerContext(context)
        .orElseThrow(IllegalStateException::new);
    retrun (Set<Exception>) containerContext
        .getStore(NAMESPACE)
        .getOrComputeIfAbsent(
            THROWN_EXCEPTIONS_KEY,
            ignoredKey -> new HashSet<>());
}

private static Optional<ExtensionContext> getAncestorContainerContext(
        ExtensionContext context) {
    Optional<ExtensionContext> containerContext = Optional.of(context);
    while (containerContext.isPresent()) 
            && !(containerContext.get() instanceof ContainerExtenstionContext)) 
        containerContext = containerContext.get().getParent();
    return containerContext;
}

如今存儲一個異常就很是簡單了:

@Override
public void handleException(TestExtensionContext context, Throwable throwable) 
        throws Throwable {
    if (throwable instanceof Exception) {
        getThrown(context).add((Exception) throwable);
    throw throwable;

有意思的是,這個擴展仍是自擴展的,沒準還能夠用來作數據統計呢「譯者注:這句不太理解,原文 This is actually an interesting extension of its own. Maybe it could be used for analytics as well.」。無論怎樣,咱們須要一個public方法來拿到已拋出的異常列表:

public static Stream<Exception> getThrownExceptions(
        ExtensionContext context) {
    return getThrown(context).stream();
}

有了這個方法,其餘的擴展就能夠檢查至今爲止所拋出的異常列表了。

禁用測試

禁用測試的部分與前節所述十分相似,咱們能夠很快寫出代碼:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DisabledIfTestFailedCondition.class)
public @interface DisabledIfTestFailedWith {

    Class <? extends Exception>[] value() default {};

}

注意,如今僅容許該註解被用在測試方法上。應用在測試類上也說得過去,不過咱們如今先不把它複雜化。所以咱們只須要實現接口TestExecutionCondition便可。咱們先檢查註解是否存在,如果,再拿到用戶提供的異常類做爲參數,調用 disableIfExceptionWasThrown

private ConditionEvaluationResult disableIfExceptionWasThrown(
        TestExtensionContext context,
        Class<? extends Exception>[] exceptions) {
    return Arrays.stream(exceptions)
            .filter(ex -> wasThrown(context, ex))
            .findAny().
            .map(thrown -> ConditionEvaluationResult.disabled(
                    thrown.getSimpleName() + "was thrown."))
            .orElseGet(() -> ConditionEvaluationResult.enabled(""));
}

private static boolean wasThrown(
        TestExtensionContext context, Class<? extends Exception> exception) {
    return CollectExceptionExtension.getThrownExceptions(context)
            .map(Object::getClass)
            .anyMatch(exception::isAssignableFrom);
}

集成

至此爲止需求完成。如今咱們可使用這個註解,在某個特定類型的異常拋出時禁用測試了:

@CollectExceptions
class DisabledIfFailsTest {
    
    private static boolean failedFirst = false;
    
    @Test
    void throwException() {
        System.out.println("I failed!");
        failedFirst = true;
        throw new RuntimeException();
    }
    
    @Test
    @DisabledIfTestFailedWith(RuntimeException.class)
    void disableIfOtherFailedFirst() {
        System.out.println("Nobody failed yet! (Right?)");
        assertFalse(failedFirst);
    }
    
}

回顧總結

哇哦,本篇的代碼還挺多的!不過相信到此你已經能徹底理解怎麼在 JUnit 5 中實現條件測試了:

  • 建立一個註解,並使用 @ExtendWith 註解它,而後提供你本身實現的條件類

  • 實現 ContainerExecutionCondition 或/和 TestExecutionCondition

  • 檢查測試類上是否應用了你新建立的註解

  • 檢查特定條件是否實現,並返回結果

除此之外,咱們還看到註解之間能夠組合,學到如何使用 JUnit 提供的 store 來保存數據,以及一個擴展的實現,如何經過自定義註解的加入變得更加優雅。

更多關於 旗幟 擴展點的故事「譯者注:原文爲more fun with flag extension points,more fun with flags 是生活大爆炸中謝耳朵講國旗的故事一部」,請參考下篇文章,咱們會探討關於參數注入的問題。

相關文章
相關標籤/搜索