什么是单元测试,什么是集成测试

单元测试是只测试一个特定单元的测试,如果测试需要启动多个层而不是只启动这个被测试单元,那它就是一个集成测试。

测试金字塔

测试金字塔描述了理想测试套件的结构:大量快速的单元测试构成底层,适量的集成测试构成中层,少量的端到端测试构成顶层。单元测试运行速度快、维护成本低,应占测试套件的70%以上;集成测试验证组件协作,约占20%;端到端测试验证完整用户场景,约占10%。遵循金字塔原则能保证测试反馈速度和成本效益的平衡。

测试策略选择

选择测试类型时需权衡速度、可靠性和成本。单元测试适合验证复杂业务逻辑和边界条件,应优先编写;集成测试适合验证组件协作和配置正确性,用于覆盖单元测试无法触及的边界;端到端测试适合验证关键用户路径,但维护成本高,应谨慎使用。

代码分类测试策略

一种比较前沿的观点认为:访问代码、管理者代码、存储代码和业务代码里,只有第四种需要测试,其他的逻辑的正确性只要由顺序执行保证就行了。这第四种测试,是不需要mock的,尽量使用main就能启动。这就要求把业务逻辑和输入输出解耦。和输入解耦比较简单,和输出解耦需要一定的巧思——把业务逻辑写成纯函数也许能达到这一目的。

而集成测试意味着要启动尽可能大的完整程序,进行:

  • 功能测试
  • 验收测试
  • 端到端测试

mock object

历史上的 mock object 是为了 peel out 整个 framework,让测试变轻而设计出来的。但如果 mock 的配置比较繁琐,则 mock 仍然很重。

当一个对象的依赖的行为很难定制而需要定制的时候,mock 对象就登场了。在历史上,软件工程的语言里,关于什么是 mock、stub、fake 和 stub 有漫长的争论,它们的共同点是都是真接口的假实现

Mockito 已经完成了一个 mock 的精确定义,stub 和 spy 都是 mock 的一些子行为,或者特定类型。

Mockito 核心用法

Stubbing:配置行为

Stubbing 通过 when().thenReturn() 语法配置 mock 对象的行为,这是最常见的 mock 操作。

1
2
3
4
5
6
7
8
import static org.mockito.Mockito.*;

List<String> mockedList = mock(List.class);
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(anyInt())).thenReturn("element");

assertEquals("first", mockedList.get(0));
assertEquals("element", mockedList.get(999));

Verification:验证调用

Mockito 支持验证 mock 对象的方法是否被调用、调用次数、调用顺序等行为。

1
2
3
4
5
6
7
8
9
10
11
List mockedList = mock(List.class);
mockedList.add("once");
mockedList.add("twice");
mockedList.add("twice");

verify(mockedList).add("once");
verify(mockedList, times(2)).add("twice");
verify(mockedList, never()).add("never happened");
verify(mockedList, atLeastOnce()).add("once");
verify(mockedList, atLeast(2)).add("twice");
verify(mockedList, atMost(5)).add("twice");

ArgumentCaptor:捕获参数

ArgumentCaptor 用于捕获方法调用时的参数,便于对参数进行断言。

1
2
3
4
5
import org.mockito.ArgumentCaptor;

ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(mockedList).add(captor.capture());
assertEquals("actual value", captor.getValue());

注解驱动 Mock

@Mock@InjectMocks 注解简化了 mock 对象的创建和依赖注入,需要配合 MockitoAnnotations.openMocks()@ExtendWith(MockitoExtension.class) 使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

@Mock
private InventoryService inventoryService;

@Mock
private PaymentService paymentService;

@InjectMocks
private OrderService orderService;

@Test
void shouldPlaceOrderSuccessfully() {
when(inventoryService.checkStock(anyString(), anyInt())).thenReturn(true);
when(paymentService.charge(anyString(), anyBigDecimal())).thenReturn(true);

boolean result = orderService.placeOrder("product-123", 2, "user-456");

assertTrue(result);
verify(inventoryService).checkStock("product-123", 2);
verify(paymentService).charge("user-456", new BigDecimal("199.99"));
}
}

