关于 spring-boot-test
面向 Spring Boot 开发者的测试参考技能,涵盖应用上下文、Web、数据和序列化层测试,并说明注解、模拟、配置与常见问题。
主要功能
- 01使用 @SpringBootTest 进行全上下文测试
- 02针对 Web 和数据层的切片测试
- 03使用 MockMvc 测试请求与认证
- 04通过 @MockBean 和 @SpyBean 模拟 Bean
- 05测试属性与自定义配置
- 06AssertJ 断言与问题排查
适用场景
- 01不加载完整应用来测试控制器
- 02通过切片测试验证 JPA 仓库
- 03用模拟对象替换外部依赖
- 04排查运行缓慢或配置错误的测试
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill spring-boot-test安装后在 Claude Code 中输入/spring-boot-test确认已加载。
附带文件quick-ref/annotations.md
详细介绍
Spring Boot 测试
深入了解:使用
mcp__documentation__fetch_docs,并将技术指定为spring-boot-test,以获取全面的文档。
何时不应使用此技能
- 纯单元测试 - 使用
junit和 Mockito,避免启动 Spring 上下文,从而加快测试速度 - 使用真实数据库的集成测试 - 使用
spring-boot-integration和 Testcontainers - REST API 客户端测试 - 使用
rest-assured进行 HTTP 测试 - 端到端 Web 测试 - 使用 Selenium 或 Playwright
- 微服务契约测试 - 使用 Spring Cloud Contract
依赖项
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
包含:JUnit 5、Mockito、AssertJ、Hamcrest、JSONPath、Spring Test
测试注解
@SpringBootTest - 完整上下文
@SpringBootTest
class ApplicationTest {
@Autowired
private UserService userService;
@Test
void contextLoads() {
assertThat(userService).isNotNull();
}
}
// With web environment
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class WebApplicationTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
void healthCheck() {
ResponseEntity<String> response = restTemplate
.getForEntity("/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
切片测试(更快、更专注)
| 注解 | 层 | 自动配置 |
|---|---|---|
@WebMvcTest | 控制器 | MockMvc、Jackson |
@DataJpaTest | JPA 仓库 | TestEntityManager、DataSource |
@DataMongoTest | MongoDB | MongoTemplate |
@JsonTest | JSON 序列化 | JacksonTester |
@RestClientTest | REST 客户端 | MockRestServiceServer |
// Controller test - only loads web layer
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUser_ReturnsUser() throws Exception {
when(userService.findById(1L))
.thenReturn(Optional.of(new User(1L, "John")));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"));
}
}
// Repository test - uses embedded database
@DataJpaTest
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository repository;
@Test
void findByEmail_ReturnsUser() {
User user = new User("[email protected]", "Test User");
entityManager.persistAndFlush(user);
Optional<User> found = repository.findByEmail("[email protected]");
assertThat(found).isPresent()
.hasValueSatisfying(u -> assertThat(u.getName()).isEqualTo("Test User"));
}
}
MockMvc
基本请求
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
// GET request
@Test
void getUsers() throws Exception {
when(userService.findAll()).thenReturn(List.of(
new User(1L, "Alice"),
new User(2L, "Bob")
));
mockMvc.perform(get("/api/users")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].name").value("Alice"));
}
// POST request with JSON body
@Test
void createUser() throws Exception {
CreateUserRequest request = new CreateUserRequest("John", "[email protected]");
User createdUser = new User(1L, "John", "[email protected]");
when(userService.create(any())).thenReturn(createdUser);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "John",
"email": "[email protected]"
}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(1));
}
// PUT request
@Test
void updateUser() throws Exception {
mockMvc.perform(put("/api/users/{id}", 1)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Updated Name"}
"""))
.andExpect(status().isOk());
verify(userService).update(eq(1L), any());
}
// DELETE request
@Test
void deleteUser() throws Exception {
mockMvc.perform(delete("/api/users/{id}", 1))
.andExpect(status().isNoContent());
verify(userService).delete(1L);
}
}
使用身份验证
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
// Using @WithMockUser
@Test
@WithMockUser(roles = "ADMIN")
void adminEndpoint_WithAdminRole_Succeeds() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void adminEndpoint_WithUserRole_Forbidden() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isForbidden());
}
// Using JWT token
@Test
void withJwtToken() throws Exception {
String token = jwtTokenProvider.createToken("admin", List.of("ROLE_ADMIN"));
mockMvc.perform(get("/api/admin/dashboard")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
}
模拟
@MockBean
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@MockBean // Replaces bean in context with mock
private PaymentGateway paymentGateway;
@MockBean
private InventoryService inventoryService;
@Test
void placeOrder_WhenPaymentSucceeds_CreatesOrder() {
when(inventoryService.checkStock(any())).thenReturn(true);
when(paymentGateway.charge(any())).thenReturn(PaymentResult.success());
Order order = orderService.placeOrder(new OrderRequest(...));
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
verify(paymentGateway).charge(any());
}
@Test
void placeOrder_WhenPaymentFails_ThrowsException() {
when(paymentGateway.charge(any()))
.thenThrow(new PaymentException("Card declined"));
assertThatThrownBy(() -> orderService.placeOrder(new OrderRequest(...)))
.isInstanceOf(PaymentException.class)
.hasMessage("Card declined");
}
}
@SpyBean
@SpringBootTest
class NotificationServiceTest {
@Autowired
private NotificationService notificationService;
@SpyBean // Wraps real bean, allows partial mocking
private EmailSender emailSender;
@Test
void sendNotification_CallsEmailSender() {
notificationService.notify(user, "Hello");
verify(emailSender).send(eq(user.getEmail()), any());
}
@Test
void sendNotification_WhenEmailFails_LogsError() {
doThrow(new EmailException("SMTP error"))
.when(emailSender).send(any(), any());
// Method should handle exception gracefully
assertThatCode(() -> notificationService.notify(user, "Hello"))
.doesNotThrowAnyException();
}
}
测试配置
测试属性
@SpringBootTest
@TestPropertySource(properties = {
"app.feature.enabled=true",
"app.external.url=http://localhost:8080"
})
class FeatureTest { }
// Or use test profile
@SpringBootTest
@ActiveProfiles("test")
class ProfileTest { }
application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
app:
external:
url: http://localhost:${wiremock.server.port}
自定义测试配置
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public Clock testClock() {
return Clock.fixed(
Instant.parse("2025-01-15T10:00:00Z"),
ZoneId.of("UTC")
);
}
@Bean
@Primary
public PaymentGateway testPaymentGateway() {
return new FakePaymentGateway();
}
}
@SpringBootTest
@Import(TestConfig.class)
class TimeBasedFeatureTest { }
AssertJ 断言
// Basic assertions
assertThat(user.getName()).isEqualTo("John");
assertThat(user.getAge()).isGreaterThan(18);
assertThat(user.getEmail()).contains("@").endsWith(".com");
// Collection assertions
assertThat(users)
.hasSize(3)
.extracting(User::getName)
.containsExactly("Alice", "Bob", "Charlie");
// Exception assertions
assertThatThrownBy(() -> service.process(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null");
// Optional assertions
assertThat(repository.findById(1L))
.isPresent()
.hasValueSatisfying(user ->
assertThat(user.getName()).isEqualTo("John")
);
// Soft assertions (collect all failures)
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(user.getName()).isEqualTo("John");
softly.assertThat(user.getEmail()).contains("@");
softly.assertThat(user.getAge()).isPositive();
});
测试数据构建器
public class UserTestBuilder {
private Long id = 1L;
private String name = "Test User";
private String email = "[email protected]";
private UserRole role = UserRole.USER;
public static UserTestBuilder aUser() {
return new UserTestBuilder();
}
public UserTestBuilder withId(Long id) {
this.id = id;
return this;
}
public UserTestBuilder withName(String name) {
this.name = name;
return this;
}
public UserTestBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserTestBuilder withRole(UserRole role) {
this.role = role;
return this;
}
public UserTestBuilder asAdmin() {
this.role = UserRole.ADMIN;
return this;
}
public User build() {
return new User(id, name, email, role);
}
}
// Usage
User admin = aUser().withName("Admin").asAdmin().build();
User regularUser = aUser().build();
反模式
| 反模式 | 不良之处 | 解决方案 |
|---|---|---|
| 对所有测试都使用 @SpringBootTest | 速度极慢 | 使用切片测试(@WebMvcTest、@DataJpaTest) |
| 不使用 @MockBean | 测试真实 Bean | 模拟外部依赖 |
| 在测试中硬编码端口 | 端口冲突 | 使用 @LocalServerPort 和 RANDOM_PORT |
| 测试私有方法 | 与实现细节耦合 | 通过控制器/服务 API 进行测试 |
| 不隔离测试数据 | 测试相互干扰 | 使用 @Transactional,或在 @AfterEach 中清理 |
| 忽略 @Sql 脚本 | 重复手动设置 | 使用 @Sql 设置测试数据 |
| 不使用测试配置文件 | 污染开发/生产配置 | 使用 @ActiveProfiles("test") |
快速故障排查
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| "Unable to find @SpringBootConfiguration" | 未找到主类 | 添加 @SpringBootTest(classes = App.class) |
| 测试非常慢 | 不必要地使用 @SpringBootTest | 使用切片测试(@WebMvcTest 等) |
| "No qualifying bean" | 缺少 @MockBean | 为依赖项添加 @MockBean |
| 端口已被占用 | 端口硬编码 | 使用 webEnvironment = RANDOM_PORT |
| "Could not autowire" | Bean 不在测试上下文中 | 检查组件扫描,或添加 @Import |
| 测试不稳定 | 数据库状态未重置 | 使用 @Transactional 或 @DirtiesContext |
参考
- 快速参考:注解
- Spring Boot 测试指南
- 另请参阅:
spring-boot-integration,了解 Testcontainers
评论
支持 Markdown