上一部分我们把“持续推送任务变化”拆进了独立的响应式服务。到这里,TaskHub 已经不再是一个只有两三个接口的演示项目:它有任务状态、有参数校验、有分页查询,有关系数据库,也有传统 Spring MVC 与 WebFlux 两条请求链路。功能越多,改动时越容易出现一种很磨人的局面——应用能启动,手动点几下也没看出问题,可某个不起眼的分支已经悄悄坏了。
比如,我们把创建任务时的 title.trim() 删掉,接口仍然返回 201;把查询条件从“标题包含关键字”写成“标题等于关键字”,应用同样能正常启动;把异常处理器遗漏后,成功请求也看不出任何区别。这些问题不会被 contextLoads() 发现,因为“Spring 容器能够创建出来”和“业务行为符合约定”是两件事。
这一部分我们给 TaskHub 建一套有层次的测试防线。它不是把所有类都套上 @SpringBootTest,也不是为了追求一个好看的覆盖率数字。我们的目标很具体:业务分支由纯单元测试守住,HTTP 契约由 MVC 切片守住,实体映射与查询由 JPA 切片守住,最后再用少量完整集成测试确认整条线路能接通。
你会看到,前面一直使用构造器注入,并不只是代码风格。到了测试里,我们可以把真实的 TaskRepository 换成 Mockito 模拟对象,只测试 TaskService 的判断和转换;也可以反过来只加载 Repository 与 JPA,避开 Controller 和 Security。依赖注入换来的可测试性,会在这一部分真正落地。
很多人第一次写 Spring Boot 测试,会下意识地给每个测试类都加上 @SpringBootTest。理由也很直观:既然完整应用能跑起来,测到的东西应该更多。问题是,“加载得多”和“测试得准”并不是同一件事。
假设 TaskService.create() 的标题去空格逻辑失败了。一个完整上下文测试可能需要先初始化 Web、JPA、安全过滤器和数据库,失败信息却只是“期望标题为 写完接口测试,实际是前后带空格的字符串”。这些额外组件没有帮助我们判断错误,反而让启动时间更长、日志更嘈杂。纯单元测试只创建 TaskService 和一个模拟 Repository,失败时视线会直接落在业务方法上。
反过来,如果我们想确认 @NotBlank 能否把空标题变成 400,直接调用 Controller 方法又不够。参数校验、JSON 反序列化、@RequestBody、异常处理器和响应状态都由 Spring MVC 基础设施参与;这时就应该加载 Web 切片,并通过 MockMvc 发一个模拟 HTTP 请求。
TaskHub 的测试金字塔可以分成四层:
最下面的测试数量多、速度快,应该在每次保存代码后都能运行。越往上,加载的真实组件越多,覆盖的边界更宽,但定位成本也更高。这个“金字塔”并不规定死板比例,它提醒我们:先用最小的测试范围证明一个事实,只有这个事实确实跨越了边界,才扩大上下文。

测试名称最好能说出场景和结果,例如 missingTaskThrowsTaskNotFoundException。testCreate1 只告诉我们“这是一个测试”,失败时仍要打开方法才能知道它在保护什么。清楚的名称本身就是一份可执行的行为说明。
课程工程统一使用 Spring Boot 4.1.0。通过 Initializr 创建项目时,我们已经选择了 Web MVC、Data JPA、Validation、Security 与 Testcontainers,所以 pom.xml 里会看到按功能拆分的测试起步依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</
spring-boot-starter-test 带来 JUnit Jupiter、AssertJ、Mockito 等通用工具;另外几个 -test 模块提供对应的 Spring Boot 测试自动配置。所有依赖都使用 test 作用域,它们只参加测试编译与执行,不会被装进生产应用。
如果你在网上看到下面这些写法,先检查资料对应的 Spring Boot 版本:
org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
org.springframework.boot.test.mock.mockito.MockBean
org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest在本课程使用的 Spring Boot 4.1.0 中,我们会使用新的包路径,并用 Spring Framework 提供的 @MockitoBean 覆盖上下文里的 Bean:
org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
org.springframework.test.context.bean.override.mockito.MockitoBean
org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest这类差异很容易造成“注解明明照着写了,IDE 却找不到”的困惑。它通常不是测试原理变了,只是模块和包名经过了整理。不要为了让旧代码编译,手工把一套旧版测试依赖塞进新项目;先以当前项目的 Spring Boot 版本为准。
测试源文件放在 src/test/java 下,包名尽量与被测试类保持一致:
src/test/java/com/welearn/taskhub/
├── task/
│ ├── TaskServiceTest.java
│ ├── TaskControllerTest.java
│ ├── TaskRepositoryTest.java
│ └── TaskApiIntegrationTest.java
└── TestcontainersConfiguration.java同包放置还有一个实际好处:测试不需要为了访问包级可见的构造器或方法而把生产代码改成 public。测试应当适应合理的封装边界,不要反过来为了测试破坏封装。
在 IDE 里点击测试方法旁边的小三角时,启动的不是 Spring Boot 主类,而是 JUnit Platform。Platform 找到 Jupiter 测试引擎,再由引擎发现带有 @Test、@ParameterizedTest 等注解的方法。对一个普通测试类,JUnit 默认会为每个测试方法创建一个新的测试类实例,然后依次执行 @BeforeEach、测试方法和 @AfterEach。
“每个方法一个实例”解释了一个常见现象:你在测试 A 的字段里保存了值,测试 B 通常看不到。这个默认隔离能减少用例之间的状态污染,但它不会自动清理数据库、文件和静态变量。数据库回滚来自 Spring Test 的事务支持,不是 JUnit 自己做的;Mockito 模拟对象的初始化来自 MockitoExtension;ApplicationContext 的创建与缓存则来自 SpringExtension。看到一个注解时,要分清究竟是哪一层扩展在工作。
@BeforeEach 适合构造每条用例都需要、而且创建成本很低的对象。不要把整套共享业务数据塞进去。测试读者往往先打开失败方法,如果准备条件藏在很远的父类和十几个辅助方法里,就很难看出“为什么这个场景会发生”。本章示例保留少量公共装配,场景数据仍放在测试方法附近。
JUnit 负责执行,AssertJ 负责表达判断,Mockito 负责替换协作者。三者与 Spring Test 的边界可以这样记:
知道这四个角色后,报错也容易归类。No tests found 先看 JUnit 发现规则;断言差异看业务结果;Wanted but not invoked 看 Mockito 交互;Failed to load ApplicationContext 再去看 Spring 配置。不要把所有测试问题都归结为“Spring 没配好”。
下面两种数据都能创建任务,但第二种更适合测试:
var task = new Task("a", "b", 1, LocalDate.now());
var task = new Task(
"补齐异常测试",
"找不到任务时返回 ProblemDetail",
5,
LocalDate.of(2026, 8, 20));测试数据不是越短越好。a、b 让读者看不出标题和描述谁是谁,也看不出断言为什么选择这个值。具体但不过度冗长的数据能把场景直接写进代码。日期尽量固定,避免 LocalDate.now() 在跨午夜、时区不同的机器或未来重放时改变结果;只有测试本来就在验证“今天”时,才应该把时钟纳入场景。
当同一实体需要十几个字段时,可以建立小型测试对象工厂,例如 aTodoTask(),但工厂名称和参数必须保留重要差异。一个无所不能的 builder 如果默认填了几十个隐藏字段,失败时同样难查。最好的测试数据不是最抽象的数据,而是读者从方法里就能看懂前提的数据。
先从 TaskService 开始。它依赖 TaskRepository,负责修整输入、选择查询分支、在找不到任务时抛出异常,并把实体转换为 TaskResponse。这些行为都不需要数据库,也不需要 Spring 容器。
生产代码通过构造器声明依赖:
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
// 业务方法省略
}运行应用时,Spring 从 ApplicationContext 找到真实的 TaskRepository Bean,把它交给构造器。纯单元测试不启动 ApplicationContext,而是把同一位置换成 Mockito 创建的模拟对象:
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock
TaskRepository repository;
TaskService service;
@BeforeEach
void setUp() {
service = new TaskService(repository);
}
}这里没有魔法。@Mock 让 Mockito 创建一个实现了 TaskRepository 接口的测试替身,setUp() 直接调用普通 Java 构造器。我们甚至没有使用 @InjectMocks,因为显式构造能一眼看清被测对象只有哪一个依赖。将来 TaskService 多出一个 Clock 或事件发布器,构造器和测试都会立刻提醒我们补上它。

