测试必备
配置信息
Spring Boot 提供了 spring-boot-starter-test 起步依赖,它集成了测试所需的主流框架。
xml
<dependencies>
<!-- Spring Boot Test 起步依赖(包含 JUnit 5、Mockito、AssertJ、Hamcrest 等) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers(用于集成测试中的真实数据库/中间件) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
核心库介绍

测试流程
一个健康的测试套件遵循测试金字塔原则:
单元测试(约占 70%) :快速、隔离,不启动 Spring 上下文
集成/切片测试(约占 20%) :启动部分 Spring 上下文
端到端测试(约占 10%) :启动完整应用
核心原则:能用纯单元测试就不用切片测试,能用切片测试就不用 @SpringBootTest。
单元测试
单元测试不启动 Spring 上下文,只测试单个类的业务逻辑,依赖通过 Mockito 模拟。
案例
被测试代码
java
// UserService.java
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found: " + id));
}
public User createUser(User user) {
if (user.getEmail() == null || user.getEmail().isEmpty()) {
throw new IllegalArgumentException("Email is required");
}
return userRepository.save(user);
}
}
单测代码
java
// UserServiceTest.java
// junit5
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
// mock
import static org.mockito.Mockito.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) // 启用 Mockito 支持
// MockitoExtension 是 JUnit 5 与 Mockito 的桥接扩展:
// 1. 自动初始化 @Mock 和 @InjectMocks 注解的字段,无需手动调用 MockitoAnnotations.openMocks()
// 2. 在测试方法执行前自动完成 Mock 对象的创建与依赖注入
// 3. 测试结束后自动校验 Mock 对象的调用(配合 verify 使用)
// 4. 默认启用严格模式(STRICT_STUBS),避免无用的打桩(unnecessary stubbing)导致测试误判
@DisplayName("UserService 单元测试")
class UserServiceTest {
@Mock // 创建 Mock 对象
private UserRepository userRepository;
@InjectMocks // 将 Mock 对象注入到被测 Service
private UserService userService;
@Test
@DisplayName("根据 ID 查询用户 - 用户存在时应返回用户")
void getUserById_WhenUserExists_ShouldReturnUser() {
// Arrange:准备测试数据
Long userId = 1L;
User expectedUser = new User(1L, "张三", "zhangsan@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// Act:执行被测方法
User actualUser = userService.getUserById(userId);
// Assert:验证结果
assertNotNull(actualUser);
assertEquals("张三", actualUser.getName());
// 验证 repository.findById 被调用了一次[reference:12]
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("根据 ID 查询用户 - 用户不存在时应抛异常")
void getUserById_WhenUserNotExists_ShouldThrowException() {
// Arrange
Long userId = 99L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(UserNotFoundException.class,
() -> userService.getUserById(userId));
verify(userRepository).findById(userId);
}
@Test
@DisplayName("创建用户 - 邮箱为空时应抛异常")
void createUser_WhenEmailEmpty_ShouldThrowException() {
// Arrange
User invalidUser = new User(null, "李四", "");
// Act & Assert
assertThrows(IllegalArgumentException.class,
() -> userService.createUser(invalidUser));
// 验证 repository.save 从未被调用
verify(userRepository, never()).save(any());
}
}
切片测试(特定层)
切片测试只加载应用的一部分 Bean,比 @SpringBootTest 快得多。
controller层
只加载 MVC 相关组件(Controller、Jackson、安全配置等),Service 层用 @MockitoBean 模拟。
案例-@WebMvcTest
待测controller
java
// UserController.java
@RestController
// 告诉 Spring "这是一个专门用来写后端 API 接口的类,返回的数据直接变成 JSON 给前端"。
// 相当于 @Controller + @ResponseBody。
@RequestMapping("/api/users")
// 定义这个控制器下所有接口的公共基础路径(URL 前缀)。
// 表示该类中所有具体的接口方法,其访问 URL 都会拼接上这个前缀
@RequiredArgsConstructor
// Lombok 库
// 自动生成包含 final 字段的构造函数,完美配合 Spring 实现构造器依赖注入。
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
// @PahVariable
// 从 URL 路径中提取占位符(变量)的值。
// @PathVariable 获取的是路径里的值:/users/100 -> 获取 100
// @RequestParam 获取的是问号后的值:/users?id=100 -> 获取 100
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.createUser(user));
}
// @Valid
// 触发 Bean Validation(数据校验),自动检查传入的数据是否符合规则
// @RequesBody
// 将 HTTP 请求体(Body)中的 JSON 数据 反序列化(转换)为 Java 对象。
}
// User对象
public class User {
@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 10, message = "用户名长度必须在2-10之间")
private String username;
@Email(message = "邮箱格式不正确")
private String email;
}
测试类
java
// UserControllerTest.java
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockitoBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@WebMvcTest(UserController.class)
// 只加载 UserController 相关 Bean
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // 用于模拟 HTTP 请求
@Autowired
private ObjectMapper objectMapper;
@MockitoBean // 模拟 Service 层 Bean
private UserService userService;
@Test
@DisplayName("GET /api/users/1 - 应返回用户 JSON")
void getUser_ShouldReturnUser() throws Exception {
// Arrange
User mockUser = new User(1L, "张三", "zhangsan@example.com");
when(userService.getUserById(1L)).thenReturn(mockUser);
// Act & Assert
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"));
// perform 执行这个构建好的请求,并将请求发送到 Spring MVC 的 DispatcherServlet 进行处理
// .andExpect 表示"期望/断言"返回的结果满足某个条件
}
@Test
@DisplayName("POST /api/users - 应创建用户并返回 201")
void createUser_ShouldReturnCreated() throws Exception {
// Arrange
User requestUser = new User(null, "李四", "lisi@example.com");
User savedUser = new User(2L, "李四", "lisi@example.com");
when(userService.createUser(any(User.class))).thenReturn(savedUser);
// Act & Assert
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestUser)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.name").value("李四"));
}
}
Repository 层
只加载 JPA 相关组件(Repository、EntityManager 等),默认使用内嵌内存数据库。
案例 H2内置数据库-@DataJpaTest
待测试类
java
// UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContaining(String keyword);
}
测试类
java
// UserRepositoryTest.java
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
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;
@DataJpaTest // 只加载 JPA 相关组件
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager; // 用于操作测试数据
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("根据邮箱查询用户 - 应返回匹配的用户")
void findByEmail_ShouldReturnUser() {
// Arrange
User user = new User(null, "王五", "wangwu@example.com");
entityManager.persist(user);
entityManager.flush();
// Act
Optional<User> found = userRepository.findByEmail("wangwu@example.com");
// Assert
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("王五");
}
@Test
@DisplayName("根据关键字模糊查询用户名 - 应返回匹配列表")
void findByNameContaining_ShouldReturnMatchingUsers() {
// Arrange
entityManager.persist(new User(null, "张三丰", "zhang@example.com"));
entityManager.persist(new User(null, "张无忌", "zhangwj@example.com"));
entityManager.persist(new User(null, "李四", "lisi@example.com"));
entityManager.flush();
// Act
List<User> users = userRepository.findByNameContaining("张");
// Assert
assertThat(users).hasSize(2);
assertThat(users).extracting(User::getName)
.containsExactlyInAnyOrder("张三丰", "张无忌");
}
}
案例 真实数据库-@AutoConfigureTestDatabase
java
application-test.yml + @AutoConfigureTestDatabase
测试配置文件
yml
# 在 src/test/resources 目录下创建 application-test.yml(或 .properties),配置真实的数据库连接
# src/test/resources/application-test.yml
spring:
datasource:
# 真实数据库驱动(以 MySQL 为例)
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
username: root
password: 123456
jpa:
# 为了让测试SQL看得清楚,开启SQL日志
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
hibernate:
# 测试环境建议 create-drop,每次启动建表,结束删表,保持干净
ddl-auto: create-drop
测试类
java
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;
// ★★★★★ 关键点1:禁用内嵌数据库替换,使用配置文件中的真实数据源 内嵌式数据库 @DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// ★★★★★ 关键点2:激活 test 配置文件,加载 application-test.yml
@ActiveProfiles("test")
@DisplayName("真实MySQL环境下的Repository测试")
class UserRepositoryRealDbTest {
@Autowired
private TestEntityManager entityManager; // 用于操作持久化上下文
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("真实DB测试:保存用户并成功查询")
void shouldSaveAndFindUserInRealDatabase() {
// Arrange:创建测试实体
User user = new User();
user.setName("真实数据库测试");
user.setEmail("realdb@example.com");
// Act:通过 EntityManager 持久化(这里会真实插入到你的 MySQL 中)
entityManager.persist(user);
entityManager.flush(); // 强制刷新,触发SQL执行
// Act:调用 Repository 查询
User found = userRepository.findByEmail("realdb@example.com").orElse(null);
// Assert:验证数据确实从真实数据库中查出来了
assertThat(found).isNotNull();
assertThat(found.getId()).isNotNull(); // 验证自增ID已回填
assertThat(found.getName()).isEqualTo("真实数据库测试");
// 注意:由于 ddl-auto 配置为 create-drop,测试结束后表会被删除
// 如果配置为 update,数据会残留,记得要清理(但测试不建议用 update)
}
}
集成测试
核心
@SpringBootTest 启动完整的 Spring 应用上下文,用于测试多层之间的集成。
@MockBean 与 @SpyBean 区别

