JUnit 5 中 @ClassTemplate 的使用指南
1. 引言
有些測試需要在各種環境下運作。 ` @ClassTemplate註解透過多次執行整個測試類,每次使用不同的配置,來解決這個問題。
在本教程中,我們將探討類別模板存在的意義以及 JUnit 如何運作它們。此外,我們還將研究它們在執行模型中的位置。最後,我們將了解它們的結構、背後的提供程序,以及一個實際範例,該範例演示如何在多個特定於語言環境的環境中運行同一個測試類,而無需重複編寫程式碼。
2. 什麼是@ClassTemplate
簡單回顧一下, @ClassTemplate會將測試類別轉換為模板,該模板在每個呼叫上下文中執行一次。提供者會指派上下文,每個上下文都會觸發一次獨立的執行,並擁有各自的生命週期和擴充。
實際上,這使我們能夠在不同的環境或配置下運行同一個測試類,同時保持測試程式碼的簡潔性。我們可以改變運行時設置,而無需複製測試類或在單一測試中引入分支邏輯。
2.1. 類別範本的執行方式
類別模板包含兩部分:模板類別本身和提供其呼叫上下文的提供者。模板類別看起來像是標準的 JUnit 測試類,但@ClassTemplate註解指示 JUnit 不要直接執行它。相反,JUnit 會等待提供者定義如何執行該類別。
JUnit 辨識到類別範本後,提供者會傳回一個或多個上下文,每個上下文都定義了一個完整的執行過程。對於每個上下文, JUnit 都會建立一個新的測試實例,應用其擴展,並運行生命週期和測試方法。因此,測試人員可以專注於邏輯,而提供程序則負責配置測試環境。
2.2. 類別模板與方法模板
在繼續之前,值得注意的是類別模板和方法模板的區別。兩者都允許重複執行,但它們的工作層級不同。方法模板使用不同的輸入重新運行單一測試方法,而類別模板重新運行整個測試類,包括其生命週期回呼、擴展和配置。
因此,當變化涉及整體環境(例如語言環境、功能標誌或系統設定)而不是每個方法的參數時,類別模板更為合適。
3. 呼叫上下文提供者
接下來,我們來看看呼叫上下文提供者。此擴充功能為類別範本提供執行上下文。它實作了ClassTemplateInvocationContextProvider接口,該接口定義了兩個核心方法,用於確定提供者如何參與測試執行。
讓我們來仔細審視它們。
3.1. SupportsClassTemplate()方法
JUnit 在使用提供者之前,首先會檢查該提供者是否適用於目前正在發現的測試類別。這是透過supportsClassTemplate()方法實現的:
@Override
public boolean supportsClassTemplate(ExtensionContext context) {
return context.getTestClass()
.map(aClass -> aClass.isAnnotationPresent(ClassTemplate.class))
.orElse(false);
}
JUnit 會為每個已註冊的提供者呼叫此方法。因此,只有傳回 true 的提供者才會對目前類別範本生效。此機制可防止意外激活,避免在不相關的測試中執行提供程序,並允許多個提供者共存而不相互幹擾。
3.2. ProvideClassTemplateInvocationContexts()方法
一旦提供者變成活動狀態,JUnit 就會呼叫provideClassTemplateInvocationContexts()來取得描述範本應如何執行的上下文:
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(invocationContext("A"), invocationContext("B"));
}
每個上下文都代表測試類別的完整執行。一個提供者可以提供一個或多個上下文,如果多個提供者處於活動狀態,JUnit 會將它們的流連結起來。每個上下文都可以添加自己的擴充或配置,從而使提供者能夠控制該次運行的環境。
JUnit 會為每個上下文建立一個新的測試類別實例,應用相關的擴展,並針對該執行執行生命週期和測試方法一次。
4. 實際範例
為了說明這些概念,我們建立一個測試,驗證不同 JVM 語言環境下的日期格式。由於語言環境會影響整個執行環境,因此非常適合使用類別模板。我們只維護一個測試類,並讓提供者使用不同的配置多次運行它。
4.1 日期格式化邏輯
首先,我們來看一個簡單的類,它使用 JVM 目前的預設區域設定來格式化日期。當預設區域設定發生變化時,它的輸出也會隨之改變:
class DateFormatter {
public String format(LocalDate date) {
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.getDefault());
return date.format(formatter);
}
}
有了這些,我們將在幾種不同的配置下驗證這種行為,每種配置都由類別模板提供。
4.2. 提供者和擴展
為了實現這一點,我們首先需要一個擴展程序,用於設定單次執行的預設區域設定:
class LocaleExtension implements BeforeEachCallback, AfterEachCallback {
private final Locale locale;
private Locale previous;
@Override
public void beforeEach(ExtensionContext context) {
previous = Locale.getDefault();
Locale.setDefault(locale);
}
@Override
public void afterEach(ExtensionContext context) {
Locale.setDefault(previous);
}
}
此擴充功能會在每次測試前暫時替換 JVM 的預設值,並在測試結束後恢復原始值。每次執行之間唯一改變的是傳遞給擴充功能的Locale實例。
此外,提供者透過provideClassTemplateInvocationContexts()提供上下文。每個上下文都由invocationContext()創建,它使用getDisplayName()分配顯示名稱,並透過getAdditionalExtensions()安裝LocaleExtension :
class DateLocaleClassTemplateProvider implements ClassTemplateInvocationContextProvider {
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(Locale.US, Locale.GERMANY, Locale.ITALY, Locale.JAPAN)
.map(this::invocationContext);
}
private ClassTemplateInvocationContext invocationContext(Locale locale) {
return new ClassTemplateInvocationContext() {
@Override
public String getDisplayName(int invocationIndex) {
return "Locale: " + locale.getDisplayName();
}
@Override
public List<Extension> getAdditionalExtensions() {
return List.of(new LocaleExtension(locale));
}
};
}
}
這種配置會產生不同的環境,導致同一個測試類別獨立執行四次。
4.3. 類別模板測試
此時,範本設定允許我們專注於單一測試方法,JUnit 透過配置的提供者在每個上下文中執行一次此方法:
private final DateFormatter formatter = new DateFormatter();
@Test
void givenDefaultLocale_whenFormattingDate_thenMatchesLocalizedOutput() {
LocalDate date = LocalDate.of(2025, 9, 30);
DateTimeFormatter expectedFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.getDefault());
String expected = date.format(expectedFormatter);
String formatted = formatter.format(date);
LOG.info("Locale: {}, Expected: {}, Formatted: {}", Locale.getDefault(), expected, formatted);
assertEquals(expected, formatted);
}
每次執行時,測試都會使用目前預設區域設定計算預期值,並將其與DateFormatter的結果進行比較。類別模板和提供者負責處理執行之間的設定更改,因此測試本身保持簡潔,沒有分支邏輯。
4.4 測試輸出
最後,當我們執行測試時,同一個類別會針對每個語言環境執行一次。每次運行的輸出格式都不同:
Locale: en_US, Expected: September 30, 2025, Formatted: September 30, 2025
Locale: de_DE, Expected: 30. September 2025, Formatted: 30. September 2025
Locale: it_IT, Expected: 30 settembre 2025, Formatted: 30 settembre 2025
Locale: ja_JP, Expected: 2025年9月30日, Formatted: 2025年9月30日
實際上,每一行程式碼都對應一個呼叫上下文。測試程式碼在這些運行之間沒有變化;只有提供者和擴充程式配置的環境發生了變化。
5. 結論
本文深入探討了@ClassTemplate的基礎知識,並探索了提供者如何為單一測試類別提供多個執行上下文。我們以區域設定為例,展示了提供者和擴充功能如何在測試程式碼保持不變的情況下改變測試環境。這使得類別模板成為測試依賴全域或配置層級設定的行為的理想解決方案。
像往常一樣,完整的源代碼可以在 GitHub 上找到。