JUnit 5 中的 @ClassTemplate 實(shí)戰(zhàn)攻略
當(dāng)你在本地、測(cè)試環(huán)境和 CI 中跑同一組測(cè)試時(shí),是否遇到過(guò)這樣的困惑:同一段業(yè)務(wù)邏輯在不同配置、不同 Locale 下的表現(xiàn)不盡相同,但你又不想為每種場(chǎng)景復(fù)制一堆幾乎一樣的測(cè)試類?如果把所有分支邏輯都塞進(jìn)一個(gè)測(cè)試方法里,又會(huì)讓測(cè)試變得臃腫難以維護(hù)。有沒(méi)有一種方式,可以讓測(cè)試代碼保持簡(jiǎn)潔,卻能優(yōu)雅地在多種“環(huán)境切面”下重復(fù)執(zhí)行整套測(cè)試?這正是 JUnit 5 中 @ClassTemplate 想要解決的問(wèn)題。本文就從這個(gè)現(xiàn)實(shí)場(chǎng)景出發(fā),帶你深入理解 Class Template 的執(zhí)行機(jī)制、擴(kuò)展點(diǎn)設(shè)計(jì)以及一個(gè)實(shí)用的多 Locale 示例。
1. 引言
有些測(cè)試需要在不同的環(huán)境中運(yùn)行。@ClassTemplate 注解可以幫我們做到這一點(diǎn):它會(huì)讓整個(gè)測(cè)試類在多種不同配置下被重復(fù)執(zhí)行。
在這篇教程中,我們會(huì)先討論為什么會(huì)有“類模板(Class Template)”這種機(jī)制,以及 JUnit 是如何執(zhí)行它們的;接著會(huì)看看它在整體執(zhí)行模型中的位置;最后,我們會(huì)拆解類模板的結(jié)構(gòu)、背后的提供者(provider),并通過(guò)一個(gè)示例,在不復(fù)制任何測(cè)試代碼的前提下,讓同一個(gè)測(cè)試類在多個(gè) Locale 環(huán)境下運(yùn)行。
2. 什么是 @ClassTemplate
簡(jiǎn)單回顧一下,@ClassTemplate 會(huì)把一個(gè)測(cè)試類變成“模板類”,讓它按照不同的調(diào)用上下文(invocation context)多次執(zhí)行。提供者負(fù)責(zé)提供這些上下文,每一個(gè)上下文都會(huì)觸發(fā)一次獨(dú)立的執(zhí)行,擁有各自的生命周期和擴(kuò)展。
在實(shí)踐中,這讓我們可以在不同環(huán)境或配置下多次運(yùn)行同一個(gè)測(cè)試類,同時(shí)保持測(cè)試代碼本身的簡(jiǎn)單性。我們可以改變運(yùn)行時(shí)的環(huán)境配置,而不用復(fù)制測(cè)試類,或者在單個(gè)測(cè)試方法里加入復(fù)雜的分支邏輯。
2.1. Class Template 如何執(zhí)行
一個(gè)類模板由兩部分組成:模板類本身,以及為其提供調(diào)用上下文的提供者。模板類在外觀上就像一個(gè)普通的 JUnit 測(cè)試類,但 @ClassTemplate 注解會(huì)告訴 JUnit 不要直接運(yùn)行它,而是等待提供者來(lái)定義該類的具體執(zhí)行方式。
一旦 JUnit 識(shí)別出某個(gè)類是類模板,提供者就會(huì)返回一個(gè)或多個(gè)上下文,每個(gè)上下文都定義了一次完整的執(zhí)行。對(duì)于每個(gè)上下文,JUnit 都會(huì)創(chuàng)建一個(gè)新的測(cè)試實(shí)例,應(yīng)用對(duì)應(yīng)的擴(kuò)展,并執(zhí)行生命周期方法和測(cè)試方法。這樣,測(cè)試類可以專注于業(yè)務(wù)邏輯本身,而由提供者來(lái)塑造運(yùn)行時(shí)環(huán)境。
2.2. Class Template 與 Method Template 對(duì)比
在繼續(xù)之前,值得先對(duì)比一下類模板和方法模板(method template)之間的區(qū)別。兩者都支持重復(fù)執(zhí)行,但關(guān)注的層級(jí)不同。方法模板會(huì)在不同輸入下重復(fù)執(zhí)行同一個(gè)測(cè)試方法;而類模板則會(huì)重復(fù)執(zhí)行整個(gè)測(cè)試類,包括它的生命周期回調(diào)、擴(kuò)展以及配置。
因此,當(dāng)變化點(diǎn)主要體現(xiàn)在整體環(huán)境層面——例如 Locale、特性開(kāi)關(guān)或系統(tǒng)級(jí)配置——而不是單個(gè)方法參數(shù)時(shí),類模板會(huì)更加合適。
3. 調(diào)用上下文提供者
接下來(lái),我們看看“調(diào)用上下文提供者(invocation context provider)”。這個(gè)擴(kuò)展負(fù)責(zé)為類模板提供執(zhí)行上下文。它需要實(shí)現(xiàn) ClassTemplateInvocationContextProvider 接口,該接口定義了兩個(gè)核心方法,用來(lái)決定提供者如何參與測(cè)試執(zhí)行。
下面我們分別來(lái)看。
3.1. supportsClassTemplate() 方法
在 JUnit 使用某個(gè)提供者之前,它會(huì)先檢查該提供者是否適用于當(dāng)前正在發(fā)現(xiàn)的測(cè)試類。這個(gè)檢查就是通過(guò) supportsClassTemplate() 方法完成的:
@Override
public boolean supportsClassTemplate(ExtensionContext context) {
return context.getTestClass()
.map(aClass -> aClass.isAnnotationPresent(ClassTemplate.class))
.orElse(false);
}JUnit 會(huì)對(duì)每一個(gè)已注冊(cè)的提供者調(diào)用這個(gè)方法。只有返回 true 的提供者才會(huì)對(duì)當(dāng)前類模板生效。通過(guò)這種機(jī)制,JUnit 可以避免提供者被意外激活,避免在無(wú)關(guān)測(cè)試上運(yùn)行擴(kuò)展,同時(shí)也允許多個(gè)提供者并存而互不干擾。
3.2. provideClassTemplateInvocationContexts() 方法
一旦某個(gè)提供者被激活,JUnit 就會(huì)調(diào)用 provideClassTemplateInvocationContexts(),以獲取描述模板執(zhí)行方式的上下文:
@Override
public Stream<ClassTemplateInvocationContext> provideClassTemplateInvocationContexts(ExtensionContext context) {
return Stream.of(invocationContext("A"), invocationContext("B"));
}每一個(gè)上下文都代表了一次對(duì)測(cè)試類的完整執(zhí)行。單個(gè)提供者可以提供一個(gè)或多個(gè)上下文;如果同時(shí)有多個(gè)提供者處于激活狀態(tài),JUnit 會(huì)把它們提供的流拼接起來(lái)。每個(gè)上下文都可以添加自己的擴(kuò)展或配置,從而讓提供者可以對(duì)該次執(zhí)行的環(huán)境進(jìn)行精細(xì)控制。
從這里開(kāi)始,JUnit 會(huì)為每個(gè)上下文創(chuàng)建一個(gè)新的測(cè)試類實(shí)例,應(yīng)用對(duì)應(yīng)的擴(kuò)展,并完整運(yùn)行生命周期方法和測(cè)試方法各一次。
4. 實(shí)用示例
為了更直觀地理解這些概念,我們來(lái)構(gòu)造一個(gè)示例:編寫(xiě)一個(gè)測(cè)試,用來(lái)驗(yàn)證在多個(gè) JVM Locale 下的日期格式化邏輯。由于 Locale 會(huì)影響整個(gè)執(zhí)行環(huán)境,這類需求非常適合用類模板來(lái)實(shí)現(xiàn)。我們只保留一個(gè)測(cè)試類,然后讓提供者在不同配置下多次執(zhí)行它。
4.1. 日期格式化邏輯
首先,從一個(gè)小工具類開(kāi)始,它使用當(dāng)前 JVM 默認(rèn) Locale 來(lái)格式化日期。只要默認(rèn) Locale 發(fā)生變化,它的輸出就會(huì)隨之改變:
class DateFormatter {
public String format(LocalDate date) {
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(Locale.getDefault());
return date.format(formatter);
}
}有了這個(gè)類之后,我們就可以在多種不同的配置下驗(yàn)證它的行為,而這些配置都由類模板來(lái)提供。
4.2. 提供者與擴(kuò)展
為了支撐上述需求,我們首先需要一個(gè)擴(kuò)展,用來(lái)在單次執(zhí)行期間設(shè)置默認(rèn) 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);
}
}這個(gè)擴(kuò)展會(huì)在每次測(cè)試之前暫時(shí)替換 JVM 的默認(rèn) Locale,并在測(cè)試結(jié)束后恢復(fù)原有值。在不同執(zhí)行之間唯一變化的,就是傳入該擴(kuò)展的 Locale 實(shí)例。
接下來(lái),提供者會(huì)通過(guò) provideClassTemplateInvocationContexts() 方法來(lái)提供不同的上下文。每個(gè)上下文都由 invocationContext() 方法創(chuàng)建,該方法通過(guò) getDisplayName() 指定顯示名,并通過(guò) getAdditionalExtensions() 安裝對(duì)應(yīng)的 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));
}
};
}
}通過(guò)這樣的配置,我們就得到了互不相同的執(zhí)行環(huán)境,最終會(huì)對(duì)同一個(gè)測(cè)試類執(zhí)行四次測(cè)試。
4.3. Class Template 測(cè)試
此時(shí),類模板的整體配置已經(jīng)就位,我們就可以專注于編寫(xiě)一個(gè)測(cè)試方法了。JUnit 會(huì)通過(guò)前面配置好的提供者,為每個(gè)上下文執(zhí)行一次這個(gè)方法:
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);
}在每次執(zhí)行中,測(cè)試都會(huì)基于當(dāng)前默認(rèn) Locale 計(jì)算預(yù)期值,并與 DateFormatter 的輸出進(jìn)行比較。類模板和提供者負(fù)責(zé)在每次執(zhí)行之間切換環(huán)境設(shè)置,因此測(cè)試代碼本身可以保持簡(jiǎn)單、干凈,不需要任何分支邏輯。
4.4. 測(cè)試輸出
最后,當(dāng)我們運(yùn)行這組測(cè)試時(shí),同一個(gè)測(cè)試類會(huì)在每個(gè) Locale 下執(zhí)行一次,而每次的格式化結(jié)果都不相同:
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日
可以看到,每一行都對(duì)應(yīng)于一個(gè)調(diào)用上下文。測(cè)試代碼在這些運(yùn)行之間完全沒(méi)有變化;變化的只是由提供者和擴(kuò)展配置出來(lái)的執(zhí)行環(huán)境。
5. 總結(jié)
在本文中,我們從基礎(chǔ)概念出發(fā),進(jìn)一步深入了 @ClassTemplate 的使用方式,重點(diǎn)考察了提供者如何為單個(gè)測(cè)試類提供多個(gè)執(zhí)行上下文。通過(guò) Locale 示例,我們看到提供者和擴(kuò)展可以在不修改測(cè)試代碼的前提下靈活地切換測(cè)試環(huán)境。這使得類模板成為處理全局設(shè)置或配置級(jí)行為測(cè)試的一種干凈而優(yōu)雅的解決方案。
到此這篇關(guān)于JUnit 5 中的 @ClassTemplate 實(shí)戰(zhàn)指南的文章就介紹到這了,更多相關(guān)JUnit 5 @ClassTemplate內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
maven settings.xml文件的存放及配置(包含了配置阿里云鏡像)
本文詳細(xì)解釋了Maven中settings.xml文件的存放位置,以及用戶級(jí)別和全局級(jí)別的區(qū)別,重點(diǎn)介紹了localRepository、交互模式、離線模式、插件組、代理設(shè)置、服務(wù)器認(rèn)證、鏡像列表和激活profiles的使用方法,感興趣的可以了解一下2025-09-09
Springboot實(shí)例講解實(shí)現(xiàn)寵物醫(yī)院管理系統(tǒng)流程
讀萬(wàn)卷書(shū)不如行萬(wàn)里路,只學(xué)書(shū)上的理論是遠(yuǎn)遠(yuǎn)不夠的,只有在實(shí)戰(zhàn)中才能獲得能力的提升,本篇文章手把手帶你用Springboot實(shí)現(xiàn)寵物醫(yī)院綜合管理系統(tǒng),大家可以在過(guò)程中查缺補(bǔ)漏,提升水平2022-06-06
spring boot 防止重復(fù)提交實(shí)現(xiàn)方法詳解
這篇文章主要介紹了spring boot 防止重復(fù)提交實(shí)現(xiàn)方法,結(jié)合實(shí)例形式詳細(xì)分析了spring boot 防止重復(fù)提交具體配置、實(shí)現(xiàn)方法及操作注意事項(xiàng),需要的朋友可以參考下2019-11-11
詳解springboot shiro jwt實(shí)現(xiàn)權(quán)限管理
為什么使用jwt呢,因?yàn)榭梢酝ㄟ^(guò)URL,POST參數(shù)或者在HTTP header發(fā)送,因?yàn)閿?shù)據(jù)量小,傳輸速度也很快。本篇通過(guò)具體代碼來(lái)進(jìn)行詳情解析,對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值2021-09-09
詳解Java中如何定義和訪問(wèn)靜態(tài)字段和方法
在面向?qū)ο蟮某绦蛟O(shè)計(jì)中,我們通常使用類來(lái)創(chuàng)建對(duì)象,類中有許多成員變量和方法,它們是直接屬于對(duì)象實(shí)例的,與之對(duì)應(yīng)的還有一類成員,那就是靜態(tài)成員,本文就來(lái)和大家聊聊Java是如何定義訪問(wèn)靜態(tài)成員的吧2023-05-05
java使用文件流實(shí)現(xiàn)查看下載次數(shù)
這篇文章主要為大家詳細(xì)介紹了java使用文件流實(shí)現(xiàn)查看下載次數(shù),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-07-07