案例-mockmvc
测试类
java
@SpringBootTest // 默认 webEnvironment = MOCK
class UserServiceIntegrationTest {
@Autowired
private UserService userService; // 注入真实的 Service
@MockitoBean
private UserRepository userRepository; // 模拟 Repository
@Test
void testGetUser() {
when(userRepository.findById(1L))
.thenReturn(Optional.of(new User(1L, "测试", "test@example.com")));
User user = userService.getUserById(1L);
assertThat(user.getName()).isEqualTo("测试");
}
}
案例-启动真实容器(RANDOM_PORT)
java
@SpringBootTest(classes = xxx.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Spring Boot 测试的核心注解
// 启动真实的 Spring 应用上下文(加载所有 Bean),用于做集成测试。
// classes = 指定启动类,告诉 Spring 从哪个入口加载配置。
// webEnvironment = 启动一个内嵌的 Web 服务器(如 Tomcat),并分配一个随机的可用端口。
@Slf4j
// 这是一个编译时注解,作用是在编译后的 class 文件中自动生成一个名为 log 的日志对象(org.slf4j.Logger),方便后续直接通过log.info/error打印日志
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
// JUnit Jupiter 提供)
// 用于控制测试方法的执行顺序。指明测试类中的测试方法将按照 @Order 注解的值(数字越小越先执行)依次执行。
class xxxControllerTest {
@LocalServerPort
// 当 webEnvironment 配置为 RANDOM_PORT 或 DEFINED_PORT 时,Spring 会将内嵌 Web 服务器实际分配的端口号,动态注入到该 int port 字段中。
private int port;
@Autowired
// Spring 的依赖注入标准注解
private TestRestTemplate restTemplate;
@Autowired
@Qualifier("db0StringRedisTemplate")
// 组合使用,是为了按名称指定注入特定的 RedisTemplate Bean。
private RedisTemplate<String, String> db0StringRedisTemplate;
@BeforeEach
// junit jupiter提供,表示该方法在每个测试方法执行之前都会运行一次。
void setUp() {}
@AfterEach
// junit jupiter提供,表示该方法在每个测试方法执行之后都会运行一次。
void tearDown() {}
@Test
@Order(1)
void testUpdateBlackAppid_close_success() {
String url = baseUrl + "/updateBlackAppid?appid={appid}&type={type}";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class, testAppid, "close");
assertEquals(HttpStatus.OK, response.getStatusCode(), "close请求应返回200");
String body = response.getBody();
assertNotNull(body, "响应体不应为null");
assertTrue(body.contains("\"code\":\"000\""),
"close操作应返回成功code=000,实际响应: " + body);
Boolean isMember = db0StringRedisTemplate.opsForSet().isMember(APPID_SET_KEY, testAppid);
assertTrue(Boolean.TRUE.equals(isMember), "close后appid应在Redis中");
log.info("testUpdateBlackAppid_close_success 通过");
}