跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

心跳接口已经拥有 Controller、Service、Repository、DTO 和统一异常处理。手动发送 curl 请求能够确认一次完整流程,但每次改代码都靠人工重复请求,既慢,也很难稳定覆盖异常分支。

自动化测试会准备输入、执行一小段目标代码,再断言结果是否符合契约。本章先测试不依赖 Spring 容器的普通 Java 对象,掌握 JUnit、AssertJ、测试结构和测试替身;下一篇再选择性加载 Spring MVC 基础设施。

1. 测试范围

不同测试覆盖的范围不同,运行成本和定位能力也不同:

测试范围是否启动 Spring是否使用真实基础设施主要验证
单元测试否否单个类的业务分支与对象转换
测试切片部分按切片决定MVC、JPA 等一层框架集成
应用集成测试是可以使用多层 Bean、配置与事务协作
端到端测试完整应用通常使用从真实入口到外部边界的整体行为

范围越大不代表测试越好。Service 名称规范化只需要直接创建对象;如果为了这一条规则启动 Web 服务器和 PostgreSQL,执行更慢,失败后也更难判断是哪一层出错。

当前 pom.xml 已经包含测试范围的 Web MVC 测试 Starter:

pom.xml
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:

src/test/java/cn/usehook/spring/health/HealthServiceTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import org.junit.jupiter.api.BeforeEach;
05
import org.junit.jupiter.api.Test;
06
07
import static org.assertj.core.api.Assertions.assertThat;
08
09
class HealthServiceTests {
10
11
private HealthService healthService;
12
13
@BeforeEach
14
void setUp() {
15
HealthProperties properties = new HealthProperties("UP", "spring");
16
HealthStatusRepository repository = new InMemoryHealthStatusRepository();
17
healthService = new HealthService(properties, repository);
18
}
19
20
@Test
21
void normalizesAndSavesServiceName() {
22
HealthStatus result = healthService.check(" PostgreSQL ");
23
24
assertThat(result).isEqualTo(new HealthStatus("UP", "postgresql"));
25
}
26
}

测试类和方法不需要写成 public。JUnit Jupiter 能发现包级可见的测试。@BeforeEach 在每个测试方法前重新执行,因此每个用例都取得新的内存 Repository,不会继承上一个用例保存的数据。

一个测试通常包含三个阶段:

test-phases.txt
1
准备 Arrange:创建依赖和输入
2
执行 Act:调用一个目标行为
3
断言 Assert:比较实际结果与预期契约

阶段不需要写成注释,但代码之间留出空行可以保持结构清楚。测试名称应该说明可观察行为,例如 normalizesAndSavesServiceName,而不是只重复方法名 testCheck。

3. 断言

断言负责让不符合预期的结果使测试失败。AssertJ 使用链式 API 表达期望:

assertions.java
1
assertThat(result.status()).isEqualTo("UP");
2
assertThat(result.service()).isEqualTo("postgresql");

HealthStatus 是 record,自动按所有组件实现值相等,因此直接比较完整对象更能表达契约:

record-assertion.java
1
assertThat(result).isEqualTo(new HealthStatus("UP", "postgresql"));

异常也是可观察结果。非法名称应该抛出明确业务异常:

exception-assertion.java
1
assertThatThrownBy(() -> healthService.check(" "))
2
.isInstanceOf(InvalidServiceNameException.class)
3
.hasMessageContaining("Invalid service name");

这段代码需要静态引入 org.assertj.core.api.Assertions.assertThatThrownBy。不要先用 try/catch 吞掉异常再让测试继续通过,否则测试无法证明异常确实发生。

集合断言应该直接表达顺序和内容:

collection-assertion.java
1
healthService.check("postgresql");
2
healthService.check("redis");
3
4
assertThat(healthService.findAll())
5
.extracting(HealthStatus::service)
6
.containsExactly("postgresql", "redis");

当 Repository 契约明确要求按服务名称排序时,containsExactly 同时验证元素和顺序;如果顺序不属于契约,才使用不关心顺序的断言。

4. 测试替身

Service 依赖 HealthStatusRepository 接口。测试可以传入不同实现,从而控制依赖行为:

替身特点适合场景
Fake有可工作的简化实现,例如内存 Repository希望通过状态和返回结果测试协作
Stub返回测试预先安排的值只需要控制依赖输出
Mock还能验证方法是否被调用及参数交互本身属于业务契约

前面的 InMemoryHealthStatusRepository 是 Fake。它适合验证保存后还能查询,但如果只想确认 Service 把规范化结果交给 Repository,可以使用 Mockito:

src/test/java/cn/usehook/spring/health/HealthServiceInteractionTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import org.junit.jupiter.api.Test;
05
06
import static org.assertj.core.api.Assertions.assertThat;
07
import static org.mockito.BDDMockito.given;
08
import static org.mockito.BDDMockito.then;
09
import static org.mockito.Mockito.mock;
10
11
class HealthServiceInteractionTests {
12
13
@Test
14
void savesNormalizedStatus() {
15
HealthStatusRepository repository = mock(HealthStatusRepository.class);
16
HealthService service = new HealthService(
17
new HealthProperties("UP", "spring"),
18
repository
19
);
20
HealthStatus expected = new HealthStatus("UP", "postgresql");
21
given(repository.save(expected)).willReturn(expected);
22
23
HealthStatus result = service.check(" PostgreSQL ");
24
25
assertThat(result).isEqualTo(expected);
26
then(repository).should().save(expected);
27
}
28
}

given 安排依赖返回值,then(...).should() 验证交互。只有调用次数和参数本身属于契约时才验证交互;对内部实现的每一步都写 mock 断言,会让合理重构也导致测试失败。

5. 隔离与可重复性

测试应该在任意顺序、任意机器和重复运行时得到相同结果。常见边界包括:

  • 每个测试自己准备必要数据,不依赖另一个测试先执行;
  • 不依赖当前时间时区、随机数或开发者机器上的环境变量;
  • 不访问真实生产服务;
  • 测试结束后不留下共享数据库数据或修改全局状态;
  • 断言公开行为,不依赖私有方法和局部变量。

时间确实属于业务输入时,可以给 Service 注入 Clock,测试传入固定时钟。随机标识可以注入生成器,外部系统则由 Fake、Mock 或受控测试容器替代。依赖可替换不是只为了单元测试,它也让业务代码明确声明自己真正需要什么。

不要为了测试私有方法而扩大其可见性。通过公开方法无法观察的私有分支通常只是实现细节;如果一个私有方法复杂到需要独立测试,可以考虑把它提取为职责明确的协作对象。

6. 执行与分层

运行全部测试:

terminal.bash
1
./mvnw test

只运行一个测试类:

terminal.bash
1
./mvnw -Dtest=HealthServiceTests test

一次测试失败时,先看失败类型:编译错误表示测试或生产代码类型不兼容,断言失败表示实际行为与预期不同,ApplicationContext 启动失败则通常属于配置或 Bean 装配问题。不要用重跑掩盖偶发失败,应找到共享状态、时间、并发或外部依赖等不稳定来源。

上一章已经确定方法、路径、状态码和版本属于外部契约,当前测试还只覆盖普通 Java 业务对象,没有证明这些 HTTP 约定能够工作。下一篇使用 @WebMvcTest 只加载 MVC 基础设施,验证 HTTP 边界并说明什么时候才需要完整的 @SpringBootTest;等实体与 JPA Repository 出现后,再使用 @DataJpaTest 验证数据访问切片。