Mock vs Stub vs Spy

Mock:验证行为

Mock 对象专注于验证行为,即验证某个方法是否被调用、调用了多少次、调用顺序等。Mockito 的默认 mock 对象就是 Mock。

1
2
3
List mockList = mock(List.class);
mockList.add("one");
verify(mockList).add("one"); // 验证行为

Stub:配置返回值

Stub 是 mock 的一种子行为,专注于配置 mock 对象的返回值,不关心方法是否被调用。

1
2
3
List stubList = mock(List.class);
when(stubList.get(0)).thenReturn("stubbed value"); // 配置返回值
assertEquals("stubbed value", stubList.get(0));

Spy:部分 Mock

Spy 是对真实对象的部分 mock,可以保留真实对象的部分行为,同时覆盖其他行为。

1
2
3
4
5
6
7
8
List<String> list = new ArrayList<>();
List<String> spy = spy(list);

when(spy.size()).thenReturn(100); // 覆盖 size() 方法
spy.add("one"); // 调用真实的 add() 方法

assertEquals(100, spy.size()); // 返回 stubbed 值
assertEquals(1, list.size()); // 真实对象被修改

选择建议

  • 使用 Mock 当需要验证方法调用行为时
  • 使用 Stub 当只需要配置返回值时
  • 使用 Spy 当需要保留部分真实行为时
  • 避免过度使用 Spy,通常意味着设计需要重构

🔑 模式提炼:隔离依赖

模式公式MOCK {依赖接口} WITH {行为配置} TO ISOLATE {被测单元}

迁移表

场景 Mock 对象 行为配置 隔离目标
数据库访问 Repository when().thenReturn() 业务逻辑
外部 API HttpClient stub() 返回固定响应 服务层
缓存操作 CacheManager verify() 验证缓存命中 缓存策略
消息队列 MessageProducer ArgumentCaptor 捕获消息 消息发送逻辑

核心洞察:当被测单元的依赖行为难以控制或运行成本高昂时,通过 mock 对象模拟依赖的行为,实现被测单元的隔离测试。听到"数据库慢"、“外部服务不稳定”、"需要验证调用"时,应想到隔离依赖模式。

Case 分类

参考:

  1. What is the explicit difference between an edge case and a corner case?
  2. What are the difference between an edge case, a corner case, a base case and a boundary case?

edge case

An edge case is a problem or situation that occurs only at an extreme (maximum or minimum) operating parameter. For example, a stereo speaker might noticeably distort audio when played at its maximum rated volume, even in the absence of other extreme settings or conditions.

边缘情况是仅在极端(最大或最小)操作参数下发生的问题或情况。例如,立体声扬声器在以最大额定音量播放时可能会明显失真,即使没有其他极端设置或条件。

corner case

Corner case occurs outside of normal operating parameters, specifically when multiple environmental variables or conditions are simultaneously at extreme levels, even though each parameter is within the specified range for that parameter. (The “outside normal operating parameters” obviously means something like “outside typical combination of operating parameters”, not strictly “outside allowed operating parameters”. That is, you’re still within the valid parameter space, but near its corner.)

在工程中,极端情况 corner case(或病理情况 pathological case)涉及仅在正常操作参数之外发生的问题或情况——特别是当多个环境变量或条件同时处于极端水平时表现出来的问题或情况,即使每个参数都在该参数的指定的范围内。

boundary case

Boundary case occurs when one of inputs is at or just beyond maximum or minimum limits.

当输入之一处于或刚好超过最大或最小限制时,就会发生边界情况。
我们常说的 corner case,很大一部分是 boundary case。

base case

Base case is where Recursion ends.

基本情况是递归结束的地方。

developer-side test framework 搭建

框架选择建议

