當你在本地、測試環境和 CI 中跑同一組測試時,是否遇到過這樣的困惑:同一段業務邏輯在不同配置、不同 Locale 下的表現不盡相同,但你又不想為每種場景複製一堆幾乎一樣的測試類?如果把所有分支邏輯都塞進一個測試方法裏,又會讓測試變得臃腫難以維護。有沒有一種方式,可以讓測試代碼保持簡潔,卻能優雅地在多種“環境切面”下重複執行整套測試?這正是 JUnit 5 中 @ClassTemplate 想要解決的問題。本文就從這個現實場景出發,帶你深入理解 Class Template 的執行機制、擴展點設計以及一個實用的多 Locale 示例。
1. 引言
有些測試需要在不同的環境中運行。@ClassTemplate 註解可以幫我們做到這一點:它會讓整個測試類在多種不同配置下被重複執行。
在這篇教程中,我們會先討論為什麼會有“類模板(Class Template)”這種機制,以及 JUnit 是如何執行它們的;接着會看看它在整體執行模型中的位置;最後,我們會拆解類模板的結構、背後的提供者(provider),並通過一個示例,在不復制任何測試代碼的前提下,讓同一個測試類在多個 Locale 環境下運行。
2. 什麼是 @ClassTemplate
簡單回顧一下,@ClassTemplate 會把一個測試類變成“模板類”,讓它按照不同的調用上下文(invocation context)多次執行。提供者負責提供這些上下文,每一個上下文都會觸發一次獨立的執行,擁有各自的生命週期和擴展。
在實踐中,這讓我們可以在不同環境或配置下多次運行同一個測試類,同時保持測試代碼本身的簡單性。我們可以改變運行時的環境配置,而不用複製測試類,或者在單個測試方法里加入複雜的分支邏輯。
2.1. Class Template 如何執行
一個類模板由兩部分組成:模板類本身,以及為其提供調用上下文的提供者。模板類在外觀上就像一個普通的 JUnit 測試類,但 @ClassTemplate 註解會告訴 JUnit 不要直接運行它,而是等待提供者來定義該類的具體執行方式。
一旦 JUnit 識別出某個類是類模板,提供者就會返回一個或多個上下文,每個上下文都定義了一次完整的執行。對於每個上下文,JUnit 都會創建一個新的測試實例,應用對應的擴展,並執行生命週期方法和測試方法。這樣,測試類可以專注於業務邏輯本身,而由提供者來塑造運行時環境。
2.2. Class Template 與 Method Template 對比
在繼續之前,值得先對比一下類模板和方法模板(method template)之間的區別。兩者都支持重複執行,但關注的層級不同。方法模板會在不同輸入下重複執行同一個測試方法;而類模板則會重複執行整個測試類,包括它的生命週期回調、擴展以及配置。
因此,當變化點主要體現在整體環境層面——例如 Locale、特性開關或系統級配置——而不是單個方法參數時,類模板會更加合適。
3. 調用上下文提供者
接下來,我們看看“調用上下文提供者(invocation context provider)”。這個擴展負責為類模板提供執行上下文。它需要實現 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 的提供者才會對當前類模板生效。通過這種機制,JUnit 可以避免提供者被意外激活,避免在無關測試上運行擴展,同時也允許多個提供者並存而互不干擾。
3.2. provideClassTemplateInvocationContexts() 方法
一旦某個提供者被激活,JUnit 就會調用 provideClassTemplateInvocationContexts(),以獲取描述模板執行方式的上下文:
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(invocationContext("A"), invocationContext("B"));
}
每一個上下文都代表了一次對測試類的完整執行。單個提供者可以提供一個或多個上下文;如果同時有多個提供者處於激活狀態,JUnit 會把它們提供的流拼接起來。每個上下文都可以添加自己的擴展或配置,從而讓提供者可以對該次執行的環境進行精細控制。
從這裏開始,JUnit 會為每個上下文創建一個新的測試類實例,應用對應的擴展,並完整運行生命週期方法和測試方法各一次。
4. 實用示例
為了更直觀地理解這些概念,我們來構造一個示例:編寫一個測試,用來驗證在多個 JVM Locale 下的日期格式化邏輯。由於 Locale 會影響整個執行環境,這類需求非常適合用類模板來實現。我們只保留一個測試類,然後讓提供者在不同配置下多次執行它。
4.1. 日期格式化邏輯
首先,從一個小工具類開始,它使用當前 JVM 默認 Locale 來格式化日期。只要默認 Locale 發生變化,它的輸出就會隨之改變:
class DateFormatter {
public String format(LocalDate date) {
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.getDefault());
return date.format(formatter);
}
}
有了這個類之後,我們就可以在多種不同的配置下驗證它的行為,而這些配置都由類模板來提供。
4.2. 提供者與擴展
為了支撐上述需求,我們首先需要一個擴展,用來在單次執行期間設置默認 Locale:
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,並在測試結束後恢復原有值。在不同執行之間唯一變化的,就是傳入該擴展的 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. Class Template 測試
此時,類模板的整體配置已經就位,我們就可以專注於編寫一個測試方法了。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);
}
在每次執行中,測試都會基於當前默認 Locale 計算預期值,並與 DateFormatter 的輸出進行比較。類模板和提供者負責在每次執行之間切換環境設置,因此測試代碼本身可以保持簡單、乾淨,不需要任何分支邏輯。
4.4. 測試輸出
最後,當我們運行這組測試時,同一個測試類會在每個 Locale 下執行一次,而每次的格式化結果都不相同:
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 的使用方式,重點考察了提供者如何為單個測試類提供多個執行上下文。通過 Locale 示例,我們看到提供者和擴展可以在不修改測試代碼的前提下靈活地切換測試環境。這使得類模板成為處理全局設置或配置級行為測試的一種乾淨而優雅的解決方案。感謝閲讀,如果您對Java內容感興趣,也可以關注我的Java專題內容。