测试分层与 JUnit 5 实战
一、测试金字塔
测试金字塔(Test Pyramid)由 Mike Cohn 提出,是一种指导测试策略的分层模型,从上到下依次为 UI 测试、接口测试、集成测试和单元测试。不同层级的测试在数量、执行速度、维护成本和反馈周期上存在显著差异。
1.1 四层测试架构
/\
/ \
/ UI \
/ 测试 \
/----------\
/ 接口测试 \
/--------------\
/ 集成测试 \
/------------------\
/ 单元测试 \
/----------------------\1.2 各层比例建议
| 测试层级 | 推荐比例 | 执行速度 | 维护成本 | 反馈周期 |
|---|---|---|---|---|
| 单元测试 | 70% | 毫秒级 | 低 | 实时 |
| 集成测试 | 20% | 秒级 | 中 | 分钟级 |
| 接口测试 | 7% | 秒到分级 | 中高 | 分钟级 |
| UI 测试 | 3% | 分级 | 高 | 小时级 |
1.3 各层测试关注点
单元测试
- 验证单个方法或类的行为是否符合预期
- 隔离外部依赖(数据库、网络、文件系统)
- 覆盖边界条件、异常路径和正常路径
- 追求高代码覆盖率(建议 80%+)
集成测试
- 验证多个模块之间的协作是否正确
- 测试数据访问层与数据库的交互
- 验证服务层与外部组件的集成
- 关注组件间的契约是否一致
接口测试
- 验证 HTTP API 的请求/响应是否符合契约
- 测试鉴权、参数校验、错误处理
- 验证接口的幂等性和状态码
- 关注端到端的业务场景
UI 测试
- 验证用户界面的渲染和交互
- 覆盖关键用户旅程(Happy Path)
- 测试浏览器兼容性和响应式布局
- 通常作为 Smoke Test 使用
二、JUnit 5 架构
JUnit 5 由三个子模块组成,分别是 JUnit Platform、JUnit Jupiter 和 JUnit Vintage。
2.1 三层架构概览
+--------------------------------------------------+
| JUnit 5 |
| +--------------------------------------------+ |
| | JUnit Jupiter | |
| | (编程模型 + 扩展模型,基于 Java 8+) | |
| +--------------------------------------------+ |
| +--------------------------------------------+ |
| | JUnit Vintage | |
| | (向后兼容 JUnit 4/3 的测试引擎) | |
| +--------------------------------------------+ |
| +--------------------------------------------+ |
| | JUnit Platform | |
| | (测试发现/执行引擎的启动基础) | |
| +--------------------------------------------+ |
+--------------------------------------------------+2.2 JUnit Platform
JUnit Platform 是整个生态的基础,提供了一套 SPI(Service Provider Interface),允许不同的测试引擎插拔运行。
- TestEngine API:定义测试引擎的发现和执行契约
- Launcher API:通过编程方式启动测试,适用于 IDE、构建工具和自定义运行器
- Console Launcher:命令行方式启动测试
- Surefire / Failsafe 集成:Maven/Gradle 原生支持
依赖引入(Maven):
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>2.3 JUnit Jupiter
JUnit Jupiter 是 JUnit 5 的核心模块,提供了全新的编程模型和扩展模型。
- 基于 Java 8+ 的注解和 Lambda 表达式
- 全新的断言和假设 API
- 参数化测试、动态测试、嵌套测试等高级特性
- 可扩展的 Extension 模型
依赖引入:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>junit-jupiter 是一个聚合 artifact,包含了 junit-jupiter-api(API)、junit-jupiter-engine(引擎实现)和 junit-jupiter-params(参数化测试支持)。
2.4 JUnit Vintage
JUnit Vintage 提供向后兼容能力,允许在 JUnit 5 平台上运行 JUnit 4 和 JUnit 3 的测试。
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>2.5 Maven 依赖配置
推荐使用 Maven 的 BOM 管理版本,避免版本冲突:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<!-- 如需要运行 JUnit 4 测试 -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
</dependency>
</dependencies>三、核心注解
JUnit 5 提供了丰富的注解体系,用于声明测试方法、控制生命周期和配置测试行为。
3.1 测试方法注解
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CoreAnnotationExampleTest {
@Test
@DisplayName("基础测试:两数相加")
void basicTest() {
Calculator calculator = new Calculator();
int result = calculator.add(1, 2);
org.junit.jupiter.api.Assertions.assertEquals(3, result);
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
@DisplayName("参数化测试:多种输入")
void parameterizedTest(int number) {
org.junit.jupiter.api.Assertions.assertTrue(number > 0);
}
@RepeatedTest(value = 3, name = "重复测试 {currentRepetition}/{totalRepetitions}")
@DisplayName("重复测试示例")
void repeatedTest() {
System.out.println("执行重复测试");
}
}3.2 生命周期注解
import org.junit.jupiter.api.*;
class LifecycleExampleTest {
@BeforeAll
static void initAll() {
System.out.println("在所有测试方法之前执行一次(必须为 static)");
}
@AfterAll
static void tearDownAll() {
System.out.println("在所有测试方法之后执行一次(必须为 static)");
}
@BeforeEach
void init() {
System.out.println("在每个测试方法之前执行");
}
@AfterEach
void tearDown() {
System.out.println("在每个测试方法之后执行");
}
@Test
void testOne() {
System.out.println("测试方法一");
}
@Test
void testTwo() {
System.out.println("测试方法二");
}
}执行顺序:
initAll (BeforeAll)
├─ init (BeforeEach)
├─ testOne
├─ tearDown (AfterEach)
├─ init (BeforeEach)
├─ testTwo
├─ tearDown (AfterEach)
tearDownAll (AfterAll)3.3 @DisplayName 与 @Disabled
import org.junit.jupiter.api.*;
@DisplayName("用户服务测试套件")
class UserServiceTest {
@Test
@DisplayName("创建用户 - 正常流程")
void createUser_success() {
// ...
}
@Test
@DisplayName("创建用户 - 邮箱已存在")
void createUser_emailExists() {
// ...
}
@Test
@Disabled("待实现:TICKET-1234")
@DisplayName("创建用户 - 手机号格式校验")
void createUser_invalidPhone() {
// TODO: 等待手机号校验功能完成
}
@Test
@Disabled("因环境配置问题暂时跳过")
void someFlakyTest() {
// ...
}
}3.4 @Tag 标签
用于在测试类或测试方法上声明标签,配合 Maven/Gradle 进行过滤执行。
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@Tag("slow")
class IntegrationTestSuite {
@Test
@Tag("database")
void databaseConnectionTest() {
// 慢速的数据库测试
}
@Test
@Tag("network")
void externalApiTest() {
// 需要外部网络的测试
}
}
@Tag("fast")
class UnitTestSuite {
@Test
void quickUnitTest() {
// 快速的单元测试
}
}Maven 配置过滤:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>fast</groups>
<excludedGroups>slow</excludedGroups>
</configuration>
</plugin>四、断言
JUnit 5 提供了 org.junit.jupiter.api.Assertions 工具类,包含丰富的静态断言方法。同时可以结合 AssertJ 等第三方库获得更流畅的链式断言体验。
4.1 标准断言
import static org.junit.jupiter.api.Assertions.*;
class AssertionExampleTest {
@Test
@DisplayName("assertEquals 基础断言")
void testAssertEquals() {
assertEquals(4, 2 + 2);
assertEquals(4, 2 + 2, "加法结果应该是 4"); // 带错误信息
assertEquals(3.1415, Math.PI, 0.001); // delta 精度比较
assertEquals("hello", "HELLO".toLowerCase(), () -> "可延迟计算错误信息");
}
@Test
@DisplayName("assertNotEquals 不等断言")
void testAssertNotEquals() {
assertNotEquals(5, 2 + 2);
}
@Test
@DisplayName("assertTrue / assertFalse 布尔断言")
void testAssertBoolean() {
assertTrue(10 > 5);
assertFalse(10 < 5, "10 不可能小于 5");
}
@Test
@DisplayName("assertNull / assertNotNull 空值断言")
void testAssertNull() {
Object obj = null;
assertNull(obj);
assertNotNull("not null");
}
@Test
@DisplayName("assertSame / assertNotSame 引用断言")
void testAssertSame() {
String a = "hello";
String b = "hello";
assertSame(a, b); // 字符串常量池中的同一对象
String c = new String("hello");
assertNotSame(a, c); // 不同对象
}
}4.2 异常断言
import static org.junit.jupiter.api.Assertions.*;
class ExceptionAssertionTest {
@Test
@DisplayName("assertThrows 断言抛出异常")
void testAssertThrows() {
// 断言抛出指定类型异常,返回异常实例
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> {
throw new IllegalArgumentException("参数不合法");
}
);
// 验证异常信息
assertEquals("参数不合法", exception.getMessage());
}
@Test
@DisplayName("assertDoesNotThrow 断言不抛出异常")
void testAssertDoesNotThrow() {
assertDoesNotThrow(() -> {
int result = 1 + 1;
});
assertDoesNotThrow(() -> {
int result = 1 + 1;
}, "这段代码不会抛出异常");
}
// 自定义异常类
static class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String message) {
super(message);
}
}
@Test
@DisplayName("业务异常断言")
void testBusinessException() {
InsufficientBalanceException ex = assertThrows(
InsufficientBalanceException.class,
() -> withdrawBalance(10, 100)
);
assertTrue(ex.getMessage().contains("余额不足"));
}
void withdrawBalance(double amount, double balance) {
if (amount > balance) {
throw new InsufficientBalanceException("余额不足,当前余额: " + balance);
}
}
}4.3 超时断言
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
class TimeoutAssertionTest {
@Test
@DisplayName("assertTimeout 超时断言(不会强制中断)")
void testTimeout() {
String result = assertTimeout(
Duration.ofSeconds(2),
() -> {
Thread.sleep(1000);
return "任务完成";
}
);
assertEquals("任务完成", result);
}
@Test
@DisplayName("assertTimeoutPreemptively 超时断言(会强制中断)")
void testTimeoutPreemptively() {
String result = assertTimeoutPreemptively(
Duration.ofMillis(500),
() -> {
Thread.sleep(100);
return "快速任务";
}
);
assertEquals("快速任务", result);
}
}4.4 assertAll 软断言
import static org.junit.jupiter.api.Assertions.*;
class SoftAssertionTest {
@Test
@DisplayName("assertAll 软断言:聚合所有失败")
void testAssertAll() {
User user = new User("张三", 25, "zhangsan@example.com");
// 所有断言都会执行,最后一起报告失败
assertAll("用户属性验证",
() -> assertEquals("张三", user.getName()),
() -> assertTrue(user.getAge() >= 18, "年龄应不小于 18"),
() -> assertNotNull(user.getEmail()),
() -> assertTrue(user.getEmail().contains("@"))
);
}
@Test
@DisplayName("嵌套 assertAll")
void testNestedAssertAll() {
Address address = new Address("北京市", "海淀区");
User user = new User("李四", 30, "lisi@example.com", address);
assertAll("用户完整信息",
() -> assertEquals("李四", user.getName()),
() -> assertAll("地址信息",
() -> assertEquals("北京市", user.getAddress().getCity()),
() -> assertEquals("海淀区", user.getAddress().getDistrict())
)
);
}
static class User {
private String name;
private int age;
private String email;
private Address address;
User(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
User(String name, int age, String email, Address address) {
this(name, age, email);
this.address = address;
}
public String getName() { return name; }
public int getAge() { return age; }
public String getEmail() { return email; }
public Address getAddress() { return address; }
}
static class Address {
private String city;
private String district;
Address(String city, String district) {
this.city = city;
this.district = district;
}
public String getCity() { return city; }
public String getDistrict() { return district; }
}
}4.5 assertInstanceOf 类型断言
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
class InstanceOfAssertionTest {
@Test
@DisplayName("assertInstanceOf 类型断言")
void testAssertInstanceOf() {
Object value = "Hello, JUnit 5";
// 断言类型并自动转型返回
String str = assertInstanceOf(String.class, value);
assertEquals("Hello, JUnit 5", str);
// 带错误信息
Number number = assertInstanceOf(Number.class, 42, "值应为 Number 类型");
assertEquals(42, number.intValue());
// 结合集合使用
Object list = new ArrayList<String>();
List<String> stringList = assertInstanceOf(List.class, list);
assertTrue(stringList.isEmpty());
}
@Test
@DisplayName("assertInstanceOf 接口类型断言")
void testAssertInstanceOfInterface() {
Object runnable = (Runnable) () -> System.out.println("run");
Runnable task = assertInstanceOf(Runnable.class, runnable);
assertNotNull(task);
}
}4.6 AssertJ 链式断言
AssertJ 是一个第三方断言库,提供更加流畅、可读性更强的链式断言 API。
依赖引入:
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.0</version>
<scope>test</scope>
</dependency>import static org.assertj.core.api.Assertions.*;
import java.util.*;
class AssertJExampleTest {
@Test
@DisplayName("AssertJ 字符串断言")
void testStringAssertions() {
String name = "Hello, JUnit 5 World";
assertThat(name)
.isNotEmpty()
.startsWith("Hello")
.endsWith("World")
.contains("JUnit")
.containsIgnoringCase("junit")
.doesNotContain("Goodbye")
.hasSize(21);
}
@Test
@DisplayName("AssertJ 数值断言")
void testNumericAssertions() {
int value = 42;
assertThat(value)
.isPositive()
.isGreaterThan(40)
.isLessThan(50)
.isBetween(40, 45)
.isEqualTo(42);
}
@Test
@DisplayName("AssertJ 集合断言")
void testCollectionAssertions() {
List<String> fruits = Arrays.asList("apple", "banana", "cherry", "date");
assertThat(fruits)
.isNotEmpty()
.hasSize(4)
.contains("apple", "banana")
.doesNotContain("grape")
.startsWith("apple")
.endsWith("date")
.allMatch(f -> f.length() >= 4)
.anyMatch(f -> f.startsWith("c"));
}
@Test
@DisplayName("AssertJ 异常断言")
void testExceptionAssertions() {
assertThatThrownBy(() -> {
throw new IllegalArgumentException("参数不合法");
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("参数不合法")
.hasMessageContaining("参数");
// 更简洁的写法
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> { throw new IllegalArgumentException("无效参数"); })
.withMessage("无效参数");
}
@Test
@DisplayName("AssertJ 对象属性断言")
void testObjectAssertions() {
User user = new User("张三", 25);
assertThat(user)
.extracting(User::getName, User::getAge)
.containsExactly("张三", 25);
assertThat(user)
.hasFieldOrPropertyWithValue("name", "张三")
.hasFieldOrPropertyWithValue("age", 25);
}
@Test
@DisplayName("AssertJ 软断言 - SoftAssertions")
void testSoftAssertions() {
// 使用 SoftAssertions 可以聚合所有断言结果
SoftAssertions softly = new SoftAssertions();
softly.assertThat("Hello").startsWith("H");
softly.assertThat(42).isGreaterThan(40);
softly.assertThat(Arrays.asList("a", "b")).hasSize(2);
// 最后调用 assertAll 统一报告所有失败
softly.assertAll();
}
static class User {
private String name;
private int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
}
}五、参数化测试
参数化测试允许使用不同的输入数据多次执行同一个测试方法,是 JUnit 5 最重要的特性之一。
5.1 @ValueSource
提供一组字面量值作为测试参数,支持 short, byte, int, long, float, double, char, boolean, String, Class 类型。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class ValueSourceExampleTest {
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "level", "civic"})
@DisplayName("回文字符串测试")
void testPalindromes(String candidate) {
String reversed = new StringBuilder(candidate).reverse().toString();
assertEquals(candidate, reversed);
}
@ParameterizedTest
@ValueSource(ints = {1, 3, 5, 7, 9, 11})
@DisplayName("奇数判断测试")
void testOddNumbers(int number) {
assertTrue(number % 2 != 0);
}
@ParameterizedTest
@ValueSource(booleans = {true, false, true})
@DisplayName("布尔值参数测试")
void testBooleanValues(boolean value) {
assertNotNull(value);
}
}5.2 @CsvSource
以 CSV 格式提供多个参数,非常适合有多个输入参数和期望结果的测试。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.CsvFileSource;
import static org.junit.jupiter.api.Assertions.*;
class CsvSourceExampleTest {
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"100, 200, 300"
})
@DisplayName("加法测试 - 多个输入组合")
void testAddition(int a, int b, int expected) {
assertEquals(expected, a + b);
}
@ParameterizedTest
@CsvSource({
"apple, 水果",
"car, 交通工具",
"java, 编程语言",
"earth, 行星"
})
@DisplayName("多参数测试:单词与分类")
void testWordCategory(String word, String category) {
assertNotNull(word);
assertNotNull(category);
assertFalse(word.isEmpty());
}
@ParameterizedTest
@CsvSource({
"hello, 5",
"junit, 5",
"parameterized, 14",
"'', 0",
"null, 4"
})
@DisplayName("字符串长度测试")
void testStringLength(String input, int expectedLength) {
int actualLength = (input == null) ? 0 : input.length();
assertEquals(expectedLength, actualLength);
}
@ParameterizedTest
@CsvSource(value = {
"张三, 25, true",
"李四, 17, false",
"王五, 18, true"
})
@DisplayName("成年判断测试")
void testAdult(String name, int age, boolean expectedAdult) {
assertEquals(expectedAdult, age >= 18);
}
}5.3 @CsvFileSource
从外部 CSV 文件读取测试数据。
# src/test/resources/user-data.csv
name,age,expected
张三,25,true
李四,17,false
王五,18,trueimport org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
class CsvFileSourceExampleTest {
@ParameterizedTest
@CsvFileSource(resources = "/user-data.csv", numLinesToSkip = 1)
@DisplayName("从 CSV 文件读取测试数据")
void testFromCsvFile(String name, int age, boolean expected) {
assertEquals(expected, age >= 18);
}
@ParameterizedTest
@CsvFileSource(
resources = "/user-data.csv",
numLinesToSkip = 1,
delimiter = ','
)
@DisplayName("指定分隔符的 CSV 文件")
void testWithCustomDelimiter(String name, int age, boolean expected) {
assertEquals(expected, age >= 18);
}
}5.4 @MethodSource
通过工厂方法提供数据源,支持复杂类型和动态数据生成。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
class MethodSourceExampleTest {
@ParameterizedTest
@MethodSource("stringProvider")
@DisplayName("使用静态工厂方法提供字符串参数")
void testWithMethodSource(String argument) {
assertNotNull(argument);
}
static Stream<String> stringProvider() {
return Stream.of("apple", "banana", "cherry");
}
@ParameterizedTest
@MethodSource("complexArgumentsProvider")
@DisplayName("多参数复杂数据源")
void testWithComplexArguments(String name, int age, String email) {
assertAll("用户数据验证",
() -> assertNotNull(name),
() -> assertTrue(age > 0),
() -> assertTrue(email.contains("@"))
);
}
static Stream<Arguments> complexArgumentsProvider() {
return Stream.of(
Arguments.of("张三", 25, "zhangsan@example.com"),
Arguments.of("李四", 30, "lisi@example.com"),
Arguments.of("王五", 28, "wangwu@example.com")
);
}
@ParameterizedTest
@MethodSource("com.example.utils.TestDataFactory#externalDataProvider")
@DisplayName("引用外部类的工厂方法")
void testWithExternalMethodSource(String value) {
assertNotNull(value);
}
// 参数化测试的流 - 无限流示例(取前 N 个)
@ParameterizedTest
@MethodSource("fibonacciProvider")
@DisplayName("斐波那契数列验证")
void testFibonacci(int index, int expected) {
assertEquals(expected, fibonacci(index));
}
static Stream<Arguments> fibonacciProvider() {
return Stream.of(
Arguments.of(0, 0),
Arguments.of(1, 1),
Arguments.of(2, 1),
Arguments.of(3, 2),
Arguments.of(4, 3),
Arguments.of(5, 5),
Arguments.of(6, 8)
);
}
static int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
}5.5 @EnumSource
使用枚举值作为数据源。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import java.time.DayOfWeek;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
class EnumSourceExampleTest {
enum Role {
ADMIN("系统管理员"),
USER("普通用户"),
GUEST("访客"),
MODERATOR("版主");
private String displayName;
Role(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
@ParameterizedTest
@EnumSource(Role.class)
@DisplayName("枚举全量测试")
void testAllRoles(Role role) {
assertNotNull(role.getDisplayName());
}
@ParameterizedTest
@EnumSource(value = Role.class, names = {"ADMIN", "MODERATOR"})
@DisplayName("筛选特定枚举值")
void testSpecificRoles(Role role) {
assertTrue(role == Role.ADMIN || role == Role.MODERATOR);
}
@ParameterizedTest
@EnumSource(
value = Role.class,
names = {"GUEST"},
mode = EnumSource.Mode.EXCLUDE
)
@DisplayName("排除特定枚举值")
void testExcludeGuest(Role role) {
assertNotEquals(Role.GUEST, role);
}
@ParameterizedTest
@EnumSource(
value = TimeUnit.class,
names = {"SECONDS", "MINUTES", "HOURS"}
)
@DisplayName("匹配特定枚举值")
void testTimeUnits(TimeUnit unit) {
assertTrue(unit == TimeUnit.SECONDS
|| unit == TimeUnit.MINUTES
|| unit == TimeUnit.HOURS);
}
@ParameterizedTest
@EnumSource(
value = DayOfWeek.class,
names = {"SATURDAY", "SUNDAY"}
)
@DisplayName("周末测试")
void testWeekend(DayOfWeek day) {
assertTrue(day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY);
}
}5.6 @ArgumentsSource
通过实现 ArgumentsProvider 接口提供最灵活的数据源方式。
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
class CustomArgumentsProviderTest {
@ParameterizedTest
@ArgumentsSource(CustomArgumentsProvider.class)
@DisplayName("自定义 ArgumentsProvider")
void testWithCustomProvider(String input, int expectedLength) {
assertEquals(expectedLength, input.length());
}
static class CustomArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(
Arguments.of("hello", 5),
Arguments.of("junit", 5),
Arguments.of("parameterized", 13),
Arguments.of("test", 4)
);
}
}
@ParameterizedTest
@ArgumentsSource(UserValidArgumentsProvider.class)
@DisplayName("自定义数据源 - 用户验证")
void testUserValidation(String name, int age, boolean expectedValid) {
assertEquals(expectedValid, validateUser(name, age));
}
static class UserValidArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(
Arguments.of("张三", 25, true),
Arguments.of("", 18, false), // 空名字
Arguments.of("李四", -1, false), // 负年龄
Arguments.of(null, 20, false) // null 名字
);
}
}
boolean validateUser(String name, int age) {
return name != null && !name.isEmpty() && age > 0 && age < 150;
}
}六、条件测试
JUnit 5 提供了内置的条件注解,可以根据操作系统、系统属性、环境变量、Java 版本等条件决定是否执行测试。
6.1 @EnabledOnOs / @DisabledOnOs
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class OsConditionalTest {
@Test
@EnabledOnOs(OS.WINDOWS)
@DisplayName("仅在 Windows 上执行")
void onlyOnWindows() {
System.out.println("当前系统: Windows");
assertTrue(System.getProperty("os.name").toLowerCase().contains("windows"));
}
@Test
@EnabledOnOs(OS.MAC)
@DisplayName("仅在 macOS 上执行")
void onlyOnMac() {
System.out.println("当前系统: macOS");
}
@Test
@EnabledOnOs({OS.LINUX, OS.MAC})
@DisplayName("在 Linux 或 macOS 上执行")
void onLinuxOrMac() {
System.out.println("当前系统: Unix-like");
}
@Test
@DisabledOnOs(OS.WINDOWS)
@DisplayName("在非 Windows 系统上执行")
void notOnWindows() {
System.out.println("不是 Windows 系统");
}
@Test
@EnabledOnOs(
value = OS.OTHER,
disabledReason = "仅在不被其他 OS 常量覆盖的系统上执行"
)
@DisplayName("在其他操作系统上执行")
void onOtherOs() {
System.out.println("未知操作系统");
}
}6.2 基于 JRE 版本的条件测试
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class JreConditionalTest {
@Test
@EnabledOnJre(JRE.JAVA_17)
@DisplayName("仅在 Java 17 上执行")
void onlyOnJava17() {
assertEquals(17, Runtime.version().feature());
}
@Test
@EnabledOnJre({JRE.JAVA_17, JRE.JAVA_21})
@DisplayName("在 Java 17 或 21 上执行")
void onJava17Or21() {
int feature = Runtime.version().feature();
assertTrue(feature == 17 || feature == 21);
}
@Test
@DisabledOnJre(JRE.JAVA_8)
@DisplayName("不在 Java 8 上执行")
void notOnJava8() {
assertTrue(Runtime.version().feature() > 8);
}
@Test
@EnabledForJreRange(min = JRE.JAVA_17, max = JRE.JAVA_21)
@DisplayName("在 Java 17 到 21 范围内执行")
void betweenJava17And21() {
int feature = Runtime.version().feature();
assertTrue(feature >= 17 && feature <= 21);
}
}6.3 基于系统属性的条件测试
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class SystemPropertyConditionalTest {
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
@DisplayName("仅在 64 位架构上执行")
void onlyOn64Bit() {
assertTrue(System.getProperty("os.arch").contains("64"));
}
@Test
@DisabledIfSystemProperty(named = "user.country", matches = "CN")
@DisplayName("不在中国地区执行")
void notInChina() {
// 某些需要规避特定地区法规的测试
}
@Test
@EnabledIfSystemProperty(
named = "test.env",
matches = "integration",
disabledReason = "仅集成环境执行"
)
@DisplayName("仅在集成环境中执行")
void onlyInIntegrationEnv() {
assertEquals("integration", System.getProperty("test.env"));
}
}6.4 基于环境变量的条件测试
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class EnvConditionalTest {
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
@DisplayName("仅在 CI 环境中执行")
void onlyInCi() {
assertEquals("true", System.getenv("CI"));
}
@Test
@DisabledIfEnvironmentVariable(named = "SKIP_SLOW_TESTS", matches = "true")
@DisplayName("有 SKIP_SLOW_TESTS 环境变量时跳过")
void skipIfEnvSet() {
// 慢速测试
}
}6.5 @EnabledIf / @DisabledIf 自定义条件
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class CustomConditionalTest {
@Test
@EnabledIf("customCondition")
@DisplayName("自定义方法条件 - 满足时执行")
void enabledByCustomCondition() {
assertTrue(customCondition());
}
@Test
@DisabledIf("customCondition")
@DisplayName("自定义方法条件 - 满足时跳过")
void disabledByCustomCondition() {
fail("该测试不应该执行");
}
boolean customCondition() {
// 自定义判断逻辑
return System.currentTimeMillis() % 2 == 0;
}
@Test
@EnabledIf(
value = "com.example.TestConditions#isDatabaseAvailable",
disabledReason = "数据库不可用"
)
@DisplayName("数据库可用时执行")
void requiresDatabase() {
// 数据库集成测试
}
}
class TestConditions {
static boolean isDatabaseAvailable() {
// 检查数据库是否可用
return false; // 简化示例
}
}七、嵌套测试
@Nested 注解允许在测试类内部创建内部类来组织测试,形成层次化的测试结构。嵌套测试在 IDE 中会以树形结构展示,非常直观。
7.1 基本嵌套测试
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("计算器测试")
class CalculatorNestedTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Nested
@DisplayName("加法操作")
class AdditionTests {
@Test
@DisplayName("正数相加")
void addPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}
@Test
@DisplayName("负数相加")
void addNegativeNumbers() {
assertEquals(-5, calculator.add(-2, -3));
}
@Test
@DisplayName("正负数相加")
void addPositiveAndNegative() {
assertEquals(0, calculator.add(5, -5));
}
}
@Nested
@DisplayName("减法操作")
class SubtractionTests {
@Test
@DisplayName("正数相减")
void subtractPositiveNumbers() {
assertEquals(3, calculator.subtract(5, 2));
}
@Test
@DisplayName("结果为零")
void subtractEqualNumbers() {
assertEquals(0, calculator.subtract(5, 5));
}
@Test
@DisplayName("结果为负数")
void subtractNegativeResult() {
assertEquals(-3, calculator.subtract(2, 5));
}
}
@Nested
@DisplayName("乘法操作")
class MultiplicationTests {
@Test
@DisplayName("正数相乘")
void multiplyPositiveNumbers() {
assertEquals(6, calculator.multiply(2, 3));
}
@Test
@DisplayName("乘以零")
void multiplyByZero() {
assertEquals(0, calculator.multiply(5, 0));
}
@Test
@DisplayName("负数相乘")
void multiplyNegativeNumbers() {
assertEquals(6, calculator.multiply(-2, -3));
}
}
@Nested
@DisplayName("除法操作")
class DivisionTests {
@Test
@DisplayName("整数除法")
void divideIntegers() {
assertEquals(2.0, calculator.divide(6, 3), 0.001);
}
@Test
@DisplayName("除以零")
void divideByZero() {
assertThrows(ArithmeticException.class, () -> calculator.divide(5, 0));
}
@Test
@DisplayName("小数结果")
void divideWithDecimalResult() {
assertEquals(1.5, calculator.divide(3, 2), 0.001);
}
}
static class Calculator {
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
double divide(int a, int b) {
if (b == 0) throw new ArithmeticException("不能除以零");
return (double) a / b;
}
}
}7.2 多层级嵌套测试
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("订单服务测试")
class OrderServiceNestedTest {
private OrderService orderService;
private User currentUser;
@BeforeEach
void setUp() {
orderService = new OrderService();
currentUser = new User("张三", Role.USER);
}
@Nested
@DisplayName("创建订单")
class CreateOrder {
@Nested
@DisplayName("正常场景")
class HappyPath {
@Test
@DisplayName("创建有效订单")
void createValidOrder() {
Order order = orderService.createOrder(currentUser, Arrays.asList("item1", "item2"));
assertAll("订单验证",
() -> assertNotNull(order.getId()),
() -> assertEquals(OrderStatus.PENDING, order.getStatus()),
() -> assertEquals(2, order.getItems().size())
);
}
@Test
@DisplayName("创建包含优惠券的订单")
void createOrderWithCoupon() {
Order order = orderService.createOrder(currentUser, Arrays.asList("item1"), "COUPON_10");
assertNotNull(order);
assertTrue(order.getDiscount() > 0);
}
}
@Nested
@DisplayName("异常场景")
class ErrorPath {
@Test
@DisplayName("用户未登录无法创建订单")
void createOrderWithoutUser() {
assertThrows(IllegalStateException.class,
() -> orderService.createOrder(null, Arrays.asList("item1")));
}
@Test
@DisplayName("空商品列表抛异常")
void createOrderWithEmptyItems() {
assertThrows(IllegalArgumentException.class,
() -> orderService.createOrder(currentUser, Collections.emptyList()));
}
@Test
@DisplayName("商品数量超过限制")
void createOrderWithTooManyItems() {
List<String> manyItems = new ArrayList<>();
for (int i = 0; i < 100; i++) manyItems.add("item" + i);
assertThrows(IllegalArgumentException.class,
() -> orderService.createOrder(currentUser, manyItems));
}
}
}
@Nested
@DisplayName("取消订单")
class CancelOrder {
private Order order;
@BeforeEach
void createOrder() {
order = orderService.createOrder(currentUser, Arrays.asList("item1"));
}
@Test
@DisplayName("取消待处理订单")
void cancelPendingOrder() {
orderService.cancelOrder(order.getId());
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
@Test
@DisplayName("已发货订单不可取消")
void cannotCancelShippedOrder() {
order.setStatus(OrderStatus.SHIPPED);
assertThrows(IllegalStateException.class,
() -> orderService.cancelOrder(order.getId()));
}
}
// 辅助类
enum OrderStatus { PENDING, PAID, SHIPPED, DELIVERED, CANCELLED }
enum Role { ADMIN, USER, GUEST }
static class User {
String name;
Role role;
User(String name, Role role) { this.name = name; this.role = role; }
}
static class Order {
private String id;
private OrderStatus status;
private List<String> items;
private double discount;
String getId() { return id; }
void setId(String id) { this.id = id; }
OrderStatus getStatus() { return status; }
void setStatus(OrderStatus status) { this.status = status; }
List<String> getItems() { return items; }
double getDiscount() { return discount; }
void setDiscount(double discount) { this.discount = discount; }
}
static class OrderService {
private int counter = 0;
Order createOrder(User user, List<String> items) {
if (user == null) throw new IllegalStateException("用户未登录");
if (items == null || items.isEmpty()) throw new IllegalArgumentException("商品列表不能为空");
if (items.size() > 50) throw new IllegalArgumentException("商品数量超过限制");
Order order = new Order();
order.setId("ORD-" + (++counter));
order.setStatus(OrderStatus.PENDING);
order.setItems(new ArrayList<>(items));
return order;
}
Order createOrder(User user, List<String> items, String coupon) {
Order order = createOrder(user, items);
order.setDiscount(10.0);
return order;
}
void cancelOrder(String orderId) {
// 简化实现
}
}
}7.3 @Nested 与生命周期配合
import org.junit.jupiter.api.*;
@DisplayName("外部类生命周期")
class OuterLifecycleTest {
OuterLifecycleTest() {
System.out.println("外部类构造");
}
@BeforeAll
static void outerBeforeAll() {
System.out.println("外部类 @BeforeAll");
}
@AfterAll
static void outerAfterAll() {
System.out.println("外部类 @AfterAll");
}
@BeforeEach
void outerBeforeEach() {
System.out.println("外部类 @BeforeEach");
}
@AfterEach
void outerAfterEach() {
System.out.println("外部类 @AfterEach");
}
@Test
void outerTest() {
System.out.println("外部类测试方法");
}
@Nested
@DisplayName("内部类")
class InnerTest {
InnerTest() {
System.out.println(" 内部类构造");
}
@BeforeEach
void innerBeforeEach() {
System.out.println(" 内部类 @BeforeEach");
}
@AfterEach
void innerAfterEach() {
System.out.println(" 内部类 @AfterEach");
}
@Test
void innerTest() {
System.out.println(" 内部类测试方法");
}
}
}输出顺序:
外部类 @BeforeAll
外部类构造
外部类 @BeforeEach
外部类测试方法
外部类 @AfterEach
外部类构造 <-- 内部类测试时,会重新创建外部类实例
外部类 @BeforeEach <-- 外部类的 @BeforeEach 先执行
内部类 @BeforeEach <-- 然后才是内部类的 @BeforeEach
内部类测试方法
内部类 @AfterEach
外部类 @AfterEach
外部类 @AfterAll八、扩展机制
JUnit 5 的扩展机制(Extension)取代了 JUnit 4 的 @Rule 和 @Runner,提供了更加灵活、强类型的扩展点。
8.1 @ExtendWith 基础使用
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(DatabaseExtension.class)
class DatabaseServiceTest {
@Test
@DisplayName("数据库连接测试")
void testDatabaseConnection() {
// 扩展提供了数据库连接
assertTrue(true);
}
}8.2 常用扩展接口
| 扩展接口 | 用途 | 对应 JUnit 4 |
|---|---|---|
ParameterResolver | 解析方法参数 | ParameterizedRunner |
BeforeEachCallback | 在每个测试前执行 | @Before |
AfterEachCallback | 在每个测试后执行 | @After |
BeforeAllCallback | 在所有测试前执行 | @BeforeClass |
AfterAllCallback | 在所有测试后执行 | @AfterClass |
TestExecutionExceptionHandler | 处理测试异常 | @Rule |
TestInstancePostProcessor | 测试实例后处理 | - |