测试框架

  • JUnit 5:事实标准,提供强大的断言、参数化测试、嵌套测试等特性
  • TestNG:功能丰富的替代方案,适合需要数据驱动测试的场景
  • Spock:基于 Groovy 的 BDD 风格测试框架,适合复杂业务逻辑验证

Mock 框架

  • Mockito:Java 生态最流行的 mock 框架,API 简洁直观
  • EasyMock:早期流行的 mock 框架,API 相对繁琐
  • PowerMock:支持 mock 静态方法和私有方法,但会破坏封装,慎用

断言库

  • AssertJ:流式 API,提供丰富的断言方法,推荐使用
  • Hamcrest:匹配器风格,可读性强但链式调用略显冗长
  • Truth:Google 出品的断言库,API 设计优雅

集成测试搭建步骤

1. 选择测试框架和测试插件

根据项目技术栈选择合适的测试框架组合。Spring Boot 项目推荐使用 JUnit 5 + Mockito + AssertJ。

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

2. 确定分包

对于 Gradle 项目,建议使用 source sets 进行测试代码分离:

1
2
3
4
5
6
7
sourceSets {
test {
java {
srcDirs 'src/test/unit', 'src/test/integration'
}
}
}

3. 建立基类

建立统一的测试基类,封装公共的初始化逻辑和配置。

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
@ActiveProfiles("test")
public abstract class BaseIntegrationTest {

@Autowired
protected TestRestTemplate restTemplate;

@BeforeEach
void setUp() {
// 公共初始化逻辑
}
}

4. 确定可插拔的 Mock 方法

全局 Mock 配置

通过配置文件或测试配置类定义全局 mock 行为。

1
2
3
4
5
6
7
8
9
@TestConfiguration
public class TestConfig {

@Bean
@Primary
public ExternalService externalService() {
return mock(ExternalService.class);
}
}

局部 Mock 替代

在测试类中通过 @MockBean 替换 Spring 容器中的 bean。

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class OrderServiceTest {

@MockBean
private PaymentService paymentService;

@Test
void shouldPlaceOrder() {
when(paymentService.charge(any())).thenReturn(true);
// 测试逻辑
}
}

5. 数据准备策略

确定测试数据的准备方式:使用内存数据库(H2)、TestContainers 或固定测试数据集。

6. Case 命名规范

采用统一的测试命名规范,推荐使用 should{ExpectedBehavior}When{StateUnderTest} 格式。

JUnit

JUnit 是事实上的单元测试框架标准,但事实上 JUnit is more than a unit test frame work, but a developer-side test framework。

JUnit 5 is modularized,composed of several modules: platform、vintage 和 jupiter。其中 The JUnit Platform serves as a foundation for launching testing frameworks on the JVM。在上面可以开发针对 JUnit 5 的 testframework,因为提供了专门的 TestEngine API。jupiter 是被重写的新 api,拥有新的 TestEngine 实现,而 vintage 兼容老版本的测试,拥有一个独特的 TestEngine 实现。

当代的 JUnit 已经变成了一个 annotation-driven 运行的 framework 了。全部的生命周期注解可以参考 JUnit 5 用户指南

生命周期注解

JUnit 5 提供了完整的生命周期注解,用于控制测试的初始化和清理过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.junit.jupiter.api.*;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LifecycleTest {

@BeforeAll
static void beforeAll() {
// 在所有测试方法执行前执行一次(静态方法)
System.out.println("BeforeAll - 初始化静态资源");
}

@BeforeEach
void setUp() {
// 在每个测试方法执行前执行
System.out.println("BeforeEach - 初始化测试数据");
}

@Test
void testOne() {
System.out.println("Test One");
}

@Test
void testTwo() {
System.out.println("Test Two");
}

@AfterEach
void tearDown() {
// 在每个测试方法执行后执行
System.out.println("AfterEach - 清理测试数据");
}

@AfterAll
static void afterAll() {
// 在所有测试方法执行后执行一次(静态方法)
System.out.println("AfterAll - 释放静态资源");
}
}

生命周期注解对比

