Skip to content
第 12 章 ⏱ 10 分钟阅读

第 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.html

CI 里加一步:

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

动手练习 ​

  1. Controller 测试:为 UserController.create 写 3 个测试(成功/未登录/参数错误)
  2. Service 单测:用 Mockito 测 createUser 的 2 个分支(成功 + 重名)
  3. 看覆盖率:跑 mvn verify,打开 target/site/jacoco/index.html 看哪个类覆盖率低

下一章:第 13 章:部署上线 →

本站基于 VitePress 构建 · 由 StackHub 团队维护