这就是前面所说的“你只报需求,容器负责组装”带来的现实收益。TaskService 没有在内部写 new TaskRepository(...),也没有到某个全局位置查找 Repository,所以测试可以在不修改业务代码的前提下替换依赖。
一个测试最好只有一个清楚的故事:先准备条件,再执行动作,最后核对结果。下面测试“创建任务时会修剪标题,并把实体交给 Repository 保存”:
package com.welearn.taskhub.task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
@ExtendWith(MockitoExtension.class)
class
given(...).willAnswer(...) 是预设模拟对象的行为,也叫 stubbing。真实 Repository 会把实体写进数据库并返回保存后的对象;这里不需要验证 JPA,只让模拟对象把参数原样返回,就足够让业务方法走完。
ArgumentCaptor 捕获传给 repository.save() 的 Task。只断言返回值有时会漏掉问题:假设 TaskService 返回了修剪后的标题,却把未修剪的实体交给 Repository,API 看起来正常,数据库里仍会存下空格。捕获依赖参数可以把这条缝补上。
Mockito 的 verify 或 BDD 风格的 then(...).should() 应当用来验证有业务含义的交互。不要把实现中的每次 getter 调用都验证一遍,那会把测试绑死在内部步骤上。我们真正关心的是“合法创建必须保存一次”,以及“已知失败不应该碰数据库”。
单元测试通常有两类观察角度。状态断言检查返回值或对象变化,例如创建响应的标题已经去掉空格;交互断言检查被测对象如何跨过依赖边界,例如 Repository 的 save 被调用一次。两者并不是越多越好。
如果一个纯函数把任务状态转换成中文标签,只断言返回值就够了。它没有外部协作者,验证调用步骤反而是在复制实现。TaskService.create() 不同:保存是这次业务操作不可缺少的副作用。只断言返回对象,无法证明数据真的交给了 Repository,所以需要一条交互断言。
模拟对象的预设也应该保持最小。假设当前场景只会调用 findById,就不要顺手给 findAll、save、existsById 都设置返回值。多余预设会让测试看上去像在描述复杂流程,实际却没有被执行;Mockito 的严格模式报告无用 stubbing,正是在帮我们删掉噪声。
有些初学者会把 given(...).willReturn(...) 当成“调用了一次方法”。它只是登记规则:将来如果收到这个调用,就给出指定答案。真正执行发生在调用 service.get(...) 时。验证则放在动作之后,检查调用是否真的出现。把“预设”和“验证”分开理解,能避免在 Arrange 阶段误以为业务已经运行。
模拟返回实体时也要尊重真实边界。数据库生成的 id 如果参与响应断言,可以在测试数据里准备一个带 ID 的对象,或者只断言当前方法真正负责的标题和状态。不要用反射强行篡改一堆 JPA 内部字段,只为让单元测试看起来像数据库。主键生成是否工作,应交给 JPA 切片。
创建成功、空标题失败、Repository 抛异常、事件发布失败和事务回滚,是五个不同场景。把它们塞进一个方法,任何一个断言失败都会让剩余断言无法运行,测试名称也无法准确描述责任。
拆分并不等于每个 getter 写一条测试。判断边界是“失败时是否指向同一个原因”。标题修整与默认状态都在同一次对象创建中产生,可以放在一起;找不到任务和删除成功的控制流不同,就应分开。这个尺度比机械追求“一条测试一个断言”更实用。
找不到任务时,TaskService.get() 应抛出 TaskNotFoundException。这个场景除了检查异常类型与消息,还应该确认 Repository 没有发生额外调用:
@Test
void missingTaskThrowsTaskNotFoundException() {
given(repository.findById(404L)).willReturn(java.util.Optional.empty());
assertThatThrownBy(() -> service.get(404L))
.isInstanceOf(TaskNotFoundException.class)
.hasMessage("没有找到编号为 404 的任务");
then(repository).should().findById(404L);
then
这条测试把 Service 层的责任钉得很清楚:Repository 只负责回答“有没有”,Service 决定没有时抛什么业务异常。至于这个异常最终如何变成 404 ProblemDetail,那属于 Web 边界,留给 MVC 切片测试。
删除逻辑也适合验证负面交互。找不到任务时,delete() 不应继续执行 repository.delete(...):
@Test
void deleteDoesNotWriteWhenTaskIsMissing() {
given(repository.findById(99L)).willReturn(java.util.Optional.empty());
assertThatThrownBy(() -> service.delete(99L))
.isInstanceOf(TaskNotFoundException.class);
then(repository).should().findById(99L);
then(repository).shouldHaveNoMoreInteractions();
}这里如果只写“抛出了异常”,以后有人先调用 deleteById()、再检查是否存在,测试也可能通过。shouldHaveNoMoreInteractions() 把“失败路径不得写库”也变成了可回归的约束。
TaskHub 的列表接口可以按三种任务状态筛选,Service 对每种状态都应把请求交给 findByStatus。这几个场景只有输入枚举不同,适合用参数化测试表达一组相同规则:
@ParameterizedTest(name = "状态 {0} 交给状态查询")
@EnumSource(TaskStatus.class)
void listDelegatesEveryStatusToRepository(TaskStatus status) {
given(repository.findByStatus(
org.mockito.ArgumentMatchers.eq(status),
any(org.springframework.data.domain.Pageable.class)))
.willReturn(org.springframework.data.domain.Page.empty());
var result = service.list(status, null, 0, 10);
@EnumSource 会为 TODO、IN_PROGRESS、DONE 分别执行一次测试。参数化测试适合这种“同一规则,多组输入”的场景,不要为了减少几行代码,把业务含义完全不同的成功、异常和删除场景硬塞进同一个方法。优先级的 @Min 与 @Max 当前位于 Web 输入模型上,因此留给 MVC 切片验证,不在 Service 测试里凭空添加第二套规则。
运行纯单元测试时,控制台不会出现 Spring Boot 横幅,也不会出现 Hibernate 建表日志:
[INFO] Running com.welearn.taskhub.task.TaskServiceTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS这正是我们想要的反馈长度。单元测试失败时,先看断言两边的值,再看模拟行为是否与场景一致,不需要去排查数据源或自动配置。
直接调用 taskController.create(request),能检查方法返回值,却绕开了大量真实请求必经的步骤:URL 是否映射到正确方法、JSON 能否反序列化成 record、Bean Validation 是否执行、异常能否交给 ApiExceptionHandler、响应是否使用约定的状态码和媒体类型。
@WebMvcTest 专门解决这件事。它创建一个受限的 Spring 上下文,只保留 Controller、@ControllerAdvice、JSON 转换、校验、过滤器和其他 MVC 基础设施,不扫描普通 @Service 与 Repository。MockMvc 把请求交给真实的 DispatcherServlet 处理,但不启动 Tomcat,也不监听端口。

创建 TaskControllerTest:
package com.welearn.taskhub.task;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.welearn.taskhub.config.TaskHubProperties;
import com.welearn.taskhub.web.ApiExceptionHandler;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDate;
import
@MockitoBean 和纯 Mockito 的 @Mock 用途不同。@Mock 只是创建一个普通 Java 模拟对象;@MockitoBean 会在测试的 ApplicationContext 中,用 Mockito 对象替换指定类型的 Bean。这样,Spring 创建 TaskController 时拿到的是模拟 TaskService,Web 层以下的调用就被截住了。
TaskHubProperties 也被替换,是因为 TaskController 用它决定未传 size 时的默认分页大小。@WebMvcTest 不会扫描普通 @ConfigurationProperties Bean,如果遗漏这个替身,上下文会在创建 Controller 时失败。这个失败恰好说明构造器注入把依赖完整暴露出来了:测试必须明确决定每个边界依赖用真实对象还是替身。
@AutoConfigureMockMvc(addFilters = false) 暂时不把安全过滤器加入 MockMvc。我们在这一部分只固定业务 API 的请求与响应;下一部分接入 SecurityFilterChain 后,会专门验证匿名访问、普通用户和管理员权限。把安全测试混在当前每一条 MVC 测试里,会让“业务契约失败”和“身份不符合要求”难以区分。
如果你的项目此时还没有引入 Spring Security,这个注解可以省略。等安全配置加入后再明确决定过滤器是否参与,不要看到 401 就机械地给所有测试加模拟用户。
mvc.perform(...) 看上去只是一串链式调用,背后经历的步骤与真实 MVC 请求很接近。理解这条路径后,遇到 400、404 或 415 时就不会只盯着 Controller 方法。
第一步,MockMvc 根据 builder 创建模拟的 HttpServletRequest。post("/api/tasks") 决定方法和路径,contentType(APPLICATION_JSON) 写入 Content-Type 请求头,content(...) 提供原始字节。遗漏媒体类型时,请求甚至可能到不了参数校验,而是在消息转换阶段得到 415 Unsupported Media Type。
第二步,DispatcherServlet 让 HandlerMapping 根据路径、HTTP 方法和条件选择 TaskController.create()。如果路径写错,通常得到 404;如果路径对但方法不允许,例如用 GET 调创建端点,会得到 405。这两种失败都还没有调用 Service。
第三步,参数解析器看到 @RequestBody,选择 JSON 消息转换器把请求体构造成 CreateTaskRequest。JSON 语法错误、日期格式错误或枚举值无法识别,会在反序列化阶段失败。它们和“成功反序列化后违反 @NotBlank”都是 400,但根因与错误体处理路径可能不同,所以实际项目应分别留一条代表性测试。
第四步,@Valid 触发 Bean Validation。字段错误被收集进绑定结果,方法尚未执行就抛出 MethodArgumentNotValidException。ApiExceptionHandler 接住它,创建 ProblemDetail,把字段错误放入 fields 扩展属性。
第五步,合法请求才会进入 Controller,再调用被 @MockitoBean 替换的 Service。Controller 得到 TaskResponse 后,用 ResponseEntity.created(location) 设置 201 和 Location,消息转换器再把 record 序列化为 JSON。最后,.andExpect(...) 对模拟响应执行断言。
所以,一条创建接口切片测试至少可以观察四类契约:状态码、Location 等响应头、JSON 字段,以及 Service 收到的请求。无需每条用例全部覆盖,但整个 Controller 测试集应让这些边界都有归属。
Spring Data 的 Page 很方便,却不是我们愿意永久承诺给客户端的响应格式。不同版本对内部字段和序列化提示可能变化,直接返回 PageImpl 会让 API 契约跟框架实现绑在一起。TaskHub 使用 TaskPageResponse(content, totalElements, totalPages, number, size) 固定需要的五个字段。
MVC 切片可以让 Service 返回一个空的 TaskPageResponse,再确认 content 是数组、size 使用配置的默认值。它不需要创建数据库记录,因为分页转换与 HTTP 形状才是这一层要证明的事。Repository 的分页总数是否正确,留在 JPA 测试中验证。
CreateTaskRequest 对标题使用 @NotBlank,对优先级使用 @Min(1) 与 @Max(5)。下面的请求同时违反两条规则:
@Test
void invalidBodyReturnsProblemDetailWithoutCallingService() throws Exception {
mvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " ",
"description": "优先级也越界",
"priority": 9
}
"""))
.andExpect(status().isBadRequest())
.andExpect
这个测试一次跨过了 JSON 反序列化、方法参数校验和异常处理器,但没有进入业务层。shouldHaveNoInteractions() 很关键:如果无效请求已经调用 Service,再由 Service 碰巧抛出异常,状态码也许仍是 400,接口边界却已经失守。
ProblemDetail 的 status、title、detail 是稳定字段,fields 是 TaskHub 添加的扩展属性。前端可以把 fields.title 放到标题输入框旁边,因此它也属于接口契约,不应只断言 400 就结束。
Service 的异常在 Web 边界应该变成可读的 404:
@Test
void missingTaskReturns404ProblemDetail() throws Exception {
given(service.get(99L)).willThrow(new TaskNotFoundException(99L));
mvc.perform(get("/api/tasks/{id}", 99L))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value
如果这条测试返回 500,不要去修改 Service 让它返回 null。Service 抛业务异常是合理的;问题多半在 ApiExceptionHandler 没被扫描、@ExceptionHandler 类型没匹配,或返回的 ProblemDetail 没设置预期状态。
MockMvc 会执行 Spring MVC 请求处理链,却使用模拟的 Servlet 请求和响应,不启动真实 HTTP 服务器。因此它很适合验证路由、绑定、校验、序列化、过滤器和 ControllerAdvice,速度也比真实端口测试快。
它不负责证明 Tomcat 确实监听了端口,也不经过操作系统网络栈。依赖容器底层错误页、连接超时、代理转发头等行为时,需要用 RANDOM_PORT 启动真实服务器。换句话说,MockMvc 不是“假的单元测试”,也不是“完整端到端测试”,它精准地落在 MVC 边界。
运行这一层时,会看到 Spring 创建 Web 测试上下文,但不会出现 Hibernate 建表或 PostgreSQL 容器日志:
[INFO] Running com.welearn.taskhub.task.TaskControllerTest
Started TaskControllerTest in 1.2 seconds
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0如果测试日志开始初始化连接池或执行建表 SQL,说明切片边界被意外扩大了。常见原因是测试导入了包含 @ComponentScan 的大配置类,或把 @WebMvcTest 换成了 @SpringBootTest。
Repository 接口看起来没有实现类,仍然有很多值得测试的地方。Spring Data 会根据方法名生成查询,@Query 中的 JPQL 直到运行时才被解析,实体字段还涉及枚举映射、主键生成、乐观锁版本和日期类型。模拟 Repository 无法证明这些内容正确,因为模拟对象只会返回我们事先告诉它的答案。
@DataJpaTest 会加载实体、Spring Data Repository、JPA、数据源和事务支持,不加载 Controller、Service 或 Web 服务器。每个测试默认在事务里执行,并在结束后回滚,因此测试数据不会流到下一条用例。

下面测试 findByStatus 只返回指定状态,并保留分页信息:
package com.welearn.taskhub.task;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class TaskRepositoryTest {
@Autowired
TaskRepository repository;
@Test
void findByStatusReturnsOnlyMatchingTasks() {
var done = new
Task.status 使用 @Enumerated(EnumType.STRING)。测试通过意味着 DONE 能以字符串形式写入并被查询;如果误改成序号映射,数据库里的可读性和迁移风险都会变化。这里保护的是映射行为,而不是 Spring Data 框架本身。
@DataJpaTest 的默认事务会包住一条测试方法。测试保存两条任务、执行查询、完成断言后,Spring Test 回滚这个事务。下一条测试看到的是干净状态,因此不需要依赖固定执行顺序,也不需要每次手工删除整张表。
回滚并不能替代正确的数据准备。一级缓存可能让刚保存的实体直接从持久化上下文返回,尚未真正经过数据库。验证查询和约束时,应在关键位置 flush;需要确认读取确实来自数据库时,还可以在 flush 后清空持久化上下文。这样,后续查询必须重新执行 SQL,而不是从当前会话缓存拿到同一个对象。
repository.saveAndFlush(done);
entityManager.clear();
var loaded = repository.findById(done.getId());
assertThat(loaded).isPresent();回滚也不会清理数据库之外的副作用。测试如果写了文件、发了消息或修改了静态缓存,需要分别清理。JPA 切片本来不应加载这些协作者;一旦发现 Repository 测试开始调用邮件或远程接口,通常说明切片导入范围太大,或者生产职责混在一起了。
事务默认回滚还有一个容易误判的地方:生产代码的事务传播与提交行为,未必能被测试外层事务完整呈现。比如需要观察 REQUIRES_NEW、数据库提交后触发器或并发锁竞争,普通 @DataJpaTest 不够。先写清楚要验证的事务边界,再决定是否关闭测试事务或升级到完整集成测试。
再测试手写的 JPQL 查询:
@Test
void searchByTitleIgnoresCaseAndMatchesSubstring() {
repository.save(new Task(
"补齐 Spring 测试", null, 5, LocalDate.of(2026, 8, 20)));
repository.save(new Task(
"整理接口文档", null, 3, LocalDate.of(2026, 8,
这条测试同时固定了两个容易被误改的细节:大小写不敏感、关键字是“包含”而不是“完全相等”。如果未来改用全文检索,测试仍描述用户看到的行为,不会因为 Repository 内部实现变化而失去价值。
JPA 写操作可能延迟到事务提交或 flush 才真正发出 SQL。下面这种测试只调用 save(),数据库约束错误未必会在这一行出现:
repository.save(task);验证唯一约束、非空约束或列长度时,使用 saveAndFlush(),或者注入 EntityManager 后调用 flush():
assertThatThrownBy(() -> repository.saveAndFlush(invalidTask))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);这样失败会发生在断言覆盖的动作里,而不是测试结束、事务清理时才突然冒出来。定位数据库问题时,先找控制台里最早出现的 SQL 与底层异常,后面的事务回滚异常往往只是连锁反应。
TaskHub 的本地配置使用 H2,并开启 PostgreSQL 兼容模式。它适合快速检查普通实体和查询,启动时不需要 Docker,也就是“1 个 JPA 上下文、0 个外部容器”。但兼容模式不等于真实 PostgreSQL:函数、大小写规则、JSON 类型、索引、锁行为和某些 SQL 语法仍可能不同。
因此我们保留大量 H2 JPA 切片,同时给数据库方言敏感的查询增加少量 PostgreSQL 测试。Testcontainers 会为测试启动一个一次性的真实 PostgreSQL,而 @ServiceConnection 把容器产生的 JDBC 地址、用户名和密码交给 Spring Boot,无需手工拼接随机端口。
package com.welearn.taskhub.task;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.domain.PageRequest;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace.NONE;
这个测试仍然是 JPA 切片:它没有加载 Web 和 Service,只是把内存数据源换成一个 PostgreSQL 容器,所以资源账单是“1 个受限 JPA 上下文、1 个数据库容器、0 个 Web 服务器”。@AutoConfigureTestDatabase(replace = NONE) 明确告诉切片不要再把容器数据源替换回嵌入式数据库。
@Testcontainers 让 JUnit 扩展管理容器生命周期,静态 @Container 在当前测试类执行前启动、全部方法结束后停止。disabledWithoutDocker = true 会在 Docker 不可用时明确跳过本类,而不是伪装成一次成功的 PostgreSQL 验证。@ServiceConnection 产生的连接信息优先于普通数据源属性,因此本机不需要预先安装 PostgreSQL,也不要在测试里写死 localhost:5432。

数据库镜像要固定版本,例如 postgres:17-alpine,不要使用 postgres:latest。否则同一提交今天与下个月可能拉到不同数据库版本,测试结果就不再可复现。第一次运行需要下载镜像,之后会使用本地缓存,因此首次耗时明显更长是正常现象。
高保真数据库测试仍要选择能暴露差异的行为。只保存一条普通字符串、再按主键读回,H2 和 PostgreSQL 几乎都会通过;它只能证明连接与最基础映射。更有价值的容器用例应覆盖项目确实依赖的方言能力,例如大小写不敏感搜索、特定列类型、真实迁移脚本、索引约束或锁行为。
@Container 字段声明为 static,意味着同一个测试类里的所有方法共享一个容器,避免每条测试都重启 PostgreSQL。数据库内容的隔离仍由测试事务或清理策略负责,容器共享不代表数据可以共享。若容器不是静态字段,JUnit 扩展会按测试方法启动和停止,隔离更强,但成本通常高得不值得。
Spring 测试上下文可能在测试类之间缓存,而 Testcontainers 的静态字段生命周期只受当前类控制。大型项目如果把同一上下文跨类复用,却让它引用一个已经停止的容器,就会出现很难理解的连接失败。解决思路是让同一批容器测试共享稳定的容器声明,或把容器定义成 Spring 测试 Bean,让它与上下文一起销毁。课程当前每个容器场景集中在一个类里,生命周期关系比较直接。
在 CI 中,Docker 可用性应该成为明确前提。构建日志需要显示容器测试确实执行,而不是只看最后的 BUILD SUCCESS。如果流水线本来承诺验证 PostgreSQL,却总是 Skipped: 1,就应当让任务失败或单独检查跳过数。允许开发机跳过,是为了保留快速反馈;允许发布流水线跳过,则会把数据库风险重新推到生产环境。
单元测试证明业务判断,两个切片分别证明 Web 与 JPA。它们仍然可能在“接缝”处错开:Controller 调用了错误的 Service 方法,Service 的事务没有生效,某个配置只在完整应用中冲突,或者返回对象经过真实持久化后字段与模拟结果不同。
完整集成测试只保留少量关键流程。它加载完整 ApplicationContext,使用真实 TaskService 和 TaskRepository。课程日常回归先连接 H2,让完整链路能稳定快速地执行;真实 PostgreSQL 差异已经由上一层的容器测试单独把守。先用 MockMvc 走完整 Spring MVC 链路,不启动网络端口:
package com.welearn.taskhub.task;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@
@SpringBootTest 与 @WebMvcTest 的差别不在请求写法,而在上下文范围。这里不再用 @MockitoBean 替换 Service,POST 会真的创建实体并写入 H2,随后 GET 再从数据库读回来。测试同时经过 JSON、校验、Controller、Service、事务、Repository 与实体映射。若要把这一整条链也切换到 PostgreSQL,可以复用上一节的 @Container 与 @ServiceConnection;代价是没有 Docker 时整个集成类都会跳过,所以课程把数据库方言验证集中在专门的 Repository 容器测试里。
第一条冒烟测试先固定当前安全边界:匿名请求不能进入任务 API。示例里的 @WithMockUser 则为业务流程准备一个登录用户。它为什么能生效、匿名请求为什么返回 401、不同角色怎样得到 403,会在下一部分专门拆开。当前集成测试只确认安全链没有被绕过,并不在这里展开完整权限矩阵。
如果要验证真实 Servlet 容器、网络层序列化或服务启动后的 HTTP 可达性,可以把 Web 环境改成随机端口。下面使用 Java 自带的 HttpClient,不增加任何测试客户端依赖:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = RANDOM_PORT)
class TaskApiHttpIntegrationTest {
RANDOM_PORT 让内嵌服务器监听一个系统分配的空闲端口,避免测试环境端口冲突。@LocalServerPort 把实际端口注入字段,客户端再组成请求地址。这条测试经过操作系统网络栈、真实服务器、安全过滤器链和 JSON 转换,资源账单是 1 个完整应用上下文、1 个 Web 服务器,以及当前课程配置中的 H2 数据库。
Spring Boot 4 的 MVC 测试模块也提供 RestTestClient,可以用更紧凑的链式 API 完成同类断言。如果项目已经在测试类路径中包含 spring-webflux,还可以使用 @AutoConfigureWebTestClient 与 WebTestClient 访问随机端口。纯 MVC 项目不必只为客户端引入整套响应式 Web 依赖;对上一部分的 WebFlux 服务,则直接使用 WebTestClient。
@SpringBootTest 决定的是应用配置范围,webEnvironment 决定的是 Web 服务器形态。默认 MOCK 可以加载完整业务上下文,再配合 MockMvc 走 Servlet 处理链;RANDOM_PORT 则额外启动嵌入式服务器,让客户端通过真实 HTTP 连接。两者都可能是完整集成测试,只是跨越的最后一道边界不同。
如果要证明 Controller、Service、Repository 的装配和事务协作,默认环境加 MockMvc 已经足够。它没有网络端口,运行更快,调试时还能直接拿到 MvcResult。如果问题涉及服务器端口、HTTP 头在真实传输中的表现、安全过滤器的完整链路或容器底层行为,再使用随机端口。把每条集成测试都升级为真实 HTTP,并不会自动多发现业务分支错误。
还要注意“完整”是相对于当前测试目标而言。课程里的 TaskApiIntegrationTest 加载完整主应用,但仍然使用 H2;它能证明各层连通,却不能证明 PostgreSQL 方言。PostgresTaskRepositoryTest 加载范围更小,却在数据库真实性上更强。测试维度不只有一条从小到大的直线:上下文范围、网络真实性、数据库真实性和外部服务真实性可以分别调整。
因此,选择测试时可以连续问四个问题:错误可能只在一个普通 Java 方法里吗?是否必须经过 Spring MVC 的参数解析?是否必须执行真实 SQL?是否必须经过真实网络?每回答一次“必须”,才增加对应基础设施。这样搭出的测试,失败时通常也能反向回答“最可能坏在哪个边界”。
完整上下文测试应尽量共享相同配置,让 Spring 的上下文缓存发挥作用。如果每个类都添加不同的 properties、profile 或 @MockitoBean 组合,框架会把它们视为不同上下文,重复启动应用。测试数量没增加多少,时间却会突然变长。先按场景复用几种稳定配置,再考虑并行执行,而不是一开始就用并发掩盖反复启动的成本。
JPA 切片的测试方法和数据库操作在同一测试事务里,方法结束时可以自动回滚。随机端口测试不同:客户端请求进入服务器线程,Controller 开启的是另一个事务。即使测试方法加了 @Transactional,也不会把服务器线程已经提交的数据自动撤销。
因此完整 HTTP 测试需要主动保持数据隔离。常见做法是每条测试使用唯一标题,在 @BeforeEach 或 @AfterEach 清理 Repository,或者为整个容器使用一次性数据库。不要依赖测试顺序,也不要假设第一条记录的主键永远是 1。
一个完整集成测试只应覆盖最关键的跨层故事,例如“创建后能查询”“无效请求返回统一问题详情”“乐观锁冲突能被转换”。如果每个 Service 分支都再写一份完整测试,测试套件会慢下来,失败时也更难判断到底是哪一层出了问题。
上一部分的任务事件流放在独立 WebFlux 服务中。响应式代码不能只靠 block() 取出结果再断言,因为这样会丢掉完成信号、错误信号、时间和取消订阅这些关键语义。
对 Mono 与 Flux 的纯逻辑,使用 Reactor Test 的 StepVerifier。TaskEventService.events() 每 250 毫秒发出一个事件,共发出四个;我们不让测试真的等待,而是使用虚拟时间推进时钟:
@Test
void emitsFourOrderedEvents() {
var service = new TaskEventService();
StepVerifier.withVirtualTime(service::events)
.thenAwait(java.time.Duration.ofSeconds(1))
.expectNextMatches(event ->
event.sequence() == 1
&& event.taskTitle().equals("设计任务接口"))
expectNextCount(2) 不关心中间两个事件的全部字段,却明确要求顺序中不能少元素;最后再检查第 4 个事件是完成状态。verifyComplete() 同时确认数据之后收到了完成信号。如果流多发一项、顺序颠倒或永不完成,测试都会失败。
对不会自动结束的流,验证到目标事件后要使用 thenCancel() 主动取消,否则测试会一直等待完成信号。虚拟时间只改变 Reactor 调度器感受到的时间,不会把阻塞数据库调用变快;这也是为什么上一部分没有把 JPA 塞进事件循环。
对 WebFlux Controller 使用 @WebFluxTest 与 WebTestClient。它们和 MVC 的 @WebMvcTest、MockMvc 处在同一个金字塔层次,只是请求处理模型不同:
@WebFluxTest(TaskEventController.class)
class TaskEventControllerTest {
@Autowired
WebTestClient client;
@MockitoBean
TaskEventService eventService;
@Test
void streamUsesEventStreamMediaType() {
given(eventService.stream()).willReturn(
Flux.just(new TaskEvent(
1L,
"设计任务接口",
"IN_PROGRESS",
这里仍然不启动真实服务器,WebTestClient 绑定在受限的 WebFlux 应用上下文上。需要验证 SSE 断线、代理缓冲或真实网络行为时,再升级为 @SpringBootTest(webEnvironment = RANDOM_PORT)。
不要在 Spring MVC 主项目里同时加入 WebFlux,只因为两个测试客户端的名字看起来相近。先看生产请求模型:MVC Controller 用 MockMvc 或 RestTestClient,WebFlux Controller 用 WebTestClient;只有真实 HTTP 集成测试才可以根据团队习惯统一客户端 API。
测试真正省时间的地方,不是全绿时那一行 BUILD SUCCESS,而是失败时能快速缩小范围。我们可以把常见失败按测试层次来读。

典型信息是 AssertJ 的值差异,或者 Mockito 报“期望调用但没有发生”“发生了未预期调用”:
expected: "写完接口测试"
but was: " 写完接口测试 "先看被测方法和测试输入。不要启动应用、查数据库,因为这一层根本没有 Spring 上下文和数据库。如果出现 UnnecessaryStubbingException,通常是测试预设了根本没走到的模拟行为;删除无关 stubbing,比把 Mockito 全局设成宽松模式更好。
常见错误是 NoSuchBeanDefinitionException: TaskService。这通常不是生产代码缺 Bean,而是切片刻意没有扫描 Service,却忘了用 @MockitoBean 提供替身。
如果期望 400 却得到 200,检查 Controller 参数上是否有 @Valid;如果得到 500,检查 ApiExceptionHandler 是否在切片扫描范围,以及异常类型是否匹配;如果得到 401,检查当前测试是否有意启用了安全过滤器。每一种状态码都对应不同边界,不要用一个宽泛的 .is4xxClientError() 把差异抹平。
先找日志中第一条 SQL 异常。could not execute statement 只是上层包装,真正原因往往在更下面的列名、约束名或数据库错误码。查询结果为空时,依次核对:测试数据是否 flush、枚举值是否一致、关键字是否被 trim、分页是否请求了错误页码。
如果 H2 通过而 PostgreSQL 容器失败,先怀疑方言差异,不要把容器测试删掉。它存在的目的正是把“只在生产数据库出现”的问题提前暴露出来。
Failed to load ApplicationContext 下面可能跟着很长的条件评估报告。有效线索通常是最早的 Caused by,例如端口配置、重复 Bean、数据库连接或迁移失败。先修第一个根因,后面几十行经常会一起消失。
完整集成测试失败后,用更小的测试复现问题:如果 JSON 结构错,回到 MVC 切片;如果查询错,回到 JPA 切片;如果只有 Service 分支错,回到纯单元。测试金字塔不只是组织测试数量,也是一条调试路径。
Spring 测试框架会缓存配置相同的 ApplicationContext,后续测试类可以复用它。@ActiveProfiles、动态属性、导入配置和 @MockitoBean 组合不同,都会形成新的缓存键。
因此,同一层测试尽量保持配置一致。多个 @WebMvcTest 若替换同一种 Bean,字段名也保持一致;不要每个类随手加一组不同属性。@DirtiesContext 会强制丢弃缓存,只有测试确实污染了上下文状态时才使用,不要把它当作“修复偶发失败”的按钮。
测试代码也会变旧。下面这些习惯能减少测试套件运行一段时间后变慢、变脆的问题。
不要让测试 B 依赖测试 A 先创建任务。JUnit 不保证你凭直觉猜测的执行顺序,未来还可能并行运行。每条测试自己准备最少数据,并只断言当前场景需要的字段。
时间也属于外部依赖。Task 目前在构造器里调用 Instant.now(),测试只要判断 createdAt 非空即可;如果业务规则需要精确判断“是否逾期”,应该把 Clock 注入 Service,测试传入固定时钟,而不是在断言里容忍一个模糊的前后几秒范围。
Mockito 最适合替换有边界的协作者,例如 Repository、邮件网关和远程客户端。CreateTaskRequest、Task、TaskResponse 都是便宜、确定的普通对象,直接构造更清楚。把每个 getter 都模拟出来,会产生大量与业务无关的 stubbing。
同样,不要模拟 Page 的十几个方法。可以用 new PageImpl<>(tasks, pageable, total) 创建真实分页对象,让测试更贴近 Spring Data 的返回结构。
如果 Service 的任务列表按 createdAt 降序,测试应断言返回顺序。它不需要验证内部一定调用了 Sort.by(...) 的哪一个重载,除非 Repository 收到的分页参数本身就是这层的输出契约。
完整响应体也不必每次做字符串完全相等。createdAt、version 或将来增加的新字段可能让无关测试全部失败。用 JSON Path 断言当前场景真正关心的字段;另写一条专门的契约测试固定必须存在的响应结构。
纯单元、MVC 切片与 H2 JPA 切片适合每次执行:
./mvnw test只运行当前类可以缩短修改反馈:
./mvnw -Dtest=TaskControllerTest test需要 Docker 的测试可以用清楚的命名或 JUnit 标签分组,在 CI 中单独执行。重点不是把慢测试排除掉,而是让开发内循环快、合并前验证完整。容器测试如果长期没人运行,等于没有测试。
主项目执行结果如下。当前机器没有可用 Docker daemon,因此 PostgreSQL 容器用例被明确跳过 1 条,其余测试全部通过:
[INFO] Running com.welearn.taskhub.task.TaskServiceTest
[INFO] Running com.welearn.taskhub.task.TaskControllerTest
[INFO] Running com.welearn.taskhub.task.TaskApiHttpIntegrationTest
[INFO] Running com.welearn.taskhub.task.TaskApiIntegrationTest
[INFO] Running com.welearn.taskhub.task.TaskRepositoryTest
[INFO] Running com.welearn.taskhub.task.PostgresTaskRepositoryTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO] BUILD SUCCESS响应式子项目单独执行,StepVerifier 与应用上下文测试共通过 4 条:
[INFO] Running com.welearn.taskhub.reactive.ResilientTaskLookupTest
[INFO] Running com.welearn.taskhub.reactive.TaskEventServiceTest
[INFO] Running com.welearn.taskhub.reactive.TaskHubReactiveApplicationTests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSSkipped: 1 不一定代表坏事。开发机没有可用 Docker 时,disabledWithoutDocker = true 会明确跳过容器测试;CI 则应该提供 Docker 并要求它运行。不要让数据库高保真测试在所有环境都悄悄跳过。
写完测试后,可以按下面的顺序快速检查。它不是形式化打分表,而是帮我们发现“测试能运行,但没有真正保护行为”的情况。
先看名称。测试报告只显示类名和方法名时,能不能知道哪个场景失败?createReturns201AndResponseBody 已经包含动作和结果;如果一个方法需要三行注释才能解释名称,通常应该重命名或拆分。
再看准备条件。每个 given、每条测试数据是否都参与了场景?无关对象会误导读者去寻找不存在的关系。固定日期、明确状态和有意义的中文标题,往往比一个庞大的通用 fixture 更容易维护。
接着看动作。大多数测试只应有一个主要动作:调用一次 Service、发送一次请求,或执行一次 Repository 查询。“创建后读取”这种完整故事可以有两个 HTTP 动作,因为它验证的正是跨请求持久化;纯单元测试若连续调用五个业务方法,失败原因就会混在一起。
然后看断言。有没有只断言 notNull,却没有检查真正的业务字段?有没有只断言 4xx,把 400、401、403、404 混为一谈?有没有比较整段易变化日志,而忽略稳定的状态码和响应字段?断言应当精确到能阻止错误,又不要精确到一次无关重构就全部破裂。
还要看替身边界。模拟的是 Repository、远程客户端这类协作者,还是把简单 DTO、实体甚至被测类本身也模拟了?@MockitoBean 是否只用于切断切片以外的依赖?如果一个 @SpringBootTest 里大半 Bean 都被模拟,它多半既失去了完整集成的真实性,又保留了完整上下文的启动成本。
最后看资源账单。测试启动了几个 Spring 上下文、几个 Web 服务器、几个外部容器?这些资源是否都帮助回答当前问题?一条标题 trim 测试如果拉起 PostgreSQL,边界显然过大;一条 PostgreSQL JSON 查询测试如果只模拟 Repository,边界又过小。把资源写清楚,往往就能看出层次是否选对。
当测试偶发失败时,不要第一时间加重试。先检查是否依赖系统时间、随机端口之外的固定端口、测试顺序、共享静态状态、异步任务未等待,或容器没有就绪。重试只能隐藏这些不确定性,还会让真正的回归在第二次碰巧通过。可靠的测试应该让相同输入稳定产生相同判断。
覆盖率也放在最后看。行覆盖率能提示哪些代码从未执行,却不能说明断言是否有效。一个测试即使运行了 if 两边,如果没有检查结果,覆盖率仍会很好看。更实用的检查是反问:如果把 trim() 删除、把 201 改成 200、把 DONE 查询写成 TODO,现有测试会不会明确失败?能抓住这些有意义的变异,才说明测试真正形成了防线。
现在,TaskHub 已经有了一条清楚的验证路线:修改业务判断,先跑 TaskServiceTest;修改请求模型或异常格式,跑 MVC 切片;修改实体与查询,跑 JPA 切片;准备合并时,再让完整上下文和 PostgreSQL 容器走一遍关键流程。
如果某条测试失败,先问它属于哪一层,再在那一层找第一条根因。不要一看到红色就启动整个应用手动点击,也不要把所有问题都交给一个巨大的 @SpringBootTest。范围越准确,反馈越快,错误越容易解释。
下一部分我们会把 SecurityFilterChain 接到同一组 /api/tasks 端点上。到那时,现有测试会成为基线:业务行为不变,在外面新增认证与授权边界。我们会分别验证匿名请求的 401、权限不足的 403 和已认证用户的正常响应,而不是让安全配置把所有业务测试搅在一起。