注解 执行时机 方法类型 典型用途
@BeforeAll 所有测试前 static 初始化数据库连接、启动服务器
@BeforeEach 每个测试前 实例 创建测试对象、准备测试数据
@AfterEach 每个测试后 实例 清理测试数据、重置状态
@AfterAll 所有测试后 static 关闭数据库连接、停止服务器

参数化测试

JUnit 5 的 @ParameterizedTest 允许使用不同的输入值多次运行同一个测试方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "level"})
void palindromes(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({"apple, 10", "banana, 20", "orange, 15"})
void testWithCsvSource(String fruit, int quantity) {
assertNotNull(fruit);
assertTrue(quantity > 0);
}

@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void testWithMethodSource(String input, boolean expected) {
assertEquals(expected, StringUtils.isBlank(input));
}

static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of(null, true),
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}

嵌套测试

@Nested 注解用于组织相关的测试方法,使测试结构更清晰,支持嵌套类的生命周期继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@DisplayName("Stack 测试")
class StackTest {

Stack<Object> stack;

@BeforeEach
void createNewStack() {
stack = new Stack<>();
}

@Test
@DisplayName("新栈应该为空")
void isNewStack() {
assertTrue(stack.isEmpty());
}

@Nested
@DisplayName("推入元素后")
class AfterPushing {

String element = "an element";

@BeforeEach
void pushElement() {
stack.push(element);
}

@Test
@DisplayName("栈不应为空")
void isNotEmpty() {
assertFalse(stack.isEmpty());
}

@Test
@DisplayName("栈应该返回元素")
void returnElement() {
assertEquals(element, stack.pop());
}
}
}

条件执行

JUnit 5 提供了多种条件执行注解,根据运行环境或配置决定是否执行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
@EnabledOnOs(OS.MAC)
void onlyOnMac() {
// 仅在 macOS 上运行
}

@Test
@DisabledOnJre(JRE.JAVA_8)
void notOnJava8() {
// 不在 Java 8 上运行
}

@Test
@EnabledIfSystemProperty(named = "test.env", matches = "ci")
void onlyInCI() {
// 仅在 CI 环境中运行
}

@Test
@EnabledIf("customCondition()")
void customCondition() {
// 自定义条件
}

boolean customCondition() {
return System.getenv().containsKey("SPECIAL_ENV");
}

断言 API

JUnit 5 提供了丰富的断言方法,支持分组断言、异常断言、超时断言等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import static org.junit.jupiter.api.Assertions.*;

@Test
void groupedAssertions() {
assertAll("person",
() -> assertEquals("John", person.getFirstName()),
() -> assertEquals("Doe", person.getLastName()),
() -> assertEquals("john.doe@example.com", person.getEmail())
);
}

@Test
void exceptionTesting() {
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
person.setAge(-1);
});

assertEquals("Age cannot be negative", exception.getMessage());
}

@Test
void timeoutNotExceeded() {
assertTimeout(ofMillis(100), () -> {
// 在 100ms 内完成
Thread.sleep(50);
});
}

@Test
void timeoutExceeded() {
assertTimeoutPreemptively(ofMillis(100), () -> {
// 超过 100ms 会立即中断
Thread.sleep(200);
});
}
# Spring 与 Test

## Unit Test

### Mock Object

org.springframework.mock 下有 env、http、jndi、web 四个子包。它实际上包含 Environment、JNDI、Servlet API、Spring Web Reactive 这四类 mock objects。所谓的 mock objects 实际上是对一些 Spring 传统 API 接口的 mock implementation。

### Unit Testing Support Classes

#### General Testing Utilities

- **AopTestUtils**:用于获取 AOP 代理背后的目标对象
- **ReflectionTestUtils**:通过反射访问和修改私有字段和方法
- **TestSocketUtils**:查找可用的 TCP/UDP 端口

#### Spring MVC Testing Utilities

- **ModelAndViewAssert**:用于断言 ModelAndView 的内容

### MockMvc 独立模式

