测试分层与 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 | 测试实例后处理 | - |
8.3 自定义 ParameterResolver
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.Test;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import static org.junit.jupiter.api.Assertions.*;
// 注入随机字符串的扩展
class RandomStringExtension implements ParameterResolver {
@Override
public boolean supportsParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext) throws ParameterResolutionException {
return parameterContext.isAnnotated(RandomString.class);
}
@Override
public Object resolveParameter(
ParameterContext parameterContext,
ExtensionContext extensionContext) throws ParameterResolutionException {
RandomString annotation = parameterContext.findAnnotation(RandomString.class).get();
int length = annotation.length();
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(chars.charAt((int) (Math.random() * chars.length())));
}
return sb.toString();
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@interface RandomString {
int length() default 10;
}
@ExtendWith(RandomStringExtension.class)
class ParameterResolverExampleTest {
@Test
@DisplayName("注入随机字符串参数")
void testWithRandomString(@RandomString(length = 8) String randomStr) {
assertNotNull(randomStr);
assertEquals(8, randomStr.length());
System.out.println("随机字符串: " + randomStr);
}
@Test
@DisplayName("注入默认长度的随机字符串")
void testWithDefaultRandomString(@RandomString String randomStr) {
assertNotNull(randomStr);
assertEquals(10, randomStr.length());
}
}8.4 BeforeEachCallback / AfterEachCallback
实现测试前后的拦截逻辑,例如开启事务、记录耗时等。
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.*;
// 计时扩展
class TimingExtension implements BeforeEachCallback, AfterEachCallback {
private static final String START_TIME_KEY = "start_time";
@Override
public void beforeEach(ExtensionContext context) {
context.getStore(ExtensionContext.Namespace.create(getClass()))
.put(START_TIME_KEY, System.currentTimeMillis());
}
@Override
public void afterEach(ExtensionContext context) {
long startTime = context.getStore(ExtensionContext.Namespace.create(getClass()))
.remove(START_TIME_KEY, long.class);
long duration = System.currentTimeMillis() - startTime;
System.out.printf("测试 [%s] 耗时: %d ms%n",
context.getDisplayName(), duration);
}
}
// 事务扩展(模拟)
class TransactionExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
System.out.println("开启事务: " + context.getDisplayName());
}
@Override
public void afterEach(ExtensionContext context) {
// 判断是否回滚
if (context.getExecutionException().isPresent()) {
System.out.println("回滚事务: " + context.getDisplayName());
} else {
System.out.println("提交事务: " + context.getDisplayName());
}
}
}
@ExtendWith({TimingExtension.class, TransactionExtension.class})
class CallbackExampleTest {
@Test
@DisplayName("第一个测试")
void firstTest() throws InterruptedException {
Thread.sleep(100);
assertTrue(true);
}
@Test
@DisplayName("第二个测试 - 会失败")
void secondTest() {
fail("故意的失败");
}
}8.5 组合扩展注解
import org.junit.jupiter.api.extension.ExtendWith;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({TimingExtension.class, TransactionExtension.class, DatabaseExtension.class})
public @interface IntegrationTest {
}使用组合注解:
@IntegrationTest
class UserRepositoryIntegrationTest {
@Test
void testFindUser() {
// 自动应用 ThinkingExtension、TransactionExtension、DatabaseExtension
}
}8.6 TestExecutionExceptionHandler
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.Test;
class IgnoreIOExceptionExtension implements TestExecutionExceptionHandler {
@Override
public void handleTestExecutionException(
ExtensionContext context,
Throwable throwable) throws Throwable {
if (throwable instanceof java.io.IOException) {
System.out.println("忽略 IOException: " + throwable.getMessage());
return; // 吞掉异常,测试标记为通过
}
throw throwable; // 其他异常继续抛出
}
}
@ExtendWith(IgnoreIOExceptionExtension.class)
class ExceptionHandlerExampleTest {
@Test
void testThatMightThrowIoException() throws Exception {
if (Math.random() > 0.5) {
throw new java.io.IOException("网络超时");
}
assertTrue(true);
}
}九、Spring Boot 测试
Spring Boot 为 JUnit 5 提供了全面的测试支持,通过切片测试(Slice Test)可以精准地测试应用的某一层。
9.1 依赖配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>spring-boot-starter-test 会自动包含 JUnit 5、Mockito、AssertJ、Hamcrest、JSON Assert 等测试库。
9.2 @SpringBootTest 全量测试
@SpringBootTest 会启动完整的 Spring 应用上下文,适合端到端的集成测试。
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional // 测试完成后自动回滚,避免数据污染
@DisplayName("用户服务集成测试")
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User("zhangsan@example.com", "张三");
userRepository.save(testUser);
}
@Test
@DisplayName("根据邮箱查找用户 - 存在")
void findByEmail_existing() {
User found = userService.findByEmail("zhangsan@example.com");
assertThat(found).isNotNull();
assertThat(found.getName()).isEqualTo("张三");
assertThat(found.getEmail()).isEqualTo("zhangsan@example.com");
}
@Test
@DisplayName("根据邮箱查找用户 - 不存在")
void findByEmail_notExisting() {
User found = userService.findByEmail("notexist@example.com");
assertThat(found).isNull();
}
@Test
@DisplayName("创建新用户")
void createUser() {
User newUser = new User("lisi@example.com", "李四");
User created = userService.createUser(newUser);
assertThat(created.getId()).isNotNull();
assertThat(created.getEmail()).isEqualTo("lisi@example.com");
// 验证数据库中确实存在
assertThat(userRepository.findById(created.getId())).isPresent();
}
@Test
@DisplayName("创建重复邮箱用户抛异常")
void createUser_duplicateEmail() {
User duplicate = new User("zhangsan@example.com", "张三重复");
assertThatThrownBy(() -> userService.createUser(duplicate))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("邮箱已存在");
}
@Test
@DisplayName("更新用户信息")
void updateUser() {
User updated = userService.updateUser(testUser.getId(), "张四");
assertThat(updated.getName()).isEqualTo("张四");
assertThat(userRepository.findById(testUser.getId()))
.get()
.extracting(User::getName)
.isEqualTo("张四");
}
}9.3 @WebMvcTest Controller 测试
@WebMvcTest 只加载 Web 层(Controller、ControllerAdvice、Jackson 配置等),不会加载完整的上下文,通常配合 @MockBean 模拟 Service 层。
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;
@WebMvcTest(UserController.class)
@DisplayName("用户 Controller 测试")
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User(1L, "zhangsan@example.com", "张三");
}
@Test
@DisplayName("GET /api/users/{id} - 用户存在")
void getUserById_existing() throws Exception {
// 模拟 Service 层行为
given(userService.findById(1L)).willReturn(testUser);
mockMvc.perform(get("/api/users/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("zhangsan@example.com"))
.andExpect(jsonPath("$.name").value("张三"));
}
@Test
@DisplayName("GET /api/users/{id} - 用户不存在")
void getUserById_notFound() throws Exception {
given(userService.findById(999L)).willReturn(null);
mockMvc.perform(get("/api/users/{id}", 999L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("POST /api/users - 创建用户成功")
void createUser_success() throws Exception {
User newUser = new User("newuser@example.com", "新用户");
given(userService.createUser(any(User.class)))
.willReturn(new User(2L, "newuser@example.com", "新用户"));
String requestBody = """
{
"email": "newuser@example.com",
"name": "新用户"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(2));
}
@Test
@DisplayName("POST /api/users - 参数校验失败")
void createUser_validationError() throws Exception {
String invalidRequestBody = """
{
"email": "invalid-email",
"name": ""
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequestBody))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
@DisplayName("GET /api/users - 分页查询")
void listUsers_pagination() throws Exception {
given(userService.findAll(anyInt(), anyInt()))
.willReturn(new PageImpl<>(Arrays.asList(testUser)));
mockMvc.perform(get("/api/users")
.param("page", "0")
.param("size", "10")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content[0].name").value("张三"));
}
}9.4 @DataJpaTest 数据访问层测试
@DataJpaTest 只加载 JPA 相关的组件(EntityManager、DataSource 等),默认使用嵌入式内存数据库。
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import static org.assertj.core.api.Assertions.*;
@DataJpaTest
@DisplayName("用户 Repository 测试")
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
private User user;
@BeforeEach
void setUp() {
user = new User("test@example.com", "测试用户");
entityManager.persist(user);
}
@Test
@DisplayName("根据邮箱查找")
void findByEmail() {
var found = userRepository.findByEmail("test@example.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("测试用户");
}
@Test
@DisplayName("根据邮箱查找 - 不存在返回空")
void findByEmail_notFound() {
var found = userRepository.findByEmail("nonexist@example.com");
assertThat(found).isEmpty();
}
@Test
@DisplayName("模糊搜索用户名")
void searchByName() {
entityManager.persist(new User("user1@test.com", "张三丰"));
entityManager.persist(new User("user2@test.com", "张无忌"));
Page<User> result = userRepository.searchByName("张", PageRequest.of(0, 10));
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent())
.extracting(User::getName)
.containsExactlyInAnyOrder("张三丰", "张无忌");
}
@Test
@DisplayName("统计活跃用户数量")
void countActiveUsers() {
user.setActive(true);
entityManager.persist(new User("inactive@test.com", "非活跃用户"));
long count = userRepository.countActiveUsers();
assertThat(count).isEqualTo(1);
}
@Test
@DisplayName("软删除用户")
void softDelete() {
userRepository.softDeleteById(user.getId());
entityManager.flush();
entityManager.clear();
User deleted = entityManager.find(User.class, user.getId());
assertThat(deleted.isDeleted()).isTrue();
}
}9.5 @JsonTest JSON 序列化测试
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.json.JsonContent;
import static org.assertj.core.api.Assertions.*;
@JsonTest
@DisplayName("用户 JSON 序列化测试")
class UserJsonTest {
@Autowired
private JacksonTester<User> json;
private User user;
@BeforeEach
void setUp() {
user = new User(1L, "test@example.com", "测试用户");
}
@Test
@DisplayName("序列化为 JSON")
void serialize() throws Exception {
JsonContent<User> content = json.write(user);
assertThat(content).extractingJsonPathNumberValue("$.id")
.isEqualTo(1);
assertThat(content).extractingJsonPathStringValue("$.email")
.isEqualTo("test@example.com");
assertThat(content).extractingJsonPathStringValue("$.name")
.isEqualTo("测试用户");
}
@Test
@DisplayName("从 JSON 反序列化")
void deserialize() throws Exception {
String jsonContent = """
{
"id": 2,
"email": "json@example.com",
"name": "JSON用户"
}
""";
User parsed = json.parse(jsonContent).getObject();
assertThat(parsed.getId()).isEqualTo(2L);
assertThat(parsed.getEmail()).isEqualTo("json@example.com");
assertThat(parsed.getName()).isEqualTo("JSON用户");
}
@Test
@DisplayName("忽略未知字段")
void deserializeWithUnknownFields() throws Exception {
String jsonContent = """
{
"id": 3,
"email": "unknown@test.com",
"name": "未知字段测试",
"unknown_field": "这个字段在 User 类中不存在"
}
""";
User parsed = json.parse(jsonContent).getObject();
assertThat(parsed.getId()).isEqualTo(3L);
// 不会因未知字段抛出异常
}
@Test
@DisplayName("null 字段不序列化")
void nullFieldNotSerialized() throws Exception {
User userWithNullName = new User(4L, "noname@test.com", null);
JsonContent<User> content = json.write(userWithNullName);
assertThat(content).doesNotHaveJsonPath("$.name");
}
}9.6 @RestClientTest REST 客户端测试
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
import static org.assertj.core.api.Assertions.*;
@RestClientTest(UserApiClient.class)
@DisplayName("用户 API 客户端测试")
class UserApiClientTest {
@Autowired
private UserApiClient client;
@Autowired
private MockRestServiceServer server;
@Test
@DisplayName("调用外部 API 获取用户")
void getUserById() {
String responseBody = """
{
"id": 1,
"email": "api@example.com",
"name": "API用户"
}
""";
server.expect(requestTo("/api/users/1"))
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
User user = client.getUserById(1L);
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getEmail()).isEqualTo("api@example.com");
assertThat(user.getName()).isEqualTo("API用户");
server.verify();
}
@Test
@DisplayName("外部 API 返回 404")
void getUserById_notFound() {
server.expect(requestTo("/api/users/999"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> client.getUserById(999L))
.isInstanceOf(UserNotFoundException.class);
}
}9.7 TestRestTemplate 使用
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("TestRestTemplate 集成测试")
class UserRestTemplateTest {
@Autowired
private TestRestTemplate restTemplate;
private String baseUrl = "/api/users";
@Test
@DisplayName("创建用户并查询")
void createAndGetUser() {
// 创建用户
User request = new User("template@test.com", "模板用户");
ResponseEntity<User> createResponse = restTemplate.postForEntity(
baseUrl, request, User.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long userId = createResponse.getBody().getId();
// 查询用户
ResponseEntity<User> getResponse = restTemplate.getForEntity(
baseUrl + "/" + userId, User.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getName()).isEqualTo("模板用户");
}
@Test
@DisplayName("列表查询")
void listUsers() {
ResponseEntity<User[]> response = restTemplate.getForEntity(
baseUrl + "?page=0&size=10", User[].class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("删除用户 - 返回 204")
void deleteUser() {
// 先创建一个用于删除的用户
User request = new User("delete@test.com", "待删除");
ResponseEntity<User> createResponse = restTemplate.postForEntity(
baseUrl, request, User.class);
Long userId = createResponse.getBody().getId();
// 删除
restTemplate.delete(baseUrl + "/" + userId);
// 验证已删除
ResponseEntity<User> getResponse = restTemplate.getForEntity(
baseUrl + "/" + userId, User.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}9.8 MockMvc 完整配置示例
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
@DisplayName("MockMvc 完整配置示例")
class UserControllerFullTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
@DisplayName("查询用户 - 成功返回 JSON")
void getUser_success() throws Exception {
given(userService.findById(1L))
.willReturn(new User(1L, "test@test.com", "测试用户"));
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("test@test.com"));
}
@Test
@DisplayName("查询用户 - 404 处理")
void getUser_notFound() throws Exception {
given(userService.findById(999L)).willReturn(null);
mockMvc.perform(get("/api/users/999")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("创建用户 - 返回 201")
void createUser_created() throws Exception {
User input = new User("new@test.com", "新用户");
given(userService.createUser(any(User.class)))
.willReturn(new User(10L, "new@test.com", "新用户"));
String json = """
{
"email": "new@test.com",
"name": "新用户"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(10));
}
@Test
@DisplayName("参数校验失败 - 返回 400")
void createUser_validationFailed() throws Exception {
String invalidJson = """
{
"email": "not-an-email",
"name": ""
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
@DisplayName("删除用户 - 返回 204")
void deleteUser_noContent() throws Exception {
willDoNothing().given(userService).deleteUser(1L);
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
}
}十、完整分层测试项目示例
本节以一个完整的用户管理系统为例,展示 Controller、Service、Repository 三层的完整测试实现。
10.1 项目结构
src/main/java/com/example/user/
├── controller/
│ └── UserController.java
├── service/
│ ├── UserService.java
│ └── impl/
│ └── UserServiceImpl.java
├── repository/
│ └── UserRepository.java
├── entity/
│ └── User.java
├── dto/
│ ├── CreateUserRequest.java
│ └── UserResponse.java
└── exception/
├── UserNotFoundException.java
└── DuplicateEmailException.java
src/test/java/com/example/user/
├── controller/
│ └── UserControllerTest.java
├── service/
│ └── UserServiceTest.java
└── repository/
└── UserRepositoryTest.java10.2 业务实体和 DTO
// entity/User.java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Column(name = "is_active")
private boolean active = true;
@Column(name = "is_deleted")
private boolean deleted = false;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public User() {}
public User(String email, String name) {
this.email = email;
this.name = name;
}
public User(Long id, String email, String name) {
this.id = id;
this.email = email;
this.name = name;
}
// getters / setters ...
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public boolean isDeleted() { return deleted; }
public void setDeleted(boolean deleted) { this.deleted = deleted; }
}
// dto/CreateUserRequest.java
public class CreateUserRequest {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "姓名不能为空")
@Size(min = 2, max = 50, message = "姓名长度在 2-50 之间")
private String name;
// getters / setters ...
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
// dto/UserResponse.java
public class UserResponse {
private Long id;
private String email;
private String name;
private boolean active;
private LocalDateTime createdAt;
public static UserResponse from(User user) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setEmail(user.getEmail());
response.setName(user.getName());
response.setActive(user.isActive());
response.setCreatedAt(user.getCreatedAt());
return response;
}
// getters / setters ...
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
// exception/UserNotFoundException.java
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("用户不存在: " + id);
}
}
// exception/DuplicateEmailException.java
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String email) {
super("邮箱已存在: " + email);
}
}10.3 Repository 层
// repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.deleted = false AND u.name LIKE %:keyword%")
Page<User> searchByName(@Param("keyword") String keyword, Pageable pageable);
@Modifying
@Query("UPDATE User u SET u.deleted = true WHERE u.id = :id")
void softDeleteById(@Param("id") Long id);
@Query("SELECT COUNT(u) FROM User u WHERE u.active = true AND u.deleted = false")
long countActiveUsers();
}Repository 层测试:
// repository/UserRepositoryTest.java
@ExtendWith(SpringExtension.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@DisplayName("UserRepository 数据访问层测试")
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
private User savedUser;
@BeforeEach
void setUp() {
savedUser = entityManager.persistAndFlush(
new User("setup@test.com", "初始用户"));
}
@Test
@DisplayName("通过邮箱查找已存在的用户")
void findByEmail_whenExists_returnsUser() {
Optional<User> found = userRepository.findByEmail("setup@test.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("初始用户");
}
@Test
@DisplayName("通过邮箱查找不存在的用户")
void findByEmail_whenNotExists_returnsEmpty() {
Optional<User> found = userRepository.findByEmail("nonexist@test.com");
assertThat(found).isEmpty();
}
@Test
@DisplayName("检查邮箱是否已存在")
void existsByEmail() {
assertThat(userRepository.existsByEmail("setup@test.com")).isTrue();
assertThat(userRepository.existsByEmail("unknown@test.com")).isFalse();
}
@Test
@DisplayName("按关键字搜索用户")
void searchByName_withKeyword_returnsMatchingUsers() {
entityManager.persistAndFlush(new User("alice@test.com", "Alice Wang"));
entityManager.persistAndFlush(new User("bob@test.com", "Bob Zhang"));
Page<User> result = userRepository.searchByName("Alice", PageRequest.of(0, 10));
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getEmail()).isEqualTo("alice@test.com");
}
@Test
@DisplayName("软删除用户")
void softDeleteById_marksUserAsDeleted() {
userRepository.softDeleteById(savedUser.getId());
entityManager.flush();
entityManager.clear();
User deleted = entityManager.find(User.class, savedUser.getId());
assertThat(deleted.isDeleted()).isTrue();
}
@Test
@DisplayName("统计活跃用户数")
void countActiveUsers_returnsCorrectCount() {
User inactiveUser = new User("inactive@test.com", "非活跃用户");
inactiveUser.setActive(false);
entityManager.persistAndFlush(inactiveUser);
long count = userRepository.countActiveUsers();
// savedUser 是活跃的,inactiveUser 不是
assertThat(count).isEqualTo(1);
}
}10.4 Service 层
// service/impl/UserServiceImpl.java
@Service
@Transactional
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
public UserServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserResponse getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return UserResponse.from(user);
}
@Override
public UserResponse createUser(CreateUserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException(request.getEmail());
}
User user = new User(request.getEmail(), request.getName());
user = userRepository.save(user);
return UserResponse.from(user);
}
@Override
public UserResponse updateUser(Long id, String newName) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
user.setName(newName);
user = userRepository.save(user);
return UserResponse.from(user);
}
@Override
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new UserNotFoundException(id);
}
userRepository.softDeleteById(id);
}
@Override
public Page<UserResponse> listUsers(String keyword, Pageable pageable) {
Page<User> users;
if (keyword != null && !keyword.isBlank()) {
users = userRepository.searchByName(keyword, pageable);
} else {
users = userRepository.findAll(pageable);
}
return users.map(UserResponse::from);
}
}Service 层测试(使用 Mock 隔离 Repository):
// service/UserServiceTest.java
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService 业务逻辑层测试")
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserServiceImpl userService;
private User testUser;
private CreateUserRequest createRequest;
@BeforeEach
void setUp() {
testUser = new User(1L, "test@example.com", "测试用户");
createRequest = new CreateUserRequest();
createRequest.setEmail("new@example.com");
createRequest.setName("新用户");
}
@Nested
@DisplayName("getUserById 方法")
class GetUserById {
@Test
@DisplayName("用户存在时返回用户信息")
void whenUserExists_returnsUserResponse() {
given(userRepository.findById(1L)).willReturn(Optional.of(testUser));
UserResponse response = userService.getUserById(1L);
assertAll(
() -> assertThat(response.getId()).isEqualTo(1L),
() -> assertThat(response.getEmail()).isEqualTo("test@example.com"),
() -> assertThat(response.getName()).isEqualTo("测试用户")
);
then(userRepository).should().findById(1L);
}
@Test
@DisplayName("用户不存在时抛出异常")
void whenUserNotExists_throwsException() {
given(userRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> userService.getUserById(999L))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining("999");
}
}
@Nested
@DisplayName("createUser 方法")
class CreateUser {
@Test
@DisplayName("创建新用户成功")
void whenEmailNotExists_createsUser() {
given(userRepository.existsByEmail("new@example.com")).willReturn(false);
given(userRepository.save(any(User.class)))
.willAnswer(invocation -> {
User saved = invocation.getArgument(0);
saved.setId(2L);
return saved;
});
UserResponse response = userService.createUser(createRequest);
assertAll(
() -> assertThat(response.getId()).isEqualTo(2L),
() -> assertThat(response.getEmail()).isEqualTo("new@example.com"),
() -> assertThat(response.getName()).isEqualTo("新用户")
);
then(userRepository).should().save(any(User.class));
}
@Test
@DisplayName("邮箱已存在时抛出异常")
void whenEmailExists_throwsException() {
given(userRepository.existsByEmail("new@example.com")).willReturn(true);
assertThatThrownBy(() -> userService.createUser(createRequest))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("new@example.com");
then(userRepository).should(never()).save(any(User.class));
}
}
@Nested
@DisplayName("updateUser 方法")
class UpdateUser {
@Test
@DisplayName("更新用户名成功")
void whenUserExists_updatesName() {
given(userRepository.findById(1L)).willReturn(Optional.of(testUser));
given(userRepository.save(any(User.class)))
.willAnswer(invocation -> invocation.getArgument(0));
UserResponse response = userService.updateUser(1L, "新名字");
assertThat(response.getName()).isEqualTo("新名字");
then(userRepository).should().save(any(User.class));
}
@Test
@DisplayName("更新不存在的用户抛异常")
void whenUserNotExists_throwsException() {
given(userRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> userService.updateUser(999L, "新名字"))
.isInstanceOf(UserNotFoundException.class);
}
}
@Nested
@DisplayName("deleteUser 方法")
class DeleteUser {
@Test
@DisplayName("删除存在的用户")
void whenUserExists_softDeletes() {
given(userRepository.existsById(1L)).willReturn(true);
userService.deleteUser(1L);
then(userRepository).should().softDeleteById(1L);
}
@Test
@DisplayName("删除不存在的用户抛异常")
void whenUserNotExists_throwsException() {
given(userRepository.existsById(999L)).willReturn(false);
assertThatThrownBy(() -> userService.deleteUser(999L))
.isInstanceOf(UserNotFoundException.class);
then(userRepository).should(never()).softDeleteById(any());
}
}
@Nested
@DisplayName("listUsers 方法")
class ListUsers {
@Test
@DisplayName("有关键字时按关键字搜索")
void withKeyword_searchesByName() {
Pageable pageable = PageRequest.of(0, 10);
given(userRepository.searchByName("测试", pageable))
.willReturn(new PageImpl<>(List.of(testUser)));
Page<UserResponse> result = userService.listUsers("测试", pageable);
assertThat(result.getContent()).hasSize(1);
then(userRepository).should().searchByName("测试", pageable);
then(userRepository).should(never()).findAll(any(Pageable.class));
}
@Test
@DisplayName("无关键字时返回全部")
void withoutKeyword_returnsAll() {
Pageable pageable = PageRequest.of(0, 10);
given(userRepository.findAll(pageable))
.willReturn(new PageImpl<>(List.of(testUser)));
Page<UserResponse> result = userService.listUsers(null, pageable);
assertThat(result.getContent()).hasSize(1);
then(userRepository).should().findAll(pageable);
}
}
}10.5 Controller 层
// controller/UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
UserResponse response = userService.getUserById(id);
return ResponseEntity.ok(response);
}
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserResponse response = userService.createUser(request);
URI location = URI.create("/api/users/" + response.getId());
return ResponseEntity.created(location).body(response);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@RequestBody Map<String, String> body) {
String newName = body.get("name");
UserResponse response = userService.updateUser(id, newName);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
@GetMapping
public ResponseEntity<Page<UserResponse>> listUsers(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<UserResponse> users = userService.listUsers(keyword, pageable);
return ResponseEntity.ok(users);
}
}Controller 层测试(使用 MockMvc):
// controller/UserControllerTest.java
@WebMvcTest(UserController.class)
@DisplayName("UserController Web 层测试")
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
@DisplayName("GET /api/users/{id} - 成功返回用户")
void getUserById_returns200() throws Exception {
UserResponse response = new UserResponse();
response.setId(1L);
response.setEmail("test@test.com");
response.setName("测试用户");
response.setActive(true);
given(userService.getUserById(1L)).willReturn(response);
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("test@test.com"))
.andExpect(jsonPath("$.name").value("测试用户"));
}
@Test
@DisplayName("GET /api/users/{id} - 用户不存在返回 404")
void getUserById_returns404() throws Exception {
given(userService.getUserById(999L))
.willThrow(new UserNotFoundException(999L));
mockMvc.perform(get("/api/users/999")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("POST /api/users - 创建成功返回 201")
void createUser_returns201() throws Exception {
UserResponse response = new UserResponse();
response.setId(1L);
response.setEmail("new@test.com");
response.setName("新用户");
given(userService.createUser(any(CreateUserRequest.class)))
.willReturn(response);
String requestBody = """
{
"email": "new@test.com",
"name": "新用户"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/users/1"))
.andExpect(jsonPath("$.id").value(1));
}
@Test
@DisplayName("POST /api/users - 邮箱重复返回 409")
void createUser_duplicateEmail_returns409() throws Exception {
given(userService.createUser(any(CreateUserRequest.class)))
.willThrow(new DuplicateEmailException("new@test.com"));
String requestBody = """
{
"email": "new@test.com",
"name": "新用户"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isConflict());
}
@Test
@DisplayName("POST /api/users - 参数校验失败返回 400")
void createUser_invalidInput_returns400() throws Exception {
String invalidBody = """
{
"email": "not-an-email",
"name": ""
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidBody))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("PUT /api/users/{id} - 更新成功")
void updateUser_returns200() throws Exception {
UserResponse response = new UserResponse();
response.setId(1L);
response.setEmail("test@test.com");
response.setName("更新后的名字");
given(userService.updateUser(eq(1L), eq("更新后的名字")))
.willReturn(response);
String requestBody = """
{
"name": "更新后的名字"
}
""";
mockMvc.perform(put("/api/users/1")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("更新后的名字"));
}
@Test
@DisplayName("DELETE /api/users/{id} - 删除成功返回 204")
void deleteUser_returns204() throws Exception {
willDoNothing().given(userService).deleteUser(1L);
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
}
@Test
@DisplayName("DELETE /api/users/{id} - 用户不存在返回 404")
void deleteUser_notFound_returns404() throws Exception {
willThrow(new UserNotFoundException(999L))
.given(userService).deleteUser(999L);
mockMvc.perform(delete("/api/users/999"))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("GET /api/users - 分页查询")
void listUsers_returns200() throws Exception {
UserResponse user1 = new UserResponse();
user1.setId(1L);
user1.setEmail("user1@test.com");
user1.setName("用户一");
Page<UserResponse> page = new PageImpl<>(List.of(user1));
given(userService.listUsers(any(), any())).willReturn(page);
mockMvc.perform(get("/api/users")
.param("page", "0")
.param("size", "10")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content[0].email").value("user1@test.com"));
}
}10.6 全局异常处理
为了让 Controller 测试中的异常映射生效,需要配合 @ControllerAdvice:
// exception/GlobalExceptionHandler.java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<Void> handleUserNotFound(UserNotFoundException ex) {
return ResponseEntity.notFound().build();
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<Void> handleDuplicateEmail(DuplicateEmailException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, Object> errors = new HashMap<>();
errors.put("errors", ex.getBindingResult().getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList()));
return ResponseEntity.badRequest().body(errors);
}
}10.7 完整集成测试
通过 @SpringBootTest 结合 TestRestTemplate,可以编写覆盖全链路的集成测试:
// UserServiceIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@DisplayName("User 全链路集成测试")
class UserServiceIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
private User savedUser;
@BeforeEach
void setUp() {
savedUser = userRepository.save(new User("integrate@test.com", "集成用户"));
}
@Test
@DisplayName("完整业务流程:创建 -> 查询 -> 更新 -> 删除")
void fullUserLifecycle() {
// 1. 创建用户
CreateUserRequest request = new CreateUserRequest();
request.setEmail("lifecycle@test.com");
request.setName("生命周期用户");
ResponseEntity<UserResponse> createResponse = restTemplate.postForEntity(
"/api/users", request, UserResponse.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long userId = createResponse.getBody().getId();
// 2. 查询用户
ResponseEntity<UserResponse> getResponse = restTemplate.getForEntity(
"/api/users/" + userId, UserResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getName()).isEqualTo("生命周期用户");
// 3. 更新用户
Map<String, String> updateBody = new HashMap<>();
updateBody.put("name", "已更新用户");
ResponseEntity<UserResponse> updateResponse = restTemplate.exchange(
"/api/users/" + userId,
HttpMethod.PUT,
new HttpEntity<>(updateBody),
UserResponse.class);
assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(updateResponse.getBody().getName()).isEqualTo("已更新用户");
// 4. 删除用户
ResponseEntity<Void> deleteResponse = restTemplate.exchange(
"/api/users/" + userId,
HttpMethod.DELETE,
null,
Void.class);
assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// 5. 验证已删除
ResponseEntity<UserResponse> afterDelete = restTemplate.getForEntity(
"/api/users/" + userId, UserResponse.class);
assertThat(afterDelete.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("重复邮箱创建用户返回 409")
void createDuplicateEmail_returns409() {
CreateUserRequest request = new CreateUserRequest();
request.setEmail("integrate@test.com"); // 与 setUp 中重复
request.setName("重复用户");
ResponseEntity<Void> response = restTemplate.postForEntity(
"/api/users", request, Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
@Test
@DisplayName("参数校验失败返回 400")
void invalidInput_returns400() {
CreateUserRequest request = new CreateUserRequest();
request.setEmail("invalid-email");
request.setName("");
ResponseEntity<Map> response = restTemplate.postForEntity(
"/api/users", request, Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).containsKey("errors");
}
@Test
@DisplayName("分页查询用户列表")
void listUsers_withPagination() {
// 准备更多用户数据
for (int i = 0; i < 15; i++) {
userRepository.save(new User("batch" + i + "@test.com", "批量用户" + i));
}
ResponseEntity<Page> response = restTemplate.exchange(
"/api/users?page=0&size=5",
HttpMethod.GET,
null,
new ParameterizedTypeReference<Page>() {});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getContent()).hasSize(5);
}
}10.8 测试总结与最佳实践
| 测试层次 | 注解 | 职责 | 速度 | 依赖管理 |
|---|---|---|---|---|
| Repository | @DataJpaTest | 数据访问逻辑、SQL 正确性 | 快 | 内存数据库 |
| Service | @ExtendWith(MockitoExtension.class) | 业务逻辑、异常处理 | 极快 | Mock Repository |
| Controller | @WebMvcTest + @MockBean | 请求映射、参数校验、响应格式 | 快 | Mock Service |
| 全链路 | @SpringBootTest + TestRestTemplate | 端到端流程验证 | 慢 | 真实数据库 |
最佳实践建议:
- 遵循测试金字塔比例:70% 单元测试、20% 集成测试、10% 接口测试
- Service 层测试使用 Mockito 隔离外部依赖,确保测试的确定性和速度
- Controller 层测试使用
@WebMvcTest切片,只加载 Web 层上下文 - Repository 层测试使用
@DataJpaTest+ 内存数据库,验证 SQL 正确性 - 集成测试使用
@SpringBootTest+ 真实数据库(或 Testcontainers),覆盖关键业务流程 - 使用
@Transactional确保测试数据自动回滚,避免测试间相互影响 - 断言优先使用 AssertJ 的链式 API,提高可读性和错误信息质量
- 使用
@Nested组织测试类,按业务场景或方法分组 - 使用
@ParameterizedTest+@CsvSource覆盖边界条件,减少重复代码 - 保持测试的独立性:每个测试方法应当独立可运行,不依赖其他测试的执行顺序