心跳接口已经拥有 Controller、Service、Repository、DTO 和统一异常处理。手动发送 curl 请求能够确认一次完整流程,但每次改代码都靠人工重复请求,既慢,也很难稳定覆盖异常分支。
自动化测试会准备输入、执行一小段目标代码,再断言结果是否符合契约。本章先测试不依赖 Spring 容器的普通 Java 对象,掌握 JUnit、AssertJ、测试结构和测试替身;下一篇再选择性加载 Spring MVC 基础设施。
1. 测试范围
不同测试覆盖的范围不同,运行成本和定位能力也不同:
| 测试范围 | 是否启动 Spring | 是否使用真实基础设施 | 主要验证 |
|---|---|---|---|
| 单元测试 | 否 | 否 | 单个类的业务分支与对象转换 |
| 测试切片 | 部分 | 按切片决定 | MVC、JPA 等一层框架集成 |
| 应用集成测试 | 是 | 可以使用 | 多层 Bean、配置与事务协作 |
| 端到端测试 | 完整应用 | 通常使用 | 从真实入口到外部边界的整体行为 |
范围越大不代表测试越好。Service 名称规范化只需要直接创建对象;如果为了这一条规则启动 Web 服务器和 PostgreSQL,执行更慢,失败后也更难判断是哪一层出错。
当前 pom.xml 已经包含测试范围的 Web MVC 测试 Starter:
1<dependency>2<groupId>org.springframework.boot</groupId>3<artifactId>spring-boot-starter-webmvc-test</artifactId>4<scope>test</scope>5</dependency>
它提供当前案例使用的 JUnit Jupiter、AssertJ、Mockito 和 Spring MVC 测试支持。test 作用域表示这些类型参与测试编译和运行,但不会进入生产应用的运行依赖。
2. JUnit 测试
JUnit 使用 @Test 标记测试方法。下面直接创建内存 Repository 和 Service,不加载 ApplicationContext:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import org.junit.jupiter.api.BeforeEach;05import org.junit.jupiter.api.Test;0607import static org.assertj.core.api.Assertions.assertThat;0809class HealthServiceTests {1011private HealthService healthService;1213@BeforeEach14void setUp() {15HealthProperties properties = new HealthProperties("UP", "spring");16HealthStatusRepository repository = new InMemoryHealthStatusRepository();17healthService = new HealthService(properties, repository);18}1920@Test21void normalizesAndSavesServiceName() {22HealthStatus result = healthService.check(" PostgreSQL ");2324assertThat(result).isEqualTo(new HealthStatus("UP", "postgresql"));25}26}
测试类和方法不需要写成 public。JUnit Jupiter 能发现包级可见的测试。@BeforeEach 在每个测试方法前重新执行,因此每个用例都取得新的内存 Repository,不会继承上一个用例保存的数据。
一个测试通常包含三个阶段:
1准备 Arrange:创建依赖和输入2执行 Act:调用一个目标行为3断言 Assert:比较实际结果与预期契约
阶段不需要写成注释,但代码之间留出空行可以保持结构清楚。测试名称应该说明可观察行为,例如 normalizesAndSavesServiceName,而不是只重复方法名 testCheck。
3. 断言
断言负责让不符合预期的结果使测试失败。AssertJ 使用链式 API 表达期望:
1assertThat(result.status()).isEqualTo("UP");2assertThat(result.service()).isEqualTo("postgresql");
HealthStatus 是 record,自动按所有组件实现值相等,因此直接比较完整对象更能表达契约:
1assertThat(result).isEqualTo(new HealthStatus("UP", "postgresql"));
异常也是可观察结果。非法名称应该抛出明确业务异常:
1assertThatThrownBy(() -> healthService.check(" "))2.isInstanceOf(InvalidServiceNameException.class)3.hasMessageContaining("Invalid service name");
这段代码需要静态引入 org.assertj.core.api.Assertions.assertThatThrownBy。不要先用 try/catch 吞掉异常再让测试继续通过,否则测试无法证明异常确实发生。
集合断言应该直接表达顺序和内容:
1healthService.check("postgresql");2healthService.check("redis");34assertThat(healthService.findAll())5.extracting(HealthStatus::service)6.containsExactly("postgresql", "redis");
当 Repository 契约明确要求按服务名称排序时,containsExactly 同时验证元素和顺序;如果顺序不属于契约,才使用不关心顺序的断言。
4. 测试替身
Service 依赖 HealthStatusRepository 接口。测试可以传入不同实现,从而控制依赖行为:
| 替身 | 特点 | 适合场景 |
|---|---|---|
| Fake | 有可工作的简化实现,例如内存 Repository | 希望通过状态和返回结果测试协作 |
| Stub | 返回测试预先安排的值 | 只需要控制依赖输出 |
| Mock | 还能验证方法是否被调用及参数 | 交互本身属于业务契约 |
前面的 InMemoryHealthStatusRepository 是 Fake。它适合验证保存后还能查询,但如果只想确认 Service 把规范化结果交给 Repository,可以使用 Mockito:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import org.junit.jupiter.api.Test;0506import static org.assertj.core.api.Assertions.assertThat;07import static org.mockito.BDDMockito.given;08import static org.mockito.BDDMockito.then;09import static org.mockito.Mockito.mock;1011class HealthServiceInteractionTests {1213@Test14void savesNormalizedStatus() {15HealthStatusRepository repository = mock(HealthStatusRepository.class);16HealthService service = new HealthService(17new HealthProperties("UP", "spring"),18repository19);20HealthStatus expected = new HealthStatus("UP", "postgresql");21given(repository.save(expected)).willReturn(expected);2223HealthStatus result = service.check(" PostgreSQL ");2425assertThat(result).isEqualTo(expected);26then(repository).should().save(expected);27}28}
given 安排依赖返回值,then(...).should() 验证交互。只有调用次数和参数本身属于契约时才验证交互;对内部实现的每一步都写 mock 断言,会让合理重构也导致测试失败。
5. 隔离与可重复性
测试应该在任意顺序、任意机器和重复运行时得到相同结果。常见边界包括:
- 每个测试自己准备必要数据,不依赖另一个测试先执行;
- 不依赖当前时间时区、随机数或开发者机器上的环境变量;
- 不访问真实生产服务;
- 测试结束后不留下共享数据库数据或修改全局状态;
- 断言公开行为,不依赖私有方法和局部变量。
时间确实属于业务输入时,可以给 Service 注入 Clock,测试传入固定时钟。随机标识可以注入生成器,外部系统则由 Fake、Mock 或受控测试容器替代。依赖可替换不是只为了单元测试,它也让业务代码明确声明自己真正需要什么。
不要为了测试私有方法而扩大其可见性。通过公开方法无法观察的私有分支通常只是实现细节;如果一个私有方法复杂到需要独立测试,可以考虑把它提取为职责明确的协作对象。
6. 执行与分层
运行全部测试:
1./mvnw test
只运行一个测试类:
1./mvnw -Dtest=HealthServiceTests test
一次测试失败时,先看失败类型:编译错误表示测试或生产代码类型不兼容,断言失败表示实际行为与预期不同,ApplicationContext 启动失败则通常属于配置或 Bean 装配问题。不要用重跑掩盖偶发失败,应找到共享状态、时间、并发或外部依赖等不稳定来源。
上一章已经确定方法、路径、状态码和版本属于外部契约,当前测试还只覆盖普通 Java 业务对象,没有证明这些 HTTP 约定能够工作。下一篇使用 @WebMvcTest 只加载 MVC 基础设施,验证 HTTP 边界并说明什么时候才需要完整的 @SpringBootTest;等实体与 JPA Repository 出现后,再使用 @DataJpaTest 验证数据访问切片。