原文地址: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 上的 PPT。測試
本系列文章都基於 Junit 5發佈的先行版 Milestone 2。它可能會有變化。若是有新的里程碑(milestone)版本發佈,或者試用版正式發行時,我會再來更新這篇文章。
這裏要介紹的多數知識你均可以在 JUnit 5 用戶指南 中找到(這個連接指向的是先行版 Milestone 2,想看的最新版本文檔的話請戳這裏),而且指南還有更多的內容等待你發掘。下面的全部代碼均可以在 個人 Github 上找到。
JUnit 4 的擴展模型
Runners(運行器)
Rules(規則)
現狀
JUnit 5 的擴展模型
擴展點
無狀態
應用擴展
自定義註解
例子
回顧總結
分享&關注
「譯者注:本篇的 Runner,統一譯爲「運行器」;Rule,統一譯爲「規則」。雖不必定徹底達義,但語義未損失太多。在每小節第一次出現處會以中英標註,其後所有使用中文。」
咱們先來看看 JUnit 4 中是如何實現擴展的。在 JUnit 4 中實現擴展主要是經過兩個,有時也互有重疊的擴展機制:運行器(Runners)和規則(Rules)。
測試運行器負責管理諸多測試的生命週期,包括它們的實例化、setup/teardown 方法的調用、測試運行、異常處理、發送消息等。在 JUnit 4 提供的運行器實現中,它負責了這全部的事情。
在 JUnit 4 中,擴展 JUnit 的惟一方法是:建立一個新的運行器,而後使用它標記你新的測試類:@Runwith(MyRunner.class)
。這樣 JUnit 就會識別並使用它來運行測試,而不會使用其默認的實現。
這個方式很重,對於小定製小擴展來講很不方便。同時它有個很苛刻的限制:一個測試類只能用一個運行器來跑,這意味着你不能組合不一樣的運行器。也便是說,你不能同時享受到兩個以上運行器提供的特性,好比說不能同時使用 Mockito 和 Spring 的運行器,等。
爲了克服這個限制,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 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
註解,它有什麼可能被咱們的擴展處理? 一個語法上的緣由是,若是一個擴展被應用到了一個類上,那麼它默認也會應用到類中的全部方法上。所以,若是咱們的需求是計算整個測試類的運行時間,但不需具體到類中每一個單獨方法的運行時間時,類中的測試方法就必須被手動排除。這點咱們能夠經過單獨檢查每一個方法是否應用了註解來作到。
有趣的是,需求的前四點與擴展點中的其中四個是一一對應的:BeforeAll、BeforeTestExecution、AfterTestExecution 與 AfterAll。所以咱們要作的任務即是實現這四個對應的接口。具體實現很簡單,把上面說的翻譯成代碼便是:
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 引入了一個更通用的概念:擴展點。它容許自定義的擴展主動聲明,它須要在一個測試的什麼節點上去介入。同時,咱們還看到如何使用元註解來輕鬆地自定義註解。
我但願聽到你的想法和反饋。