MockMvc 独立模式无需启动完整的 Spring 容器,仅加载 Web 层组件,适合测试 Controller 层逻辑。

```java
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class UserControllerTest {

private MockMvc mockMvc;

@BeforeEach
void setUp() {
UserController controller = new UserController(userService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}

@Test
void shouldReturnUser() throws Exception {
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.name", is("John")));
}
}

@WebMvcTest

@WebMvcTest 是 Spring Boot 提供的测试切片注解,仅加载 Web 层相关组件,自动配置 MockMvc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@WebMvcTest(UserController.class)
class UserControllerWebMvcTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private UserService userService;

@Test
void shouldReturnUser() throws Exception {
when(userService.findById(1L)).thenReturn(new User(1L, "John"));

mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("John")));
}
}

Integration Test

如果需要 deploy a server、connecting to other enterprise infrastructure、init a context。

集成测试的支持包括:

  • 管理 Spring IoC 容器在测试之间的缓存
  • 为测试 fixture 实例提供依赖注入,对固定装置对象的注入对于测试的帮助很大
  • 提供适合集成测试的事务管理
  • 提供帮助开发者编写集成测试的 Spring 特定基类

@SpringBootTest

@SpringBootTest 启动完整的 Spring 应用上下文,用于编写集成测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class OrderIntegrationTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private OrderRepository orderRepository;

@Test
void shouldCreateOrder() throws Exception {
String orderJson = "{\"productId\": 1, \"quantity\": 2}";

mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(orderJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists());
}
}

TestContext 缓存机制

Spring TestContext Framework 提供 ApplicationContext 缓存机制,避免在多个测试类之间重复启动容器。默认情况下,相同配置的测试类会共享同一个 ApplicationContext。

1
2
3
4
5
6
// 两个测试类使用相同的配置,共享同一个 ApplicationContext
@SpringBootTest
class TestClass1 { }

@SpringBootTest
class TestClass2 { }

@Transactional 回滚

测试方法上的 @Transactional 注解默认在测试结束后回滚事务,保持数据库干净。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootTest
@Transactional
class OrderRepositoryTest {

@Autowired
private OrderRepository orderRepository;

@Test
void shouldSaveOrder() {
Order order = new Order("product-123", 2);
orderRepository.save(order);

// 测试结束后自动回滚,数据库中不会保存该订单
assertNotNull(order.getId());
}
}

@Sql 数据准备

@Sql 注解用于在测试执行前或执行后执行 SQL 脚本,准备或清理测试数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
@Sql("/test-data.sql")
class OrderServiceTest {

@Test
void shouldFindAllOrders() {
List<Order> orders = orderService.findAll();
assertEquals(3, orders.size());
}

@Test
@Sql(scripts = "/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void shouldCleanUpAfterTest() {
// 测试结束后执行清理脚本
}
}

JDBC Testing Support

Spring Test 提供 JdbcTestUtils 用于简化数据库测试的 SQL 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.springframework.test.context.jdbc.Sql;
import org.springframework.jdbc.core.JdbcTemplate;

@SpringBootTest
class JdbcTest {

@Autowired
private JdbcTemplate jdbcTemplate;

@Test
@Sql(scripts = "/schema.sql", executionPhase = BEFORE_TEST_METHOD)
void shouldCountRows() {
int count = JdbcTestUtils.countRowsInTable(jdbcTemplate, "users");
assertEquals(5, count);

JdbcTestUtils.deleteFromTables(jdbcTemplate, "users");
assertEquals(0, JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"));
}
}

Spring TestContext Framework

Spring TestContext Framework 提供了测试执行的核心基础设施,支持:

  • ApplicationContext 的加载和管理
  • TestExecutionListener 的扩展机制
  • 测试上下文的缓存和共享

WebTestClient

WebTestClient 是用于测试 WebFlux 应用的响应式测试客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootTest
@AutoConfigureWebTestClient
class UserControllerTest {

@Autowired
private WebTestClient webTestClient;

@Test
void shouldReturnUser() {
webTestClient.get().uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.isEqualTo(new User(1L, "John"));
}
}

Testing Client Applications

使用 TestRestTemplate 测试 REST 客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest(webEnvironment = RANDOM_PORT)
class ClientIntegrationTest {

@LocalServerPort
private int port;

@Autowired
private TestRestTemplate restTemplate;

@Test
void shouldCallExternalApi() {
String url = "http://localhost:" + port + "/api/users/1";
ResponseEntity<User> response = restTemplate.getForEntity(url, User.class);

assertEquals(HttpStatus.OK, response.getStatusCode());
}
}

测试切片注解

Spring Boot 提供了多个测试切片注解,用于测试特定层而不加载完整的应用上下文。

@DataJpaTest

@DataJpaTest 仅加载 JPA 相关组件,配置内存数据库,适合测试 Repository 层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@DataJpaTest
class UserRepositoryTest {

@Autowired
private TestEntityManager entityManager;

@Autowired
private UserRepository userRepository;

@Test
void shouldFindUserByEmail() {
User user = new User("john@example.com", "John");
entityManager.persist(user);

Optional<User> found = userRepository.findByEmail("john@example.com");

assertTrue(found.isPresent());
assertEquals("John", found.get().getName());
}
}

@WebMvcTest

@WebMvcTest 仅加载 Web 层组件,自动配置 MockMvc。

@JsonTest

@JsonTest 仅加载 JSON 序列化相关组件,测试 JSON 序列化和反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@JsonTest
class UserJsonTest {

@Autowired
private JacksonTester<User> json;

@Test
void shouldSerializeUser() throws JsonProcessingException {
User user = new User(1L, "John");
assertThat(json.write(user)).extractingJsonPathStringValue("$.name")
.isEqualTo("John");
}

@Test
void shouldDeserializeUser() throws IOException {
String content = "{\"id\":1,\"name\":\"John\"}";
User user = json.parse(content).getObject();

assertEquals("John", user.getName());
}
}

Annotations

Spring Test 提供的核心注解:

  • @SpringBootTest:启动完整应用上下文
  • @WebMvcTest:测试 Web 层
  • @DataJpaTest:测试 JPA 层
  • @MockBean:替换 Spring 容器中的 bean
  • @TestConfiguration:提供测试特定的配置
  • @ActiveProfiles:激活指定的 profile
  • @Transactional:测试方法事务管理
  • @Sql:执行 SQL 脚本

Annotations

测试最佳实践

Given-When-Then 模式

Given-When-Then 是一种测试结构模式,将测试分为三个清晰的阶段:准备数据、执行操作、验证结果。

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void shouldCalculateTotalPrice() {
// Given: 准备测试数据
Product product = new Product("Laptop", 1000.0);
int quantity = 2;

// When: 执行被测操作
BigDecimal total = orderService.calculateTotal(product, quantity);

// Then: 验证结果
assertEquals(new BigDecimal("2000.00"), total);
}

这种结构使测试意图清晰,便于阅读和维护。

测试命名规范

良好的测试命名应该清晰表达测试的目的和预期行为。推荐使用 should{ExpectedBehavior}When{StateUnderTest} 格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 好的命名
@Test
void shouldReturnOrderNotFoundWhenOrderIdDoesNotExist() { }

@Test
void shouldThrowExceptionWhenQuantityIsNegative() { }

// 不好的命名
@Test
void testOrder() { }

@Test
void test1() { }

测试覆盖率策略

测试覆盖率是衡量测试完整性的指标,但不是唯一标准。合理的覆盖率策略应考虑:

  • 行覆盖率:至少达到 80%,核心业务代码应达到 100%
  • 分支覆盖率:确保所有条件分支都被测试
  • 方法覆盖率:公共 API 应该有完整的测试覆盖

避免为了追求覆盖率而编写无意义的测试,测试应该关注业务逻辑的正确性和边界条件。

🔑 模式提炼:测试三段式

模式公式GIVEN {测试准备} WHEN {执行操作} THEN {验证结果}

迁移表

测试类型 Given When Then
单元测试 准备 mock 对象和输入数据 调用被测方法 断言返回值和验证 mock
集成测试 准备数据库数据和请求 发送 HTTP 请求 断言响应状态和内容
端到端测试 登录用户并进入页面 执行用户操作 验证页面元素和状态

核心洞察:所有测试都可以抽象为准备、执行、验证三个阶段。听到"测试结构不清晰"、"测试难以理解"时,应想到测试三段式模式。

测试反模式

过度 Mock

过度 mock 是指对不应该 mock 的依赖进行 mock,导致测试失去了验证真实行为的能力。

1
2
3
4
5
6
7
8
9
10
11
// 过度 mock 的示例
@Test
void shouldCreateOrder() {
Order order = new Order();
when(orderRepository.save(order)).thenReturn(order); // 不必要的 mock
when(orderService.calculateTotal(any())).thenReturn(BigDecimal.ZERO); // 不必要的 mock

orderService.createOrder(order);

verify(orderRepository).save(order); // 仅验证调用,未验证实际效果
}

过度 mock 的后果包括:测试与实现细节耦合、无法发现设计问题、重构困难。

避免方法:只在测试真正的外部依赖时使用 mock,如数据库、外部 API、文件系统等。

脆弱测试

脆弱测试是指对实现细节过于敏感的测试,代码重构时容易失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 脆弱测试:依赖内部实现细节
@Test
void shouldProcessOrder() {
Order order = new Order();
orderService.processOrder(order);

// 直接验证内部状态,脆弱
assertEquals("PROCESSING", order.getStatus());
assertEquals(1, order.getAuditLog().size());
}

// 健壮测试:验证可观察的行为
@Test
void shouldSendOrderConfirmationEmail() {
Order order = new Order();
orderService.processOrder(order);

// 验证可观察的行为
verify(emailService).sendConfirmationEmail(order.getCustomerEmail());
}

避免方法:测试公共 API 和可观察的行为,而非内部实现细节。

测试间依赖

测试间依赖是指测试的执行依赖于其他测试的状态,导致测试无法独立运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 错误:测试间有依赖
@SpringBootTest
class OrderRepositoryTest {

@Autowired
private OrderRepository orderRepository;

@Test
void shouldCreateOrder() {
Order order = new Order("product-123", 1);
orderRepository.save(order);
// 未清理数据,影响后续测试
}

@Test
void shouldFindAllOrders() {
// 依赖上一个测试的数据
List<Order> orders = orderRepository.findAll();
assertFalse(orders.isEmpty());
}
}

// 正确:每个测试独立
@SpringBootTest
@Transactional
class OrderRepositoryTest {

@Autowired
private OrderRepository orderRepository;

@Test
void shouldCreateOrder() {
Order order = new Order("product-123", 1);
orderRepository.save(order);
assertNotNull(order.getId());
}

@Test
void shouldFindAllOrders() {
// 独立准备数据
Order order = new Order("product-456", 2);
orderRepository.save(order);

List<Order> orders = orderRepository.findAll();
assertEquals(1, orders.size());
}
}

避免方法:每个测试应该是独立的,使用 @Transactional 回滚或 @BeforeEach/@AfterEach 清理数据。

🔑 模式提炼:测试独立性

模式公式ENSURE {测试隔离} BY {独立准备} AND {独立清理} AND {无共享状态}

迁移表

场景 隔离方式 清理方式 避免共享
数据库测试 @Transactional 回滚 自动回滚 每个测试独立准备数据
文件系统测试 临时目录 @AfterEach 删除 使用唯一文件名
内存状态测试 每次新建对象 GC 自动回收 避免静态变量
Mock 对象 @MockBean 每次重新创建 自动重置 不跨测试共享

核心洞察:测试必须独立运行,顺序无关。听到"测试有时通过有时失败"、"单独运行通过但批量运行失败"时,应想到测试独立性模式。