当你在本地、测试环境和 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()方法完成的:
@OverridepublicbooleansupportsClassTemplate(ExtensionContextcontext){returncontext.getTestClass().map(aClass->aClass.isAnnotationPresent(ClassTemplate.class)).orElse(false);}JUnit 会对每一个已注册的提供者调用这个方法。只有返回true的提供者才会对当前类模板生效。通过这种机制,JUnit 可以避免提供者被意外激活,避免在无关测试上运行扩展,同时也允许多个提供者并存而互不干扰。
3.2.provideClassTemplateInvocationContexts()方法
一旦某个提供者被激活,JUnit 就会调用provideClassTemplateInvocationContexts(),以获取描述模板执行方式的上下文:
@OverridepublicStream<ClassTemplateInvocationContext>provideClassTemplateInvocationContexts(ExtensionContextcontext){returnStream.of(invocationContext("A"),invocationContext("B"));}每一个上下文都代表了一次对测试类的完整执行。单个提供者可以提供一个或多个上下文;如果同时有多个提供者处于激活状态,JUnit 会把它们提供的流拼接起来。每个上下文都可以添加自己的扩展或配置,从而让提供者可以对该次执行的环境进行精细控制。
从这里开始,JUnit 会为每个上下文创建一个新的测试类实例,应用对应的扩展,并完整运行生命周期方法和测试方法各一次。
4. 实用示例
为了更直观地理解这些概念,我们来构造一个示例:编写一个测试,用来验证在多个 JVM Locale 下的日期格式化逻辑。由于 Locale 会影响整个执行环境,这类需求非常适合用类模板来实现。我们只保留一个测试类,然后让提供者在不同配置下多次执行它。
4.1. 日期格式化逻辑
首先,从一个小工具类开始,它使用当前 JVM 默认 Locale 来格式化日期。只要默认 Locale 发生变化,它的输出就会随之改变:
classDateFormatter{publicStringformat(LocalDatedate){DateTimeFormatterformatter=DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.getDefault());returndate.format(formatter);}}有了这个类之后,我们就可以在多种不同的配置下验证它的行为,而这些配置都由类模板来提供。
4.2. 提供者与扩展
为了支撑上述需求,我们首先需要一个扩展,用来在单次执行期间设置默认 Locale:
classLocaleExtensionimplementsBeforeEachCallback,AfterEachCallback{privatefinalLocalelocale;privateLocaleprevious;@OverridepublicvoidbeforeEach(ExtensionContextcontext){previous=Locale.getDefault();Locale.setDefault(locale);}@OverridepublicvoidafterEach(ExtensionContextcontext){Locale.setDefault(previous);}}这个扩展会在每次测试之前暂时替换 JVM 的默认 Locale,并在测试结束后恢复原有值。在不同执行之间唯一变化的,就是传入该扩展的Locale实例。
接下来,提供者会通过provideClassTemplateInvocationContexts()方法来提供不同的上下文。每个上下文都由invocationContext()方法创建,该方法通过getDisplayName()指定显示名,并通过getAdditionalExtensions()安装对应的LocaleExtension:
classDateLocaleClassTemplateProviderimplementsClassTemplateInvocationContextProvider{@OverridepublicStream<ClassTemplateInvocationContext>provideClassTemplateInvocationContexts(ExtensionContextcontext){returnStream.of(Locale.US,Locale.GERMANY,Locale.ITALY,Locale.JAPAN).map(this::invocationContext);}privateClassTemplateInvocationContextinvocationContext(Localelocale){returnnewClassTemplateInvocationContext(){@OverridepublicStringgetDisplayName(intinvocationIndex){return"Locale: "+locale.getDisplayName();}@OverridepublicList<Extension>getAdditionalExtensions(){returnList.of(newLocaleExtension(locale));}};}}通过这样的配置,我们就得到了互不相同的执行环境,最终会对同一个测试类执行四次测试。
4.3. Class Template 测试
此时,类模板的整体配置已经就位,我们就可以专注于编写一个测试方法了。JUnit 会通过前面配置好的提供者,为每个上下文执行一次这个方法:
privatefinalDateFormatterformatter=newDateFormatter();@TestvoidgivenDefaultLocale_whenFormattingDate_thenMatchesLocalizedOutput(){LocalDatedate=LocalDate.of(2025,9,30);DateTimeFormatterexpectedFormatter=DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.getDefault());Stringexpected=date.format(expectedFormatter);Stringformatted=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专题内容。