第 12 章:接口测试
学习目标
- 编写 Controller 单测(MockMvc)
- 用 Mockito 做 Service 单测
- 用 Testcontainers 跑集成测试
一、为什么做接口测试
| 测试类型 | 速度 | 覆盖 | 适用 |
|---|---|---|---|
| 单元测试 | 毫秒级 | 业务逻辑 | Service、工具类 |
| 接口测试 | 秒级 | 请求/响应路径 | Controller |
| 集成测试 | 10 秒级 | 整链路 | 关键流程 |
接口测试是"业务正确性"的最佳保障——它验的是真正面向用户的代码路径。
二、MockMvc 测试 Controller
xml
<!-- 必备依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(username = "admin", authorities = {"user:create", "user:query"})
void testCreateUser_Success() throws Exception {
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("alice");
dto.setPassword("123456");
dto.setNickname("爱丽丝");
mockMvc.perform(post("/api/user")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("操作成功"));
}
@Test
void testCreateUser_Unauthorized() throws Exception {
// 没有 @WithMockUser → 未登录
mockMvc.perform(post("/api/user")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
@WithMockUser(username = "alice", authorities = {"user:create"})
void testCreateUser_ValidationError() throws Exception {
// 用户名太短
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("a");
dto.setPassword("123456");
mockMvc.perform(post("/api/user")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400));
}
}⚠️ 坑 1:
@WithMockUser给的权限字符串必须完全等于@PreAuthorize里的表达式。hasAuthority('user:create')不接受ROLE_user:create,前缀别加错。
三、Mockito 单元测试 Service
Service 不依赖 Spring 容器,直接用 Mockito:
java
@ExtendWith(MockitoExtension.class)
class UserServiceMockTest {
@Mock private UserMapper userMapper;
@Mock private PasswordEncoder passwordEncoder;
@Mock private JwtUtil jwtUtil;
@InjectMocks
private UserServiceImpl userService;
@Test
void testCreateUser() {
// ① Given
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("alice");
dto.setPassword("123456");
dto.setNickname("爱丽丝");
when(userMapper.selectByUsername("alice")).thenReturn(null);
when(passwordEncoder.encode("123456")).thenReturn("$2a$10$xxx");
when(userMapper.insert(any(User.class))).thenAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(1L);
return 1;
});
// ② When
Long id = userService.createUser(dto);
// ③ Then
assertEquals(1L, id);
verify(userMapper).insert(any(User.class));
verify(passwordEncoder).encode("123456");
}
@Test
void testCreateUser_DuplicateUsername() {
// ① Given - 用户名已存在
User existing = new User();
existing.setUsername("alice");
when(userMapper.selectByUsername("alice")).thenReturn(existing);
UserCreateDTO dto = new UserCreateDTO();
dto.setUsername("alice");
dto.setPassword("123456");
// ② When + Then
assertThrows(BusinessException.class, () -> userService.createUser(dto));
verify(userMapper, never()).insert(any(User.class)); // 不会触发 insert
}
}⚠️ 坑 2:
@ExtendWith(MockitoExtension.class)而不是@SpringBootTest。前者只加载 Mockito,毫秒级启动;后者加载整个 Spring 容器,几秒到十几秒。Service 层测试优先用 Mockito。
四、断言工具(AssertJ)
AssertJ 比 JUnit 自带的 assertEquals 流畅一百倍:
java
import static org.assertj.core.api.Assertions.*;
@Test
void testUserVO() {
UserVO vo = userService.getVOById(1L);
assertThat(vo)
.isNotNull()
.hasFieldOrPropertyWithValue("username", "admin")
.extracting(UserVO::getStatus).isEqualTo(1);
assertThat(vo.getRoles())
.isNotEmpty()
.extracting(RoleVO::getCode)
.contains("ROLE_ADMIN");
}五、覆盖率(JaCoCo)
xml
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum> <!-- 行覆盖不低于 70% -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>bash
mvn clean verify
# 报告路径:target/site/jacoco/index.html⚠️ 坑 3:覆盖率不是越高越好。盲目追求 100% 会让代码全是
if-else没意义的分支。关注核心业务路径(登录、权限校验、订单支付)的覆盖,工具类不必苛求。
六、Testcontainers 集成测试
真实数据库测,排除 MySQL/Redis 容器:
xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.1</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.20.1</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
</dependency>java
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("taskflow_test")
.withUsername("root")
.withPassword("test");
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private UserService userService;
@Autowired
private UserMapper userMapper;
@Test
void testLoginIntegration() {
// 真实数据库读写
User user = userMapper.selectByUsername("admin");
assertNotNull(user);
}
}七、Postman/Newman 自动化
Postman 写的接口测试可以纳入 CI:
bash
# 安装 newman
npm install -g newman
# 跑测试集合
newman run taskflow-api.postman_collection.json \
-e dev.postman_environment.json \
--reporters cli,html \
--reporter-html-export report.htmlCI 里加一步:
yaml
# .github/workflows/ci.yml
- name: API Tests
run: |
newman run tests/api/postman_collection.json \
--env-var "BASE_URL=http://localhost:8080"⚠️ 坑 4:Postman 测试的环境变量不要硬编码到 collection 里。每个环境(dev/test/prod)的 URL 不一样,得用
--env-var注入。
八、测试金字塔
▲
╱ ╲ E2E 测试(少量,关键流程)
╱───╲
╱ ╲
╱ API ╲ 接口测试(中等覆盖)
╱ 测试 ╲
╱───────────╲
╱ Service ╲ 单元测试(大量,覆盖业务逻辑)
╱ 单测 ╲
╱─────────────────╲配比:70% 单元测试 + 25% 接口测试 + 5% E2E 测试。
九、本章小结
| 要点 | 关键 |
|---|---|
| MockMvc | @SpringBootTest + @AutoConfigureMockMvc |
@WithMockUser | 模拟登录用户和权限 |
| Mockito | @Mock + @InjectMocks,Service 首选 |
| 覆盖率 | JaCoCo ≥ 70%,核心路径 100% |
| Testcontainers | 真实数据库测,排除 MySQL/Redis |
| 测试金字塔 | 70% 单测 + 25% 接口 + 5% E2E |
动手练习
- Controller 测试:为
UserController.create写 3 个测试(成功/未登录/参数错误) - Service 单测:用 Mockito 测
createUser的 2 个分支(成功 + 重名) - 看覆盖率:跑
mvn verify,打开target/site/jacoco/index.html看哪个类覆盖率低
下一章:第 13 章:部署上线 →