JUnit 5 详解
一、JUnit 5 架构
1.1 整体架构
JUnit 5 由三个顶层模块组成:
| 模块 | 职责 | Maven Artifact |
|---|---|---|
| JUnit Platform | 测试平台基础,定义 TestEngine SPI,提供 Launcher API | junit-platform-launcher |
| JUnit Jupiter | 新的编程模型和扩展模型,包含注解、断言、引擎 | junit-jupiter |
| JUnit Vintage | 兼容引擎,在 Platform 上运行 JUnit 3/4 测试 | junit-vintage-engine |
1.2 Maven 依赖配置
xml
<dependencies>
<!-- JUnit Jupiter(聚合包,包含 API + Engine + Params) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- 如需运行 JUnit 4 旧测试 -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
1.3 Gradle 配置
groovy
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
二、测试生命周期
2.1 生命周期注解
JUnit 5 提供五个生命周期注解,控制测试执行前后的初始化和清理逻辑:
| 注解 | 执行时机 | 要求 |
|---|---|---|
@BeforeAll |
所有测试方法执行前,仅一次 | 默认需 static |
@BeforeEach |
每个测试方法执行前 | 实例方法 |
@Test |
标记测试方法 | 实例方法 |
@AfterEach |
每个测试方法执行后 | 实例方法 |
@AfterAll |
所有测试方法执行后,仅一次 | 默认需 static |
2.2 执行顺序示例
java
import org.junit.jupiter.api.*;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class LifecycleDemoTest {
@BeforeAll
static void beforeAll() {
System.out.println("1. @BeforeAll - 全局初始化(仅一次)");
}
@BeforeEach
void beforeEach() {
System.out.println("2. @BeforeEach - 每个测试前");
}
@Test
@Order(1)
void testFirst() {
System.out.println("3. @Test - testFirst 执行");
}
@Test
@Order(2)
void testSecond() {
System.out.println("3. @Test - testSecond 执行");
}
@AfterEach
void afterEach() {
System.out.println("4. @AfterEach - 每个测试后");
}
@AfterAll
static void afterAll() {
System.out.println("5. @AfterAll - 全局清理(仅一次)");
}
}
输出顺序:
1. @BeforeAll - 全局初始化(仅一次)
2. @BeforeEach - 每个测试前
3. @Test - testFirst 执行
4. @AfterEach - 每个测试后
2. @BeforeEach - 每个测试前
3. @Test - testSecond 执行
4. @AfterEach - 每个测试后
5. @AfterAll - 全局清理(仅一次)
2.3 @DisplayName 自定义名称
java
@DisplayName("用户服务测试")
class UserServiceTest {
@Test
@DisplayName("当用户名为空时,应抛出 IllegalArgumentException")
void shouldThrowExceptionWhenUsernameIsNull() {
assertThrows(IllegalArgumentException.class,
() -> new UserService().register(null));
}
}
三、断言(Assertions)
3.1 基本断言
java
import static org.junit.jupiter.api.Assertions.*;
class BasicAssertionsTest {
@Test
void testEquals() {
assertEquals(4, 2 + 2);
assertEquals("hello", "hel" + "lo");
// 带自定义失败消息
assertEquals(100, calculateScore(), "分数计算错误");
// 带 Supplier 延迟消息(仅在失败时构造)
assertEquals(100, calculateScore(),
() -> "期望100,实际: " + calculateScore());
}
@Test
void testNotEquals() {
assertNotEquals(5, 2 + 2);
}
@Test
void testBoolean() {
assertTrue(3 > 2);
assertFalse(1 > 2);
}
@Test
void testNull() {
assertNull(null);
assertNotNull("value");
}
@Test
void testSame() {
Object obj = new Object();
assertSame(obj, obj); // 引用相同
assertNotSame(new Object(), new Object()); // 引用不同
}
@Test
void testArray() {
int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
assertArrayEquals(expected, actual);
}
@Test
void testIterable() {
List<String> expected = List.of("a", "b", "c");
List<String> actual = List.of("a", "b", "c");
assertIterableEquals(expected, actual);
}
private int calculateScore() {
return 100;
}
}
3.2 异常断言 assertThrows
java
@Test
void testException() {
// 验证异常类型
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> Integer.parseInt("abc")
);
// 验证异常消息
assertEquals("For input string: \"abc\"", ex.getMessage());
}
@Test
void testExceptionMessage() {
Exception ex = assertThrows(
ArithmeticException.class,
() -> {
int result = 1 / 0;
},
"除零应抛出 ArithmeticException"
);
assertTrue(ex.getMessage().contains("/ by zero"));
}
3.3 组合断言 assertAll
assertAll 会执行所有断言,即使前面的断言失败也不会中断,最终聚合报告所有失败:
java
@Test
void testUserFields() {
User user = userService.findById(1L);
assertAll("用户属性验证",
() -> assertEquals("张三", user.getName(), "姓名不匹配"),
() -> assertEquals(25, user.getAge(), "年龄不匹配"),
() -> assertEquals("zhangsan@example.com", user.getEmail(), "邮箱不匹配"),
() -> assertNotNull(user.getCreateTime(), "创建时间不应为空")
);
}
3.4 超时断言
java
@Test
void testTimeout() {
// 如果执行超过 2 秒则失败(在同一线程中等待完成)
assertTimeout(Duration.ofSeconds(2), () -> {
Thread.sleep(100);
return "done";
});
}
@Test
void testTimeoutPreemptively() {
// 超时后立即中断执行线程
assertTimeoutPreemptively(Duration.ofMillis(500), () -> {
Thread.sleep(100);
return "completed";
});
}
@Test
void testTimeoutWithMessage() {
assertTimeout(Duration.ofSeconds(1), () -> {
// 模拟耗时操作
longRunningTask();
}, "操作超时:应在1秒内完成");
}
四、假设(Assumptions)
4.1 基本概念
假设用于在特定条件不满足时中止测试(标记为 Aborted),而非失败(Failed)。适用于环境依赖型测试。
4.2 基本用法
java
import static org.junit.jupiter.api.Assumptions.*;
class AssumptionsTest {
@Test
void testOnlyOnLinux() {
assumeTrue(
System.getProperty("os.name").toLowerCase().contains("linux"),
"仅在 Linux 环境运行"
);
// 以下代码仅在 Linux 上执行
assertTrue(new File("/etc/passwd").exists());
}
@Test
void testOnlyWhenDbAvailable() {
assumeFalse(isDatabaseOffline(), "数据库离线时跳过");
// 数据库相关测试逻辑
assertNotNull(dataSource.getConnection());
}
@Test
void testAssumingThat() {
// 条件满足时执行 lambda,不满足则跳过该部分(测试继续)
assumingThat(
"CI".equals(System.getenv("ENV")),
() -> {
// 仅在 CI 环境执行的验证
assertEquals(4, 2 + 2);
}
);
// 无论环境如何都会执行
assertTrue(3 > 1);
}
private boolean isDatabaseOffline() {
return false;
}
}
4.3 假设 vs 断言
| 特性 | 假设(Assumption) | 断言(Assertion) |
|---|---|---|
| 失败结果 | Aborted(中止) | Failed(失败) |
| 语义 | 前置条件不满足,跳过 | 预期行为不满足,报错 |
| 典型场景 | 环境依赖、平台限制 | 业务逻辑验证 |
五、参数化测试
5.1 基本结构
参数化测试通过 @ParameterizedTest + 数据源注解实现同一测试逻辑的多组数据验证。
xml
<!-- 需要额外依赖(junit-jupiter 聚合包已包含) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
5.2 @ValueSource
适用于单参数场景,支持 int、long、double、String、Class 等类型:
java
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"racecar", "madam", "level"})
void testPalindrome(String candidate) {
assertTrue(isPalindrome(candidate));
}
@ParameterizedTest
@ValueSource(doubles = {1.1, 2.2, 3.3})
void testDoubleRange(double value) {
assertTrue(value > 1.0 && value < 4.0);
}
private boolean isPalindrome(String s) {
return new StringBuilder(s).reverse().toString().equals(s);
}
5.3 @CsvSource
适用于多参数场景,以 CSV 格式提供参数:
java
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, a + b);
}
@ParameterizedTest
@CsvSource(value = {
"张三:25:北京",
"李四:30:上海",
"王五:28:广州"
}, delimiter = ':')
void testUserCreation(String name, int age, String city) {
User user = new User(name, age, city);
assertAll(
() -> assertEquals(name, user.getName()),
() -> assertEquals(age, user.getAge()),
() -> assertEquals(city, user.getCity())
);
}
// 使用 null 值
@ParameterizedTest
@CsvSource({
"apple, 1",
"banana, 2",
"'', 3" // 空字符串表示 null
})
void testWithNull(String fruit, int quantity) {
if (fruit == null) {
assertNull(fruit);
} else {
assertNotNull(fruit);
}
}
5.4 @MethodSource
通过工厂方法提供复杂参数:
java
@ParameterizedTest
@MethodSource("provideStrings")
void testWithMethodSource(String input, boolean expected) {
assertEquals(expected, StringUtils.isNotBlank(input));
}
// 工厂方法必须是 static(除非使用 PER_CLASS 生命周期)
static Stream<Arguments> provideStrings() {
return Stream.of(
Arguments.of("hello", true),
Arguments.of("", false),
Arguments.of(" ", false),
Arguments.of(null, false)
);
}
// 多参数复杂对象
@ParameterizedTest
@MethodSource("provideUsers")
void testUserValidation(User user, boolean valid) {
assertEquals(valid, userValidator.validate(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("张三", 25, "a@b.com"), true),
Arguments.of(new User("", 25, "a@b.com"), false),
Arguments.of(new User("李四", -1, "a@b.com"), false),
Arguments.of(new User("王五", 30, "invalid"), false)
);
}
5.5 @EnumSource
java
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
@ParameterizedTest
@EnumSource(Season.class)
void testAllSeasons(Season season) {
assertNotNull(season.name());
}
// 排除某些枚举值
@ParameterizedTest
@EnumSource(value = Season.class, names = {"WINTER"}, mode = EnumSource.Mode.EXCLUDE)
void testNonWinterSeasons(Season season) {
assertNotEquals(Season.WINTER, season);
}
// 正则匹配
@ParameterizedTest
@EnumSource(value = Season.class, names = "^S.*", mode = EnumSource.Mode.MATCH_ALL)
void testSeasonsStartingWithS(Season season) {
assertTrue(season.name().startsWith("S"));
}
5.6 @CsvFileSource
java
@ParameterizedTest
@CsvFileSource(resources = "/test-data/users.csv", numLinesToSkip = 1)
void testFromCsvFile(String name, int age, String email) {
User user = new User(name, age, email);
assertTrue(user.getAge() > 0);
assertTrue(user.getEmail().contains("@"));
}
/test-data/users.csv:
csv
name,age,email
张三,25,zhangsan@example.com
李四,30,lisi@example.com
5.7 自定义参数转换
java
@ParameterizedTest
@CsvSource({"2024-01-01, 2024", "2023-06-15, 2023"})
void testDateParsing(
@ConvertWith(DateFormatConverter.class) LocalDate date,
int expectedYear) {
assertEquals(expectedYear, date.getYear());
}
// 自定义转换器
public class DateFormatConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) {
return LocalDate.parse((String) source, DateTimeFormatter.ISO_LOCAL_DATE);
}
}
六、嵌套测试(@Nested)
6.1 基本用法
@Nested 允许通过内部类组织相关测试,形成层次化结构:
java
@DisplayName("Stack 测试")
class StackTest {
private Stack<String> stack;
@BeforeEach
void setUp() {
stack = new Stack<>();
}
@Nested
@DisplayName("当 Stack 为空时")
class WhenNew {
@Test
@DisplayName("size 应返回 0")
void shouldReturnZeroSize() {
assertEquals(0, stack.size());
}
@Test
@DisplayName("pop 应抛出 EmptyStackException")
void shouldThrowOnPop() {
assertThrows(EmptyStackException.class, () -> stack.pop());
}
@Test
@DisplayName("peek 应抛出 EmptyStackException")
void shouldThrowOnPeek() {
assertThrows(EmptyStackException.class, () -> stack.peek());
}
}
@Nested
@DisplayName("当 Stack 有一个元素时")
class WithOneElement {
@BeforeEach
void pushElement() {
stack.push("first");
}
@Test
@DisplayName("size 应返回 1")
void shouldReturnOneSize() {
assertEquals(1, stack.size());
}
@Test
@DisplayName("peek 应返回栈顶元素")
void shouldReturnTopElement() {
assertEquals("first", stack.peek());
}
@Test
@DisplayName("pop 后应为空")
void shouldBeEmptyAfterPop() {
stack.pop();
assertTrue(stack.isEmpty());
}
}
@Nested
@DisplayName("当 Stack 有多个元素时")
class WithMultipleElements {
@BeforeEach
void pushElements() {
stack.push("first");
stack.push("second");
stack.push("third");
}
@Test
@DisplayName("应遵循 LIFO 顺序")
void shouldFollowLifo() {
assertEquals("third", stack.pop());
assertEquals("second", stack.pop());
assertEquals("first", stack.pop());
}
}
}
6.2 嵌套规则
@Nested类必须是非 static 内部类- 可以访问外部类的实例字段和方法
- 外部类的
@BeforeEach/@AfterEach对嵌套类同样生效 - 支持多层嵌套
- 嵌套类中不能 声明
@BeforeAll/@AfterAll(除非使用 PER_CLASS 生命周期)
七、条件测试
7.1 @Disabled
java
@Test
@Disabled("功能尚未实现,等待 v2.0 发布")
void testFutureFeature() {
// 此测试不会执行
}
@Disabled("整个类暂时禁用")
class DisabledClassTest {
@Test
void testSomething() { }
}
7.2 操作系统条件
java
@Test
@EnabledOnOs(OS.WINDOWS)
void testWindowsOnly() {
assertTrue(System.getProperty("os.name").contains("Windows"));
}
@Test
@EnabledOnOs({OS.LINUX, OS.MAC})
void testUnixLike() {
assertTrue(new File("/tmp").exists());
}
@Test
@DisabledOnOs(OS.WINDOWS)
void testNotOnWindows() {
// 在非 Windows 系统执行
}
7.3 JRE 版本条件
java
@Test
@EnabledOnJre(JRE.JAVA_17)
void testJava17Only() {
// 仅在 Java 17 上执行
}
@Test
@EnabledForJreRange(min = JRE.JAVA_11, max = JRE.JAVA_21)
void testJava11To21() {
// Java 11 ~ 21 范围内执行
}
@Test
@DisabledOnJre(JRE.JAVA_8)
void testNotOnJava8() {
// Java 8 上不执行
}
7.4 系统属性和环境变量条件
java
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void testOnlyOn64Bit() {
// 64位架构执行
}
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void testOnlyInCI() {
// 仅在 CI 环境执行
}
@Test
@DisabledIfSystemProperty(named = "java.vm.name", matches = ".*GraalVM.*")
void testNotOnGraalVM() {
// 非 GraalVM 执行
}
7.5 自定义条件 @EnabledIf / @DisabledIf
java
@Test
@EnabledIf("isDatabaseAvailable")
void testWithDatabase() {
// 仅在数据库可用时执行
}
boolean isDatabaseAvailable() {
try {
DriverManager.getConnection("jdbc:h2:mem:test");
return true;
} catch (SQLException e) {
return false;
}
}
// 基于类的条件(JUnit 5.7+)
@Test
@EnabledIf(CustomCondition.class)
void testWithCustomCondition() {
// 自定义条件类
}
class CustomCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
boolean enabled = "prod".equals(System.getProperty("app.env"));
return enabled
? ConditionEvaluationResult.enabled("生产环境")
: ConditionEvaluationResult.disabled("非生产环境,跳过");
}
}
八、扩展模型(Extension API)
8.1 扩展模型概述
JUnit 5 的扩展模型取代了 JUnit 4 的 Runner/Rule 机制,提供统一的扩展点:
| 扩展接口 | 触发时机 |
|---|---|
BeforeAllCallback |
所有测试前 |
AfterAllCallback |
所有测试后 |
BeforeEachCallback |
每个测试前 |
AfterEachCallback |
每个测试后 |
BeforeTestExecutionCallback |
测试方法执行前(@BeforeEach 之后) |
AfterTestExecutionCallback |
测试方法执行后(@AfterEach 之前) |
ParameterResolver |
参数注入 |
TestExecutionExceptionHandler |
异常处理 |
TestInstancePostProcessor |
实例创建后 |
8.2 注册方式
java
// 方式一:声明式(@ExtendWith)
@ExtendWith(TimingExtension.class)
class MyTest { }
// 方式二:编程式(@RegisterExtension)
class MyTest {
@RegisterExtension
static TimingExtension timing = new TimingExtension();
}
// 方式三:ServiceLoader 自动发现
// META-INF/services/org.junit.jupiter.api.extension.Extension
// com.example.TimingExtension
// 需配置:junit.jupiter.extensions.autodetection.enabled=true
8.3 ParameterResolver 示例
java
// 自定义扩展:自动注入当前测试的临时目录
public class TempDirExtension implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return parameterContext.getParameter().getType() == Path.class
&& parameterContext.isAnnotated(InjectTempDir.class);
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
try {
return Files.createTempDirectory("junit5-test");
} catch (IOException e) {
throw new ParameterResolutionException("创建临时目录失败", e);
}
}
}
// 自定义注解
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface InjectTempDir { }
// 使用
@ExtendWith(TempDirExtension.class)
class FileServiceTest {
@Test
void testFileWrite(@InjectTempDir Path tempDir) throws IOException {
Path file = tempDir.resolve("test.txt");
Files.writeString(file, "hello");
assertEquals("hello", Files.readString(file));
}
}
8.4 生命周期回调示例
java
public class TimingExtension implements BeforeTestExecutionCallback,
AfterTestExecutionCallback {
@Override
public void beforeTestExecution(ExtensionContext context) {
getStore(context).put("start", System.nanoTime());
}
@Override
public void afterTestExecution(ExtensionContext context) {
long start = getStore(context).remove("start", long.class);
long duration = System.nanoTime() - start;
String methodName = context.getRequiredTestMethod().getName();
System.out.printf("[%s] 执行耗时: %.2f ms%n",
methodName, duration / 1_000_000.0);
}
private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(
ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()));
}
}
@ExtendWith(TimingExtension.class)
class PerformanceTest {
@Test
void testSlowOperation() throws InterruptedException {
Thread.sleep(100);
}
}
九、自定义扩展
9.1 数据库事务回滚扩展
java
public class TransactionalTestExtension implements BeforeEachCallback,
AfterEachCallback {
private static final ExtensionContext.Namespace NS =
ExtensionContext.Namespace.create(TransactionalTestExtension.class);
@Override
public void beforeEach(ExtensionContext context) throws Exception {
Connection conn = DataSourceProvider.getConnection();
conn.setAutoCommit(false);
getStore(context).put("connection", conn);
}
@Override
public void afterEach(ExtensionContext context) throws Exception {
Connection conn = getStore(context).remove("connection", Connection.class);
if (conn != null) {
conn.rollback();
conn.setAutoCommit(true);
conn.close();
}
}
private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(NS);
}
}
// 使用
@ExtendWith(TransactionalTestExtension.class)
class UserRepositoryTest {
@Test
void testSaveUser() {
userRepository.save(new User("测试用户"));
assertEquals(1, userRepository.count());
// 测试结束后自动回滚,不污染数据库
}
}
9.2 重试扩展
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int value() default 3;
}
public class RetryExtension implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(ExtensionContext context,
Throwable throwable) throws Throwable {
Method method = context.getRequiredTestMethod();
Retry retry = method.getAnnotation(Retry.class);
if (retry == null) {
throw throwable; // 无注解,直接抛出
}
int maxAttempts = retry.value();
for (int attempt = 2; attempt <= maxAttempts; attempt++) {
try {
// 重新调用测试方法
method.invoke(context.getRequiredTestInstance());
return; // 成功则返回
} catch (InvocationTargetException e) {
if (attempt == maxAttempts) {
throw e.getCause(); // 最后一次仍失败
}
}
}
}
}
// 使用
@ExtendWith(RetryExtension.class)
class FlakyServiceTest {
@Test
@Retry(3)
void testUnstableNetworkCall() {
// 可能因网络波动失败,最多重试3次
String result = httpClient.get("https://api.example.com/data");
assertNotNull(result);
}
}
十、测试实例生命周期
10.1 PER_METHOD(默认)
每个测试方法创建一个新的测试类实例,测试间完全隔离:
java
// 默认行为,无需显式声明
@TestInstance(TestInstance.Lifecycle.PER_METHOD)
class PerMethodTest {
private int counter = 0;
@Test
void testOne() {
counter++;
assertEquals(1, counter); // 始终为1(新实例)
}
@Test
void testTwo() {
counter++;
assertEquals(1, counter); // 同样为1(另一个新实例)
}
}
10.2 PER_CLASS
所有测试方法共享同一个实例:
java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
private int counter = 0;
@BeforeAll
void beforeAll() {
// PER_CLASS 模式下 @BeforeAll 无需 static
System.out.println("初始化(非 static)");
}
@Test
void testOne() {
counter++;
assertEquals(1, counter);
}
@Test
void testTwo() {
counter++;
// 注意:执行顺序不确定,此处可能为2
assertTrue(counter >= 1);
}
@AfterAll
void afterAll() {
// 无需 static
System.out.println("清理,counter = " + counter);
}
}
10.3 全局配置
在 src/test/resources/junit-platform.properties 中配置:
properties
# 全局默认生命周期
junit.jupiter.testinstance.lifecycle.default=per_class
# 启用扩展自动发现
junit.jupiter.extensions.autodetection.enabled=true
# 测试方法排序
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$OrderAnnotation
十一、动态测试(@TestFactory / DynamicTest)
11.1 基本动态测试
java
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.util.stream.Stream;
class DynamicTestDemo {
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
List<String> inputs = List.of("racecar", "madam", "hello", "level");
List<Boolean> expected = List.of(true, true, false, true);
return inputs.stream()
.map(input -> DynamicTest.dynamicTest(
"回文检测: " + input,
() -> assertEquals(
expected.get(inputs.indexOf(input)),
isPalindrome(input)
)
));
}
@TestFactory
Collection<DynamicTest> dynamicTestsFromCollection() {
return List.of(
DynamicTest.dynamicTest("加法: 1+1=2",
() -> assertEquals(2, 1 + 1)),
DynamicTest.dynamicTest("加法: 2+3=5",
() -> assertEquals(5, 2 + 3)),
DynamicTest.dynamicTest("乘法: 3*4=12",
() -> assertEquals(12, 3 * 4))
);
}
private boolean isPalindrome(String s) {
return new StringBuilder(s).reverse().toString().equals(s);
}
}
11.2 动态容器(DynamicContainer)
java
@TestFactory
Stream<DynamicNode> dynamicTestsWithContainers() {
Map<String, List<String>> testData = Map.of(
"水果", List.of("apple", "banana", "cherry"),
"蔬菜", List.of("carrot", "broccoli")
);
return testData.entrySet().stream()
.map(entry -> DynamicContainer.dynamicContainer(
entry.getKey() + " 测试",
entry.getValue().stream()
.map(item -> DynamicTest.dynamicTest(
"验证: " + item,
() -> assertFalse(item.isEmpty())
))
));
}
11.3 基于数据库的动态测试
java
@TestFactory
Stream<DynamicTest> dynamicTestsFromDatabase() {
return userRepository.findAll().stream()
.map(user -> DynamicTest.dynamicTest(
"验证用户: " + user.getName(),
() -> {
assertNotNull(user.getEmail());
assertTrue(user.getAge() > 0);
}
));
}
十二、与 Spring Boot Test 集成
12.1 基本配置
Spring Boot 2.2+ 默认使用 JUnit 5:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
12.2 @SpringBootTest 全量集成测试
java
@SpringBootTest
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void testCreateAndFindUser() {
User created = userService.createUser(new UserDTO("张三", "zhang@test.com"));
assertNotNull(created.getId());
User found = userService.findById(created.getId());
assertEquals("张三", found.getName());
}
}
12.3 切片测试
java
// Web 层测试
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void testGetUser() throws Exception {
when(userService.findById(1L))
.thenReturn(new User(1L, "张三", "zhang@test.com"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("zhang@test.com"));
}
@Test
void testCreateUserValidation() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\",\"email\":\"invalid\"}"))
.andExpect(status().isBadRequest());
}
}
// 数据层测试
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void testFindByName() {
entityManager.persist(new User("张三", "zhang@test.com"));
entityManager.flush();
Optional<User> found = userRepository.findByName("张三");
assertTrue(found.isPresent());
assertEquals("zhang@test.com", found.get().getEmail());
}
}
12.4 @TestConfiguration
java
@SpringBootTest
@Import(TestConfig.class)
class ServiceWithMockDependencyTest {
@Autowired
private OrderService orderService;
@Test
void testPlaceOrder() {
OrderResult result = orderService.placeOrder(new OrderRequest("item-1", 2));
assertTrue(result.isSuccess());
}
}
@TestConfiguration
class TestConfig {
@Bean
@Primary
public PaymentGateway mockPaymentGateway() {
return new MockPaymentGateway(); // 测试用模拟实现
}
}
十三、与 Mockito 集成
13.1 基本集成
java
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
@Test
void testPlaceOrderSuccess() {
// Arrange
Order order = new Order("item-1", 2, new BigDecimal("99.9"));
when(paymentService.charge(any())).thenReturn(PaymentResult.success());
when(orderRepository.save(any())).thenAnswer(inv -> {
Order saved = inv.getArgument(0);
saved.setId(1L);
return saved;
});
// Act
OrderResult result = orderService.placeOrder(order);
// Assert
assertTrue(result.isSuccess());
assertEquals(1L, result.getOrderId());
verify(paymentService, times(1)).charge(any());
verify(orderRepository, times(1)).save(any());
}
@Test
void testPlaceOrderPaymentFailed() {
when(paymentService.charge(any()))
.thenReturn(PaymentResult.failure("余额不足"));
OrderResult result = orderService.placeOrder(
new Order("item-1", 1, new BigDecimal("999")));
assertFalse(result.isSuccess());
assertEquals("余额不足", result.getMessage());
verify(orderRepository, never()).save(any());
}
}
13.2 @Spy 部分模拟
java
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Spy
private EmailTemplateEngine templateEngine = new EmailTemplateEngine();
@Mock
private SmtpClient smtpClient;
@InjectMocks
private NotificationService notificationService;
@Test
void testSendWelcomeEmail() {
// templateEngine 使用真实逻辑,smtpClient 被模拟
notificationService.sendWelcome("user@test.com", "张三");
verify(smtpClient).send(
eq("user@test.com"),
eq("欢迎加入"),
contains("张三")
);
}
}
13.3 Mockito 严格模式
java
// MockitoExtension 默认使用 STRICT_STUBS
// 未使用的 stub 会报 UnnecessaryStubbingException
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class LenientMockTest {
@Mock
private SomeService service;
@Test
void testWithLenientStub() {
// 即使此 stub 未被使用也不会报错
when(service.getData()).thenReturn("data");
// 测试其他逻辑...
}
}
十四、测试覆盖率(JaCoCo)
14.1 Maven 配置
xml
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<!-- 准备 agent -->
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- 生成报告 -->
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- 覆盖率检查 -->
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
14.2 Gradle 配置
groovy
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.11"
}
test {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.80
}
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.70
}
}
}
}
14.3 排除特定类
xml
<configuration>
<excludes>
<exclude>**/config/**</exclude>
<exclude>**/dto/**</exclude>
<exclude>**/entity/**</exclude>
<exclude>**/*Application.*</exclude>
</excludes>
</configuration>
14.4 运行与报告
bash
# Maven
mvn clean test jacoco:report
# 报告位置
# target/site/jacoco/index.html
# Gradle
./gradlew test jacocoTestReport
# 报告位置
# build/reports/jacoco/test/html/index.html
十五、最佳实践
15.1 测试命名规范
java
// 推荐:should_期望行为_when_条件
class OrderServiceTest {
@Test
void shouldReturnOrder_whenOrderExists() { }
@Test
void shouldThrowNotFoundException_whenOrderIdInvalid() { }
@Test
void shouldCalculateDiscount_whenUserIsVip() { }
}
// 或使用 @DisplayName 提供可读描述
@Test
@DisplayName("当库存不足时,下单应抛出 InsufficientStockException")
void shouldThrowWhenStockInsufficient() { }
15.2 AAA 模式(Arrange-Act-Assert)
java
@Test
void shouldApplyDiscountForVipUser() {
// Arrange - 准备测试数据和环境
User vipUser = new User("张三", UserType.VIP);
Order order = new Order(vipUser, new BigDecimal("100"));
// Act - 执行被测操作
BigDecimal finalPrice = pricingService.calculate(order);
// Assert - 验证结果
assertEquals(new BigDecimal("80.00"), finalPrice);
}
15.3 测试独立性
java
// 错误:测试间存在依赖
class BadExample {
private static Long createdId;
@Test
@Order(1)
void create() { createdId = service.create(...); }
@Test
@Order(2)
void delete() { service.delete(createdId); } // 依赖 create 的结果
}
// 正确:每个测试独立
class GoodExample {
@Test
void shouldCreateUser() {
User user = service.create(new UserDTO("张三"));
assertNotNull(user.getId());
}
@Test
void shouldDeleteUser() {
Long id = service.create(new UserDTO("李四")).getId(); // 自行准备数据
service.delete(id);
assertThrows(NotFoundException.class, () -> service.findById(id));
}
}
15.4 合理使用 @Nested 组织
java
@DisplayName("ShoppingCart")
class ShoppingCartTest {
@Nested
@DisplayName("addItem")
class AddItem {
@Test void shouldIncreaseQuantity() { }
@Test void shouldThrowWhenItemNull() { }
@Test void shouldThrowWhenQuantityExceedsLimit() { }
}
@Nested
@DisplayName("removeItem")
class RemoveItem {
@Test void shouldDecreaseQuantity() { }
@Test void shouldRemoveCompletelyWhenQuantityZero() { }
}
@Nested
@DisplayName("checkout")
class Checkout {
@Test void shouldCalculateTotal() { }
@Test void shouldApplyCoupon() { }
@Test void shouldThrowWhenCartEmpty() { }
}
}
15.5 参数化减少重复
java
// 避免:多个相似测试
@Test void testAdd1Plus1() { assertEquals(2, calc.add(1, 1)); }
@Test void testAdd2Plus3() { assertEquals(5, calc.add(2, 3)); }
@Test void testAdd0Plus0() { assertEquals(0, calc.add(0, 0)); }
// 推荐:参数化
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({"1,1,2", "2,3,5", "0,0,0", "-1,1,0"})
void testAdd(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
15.6 扩展优于继承
java
// 避免:通过继承共享测试逻辑
abstract class BaseIntegrationTest {
// 大量共享逻辑,子类难以理解
}
// 推荐:通过扩展组合
@ExtendWith(DatabaseExtension.class)
@ExtendWith(TimingExtension.class)
@ExtendWith(CleanupExtension.class)
class MyIntegrationTest {
// 清晰的测试逻辑,扩展各司其职
}
15.7 测试数据管理
java
// 使用 Builder 模式构造测试数据
class UserFixture {
static User.Builder aDefaultUser() {
return User.builder()
.name("默认用户")
.email("default@test.com")
.age(25)
.status(UserStatus.ACTIVE);
}
}
@Test
void testWithFixture() {
User vip = UserFixture.aDefaultUser()
.name("VIP用户")
.type(UserType.VIP)
.build();
assertTrue(pricingService.hasDiscount(vip));
}
15.8 常见反模式
| 反模式 | 问题 | 改进 |
|---|---|---|
| 测试实现细节 | 重构即破坏测试 | 测试行为和输出 |
| 一个测试多个断言无关逻辑 | 失败时难以定位 | 单一职责 |
| 使用 Thread.sleep 等待 | 不稳定、慢 | 使用 Awaitility 或超时断言 |
| 共享可变状态 | 测试顺序敏感 | 每个测试独立准备数据 |
| 忽略异常测试 | 遗漏边界情况 | 使用 assertThrows 覆盖 |
| 过度 Mock | 测试无实际价值 | 集成测试验证真实交互 |
