写SpringBoot项目测试,大概是所有Java开发都会遇到但又不愿意深挖的一块。业务代码写得好好的,一写测试就卡壳:启动太慢、数据库连不上、Mock不好使、跑完还互相污染。这篇文章想把我在SpringBoot项目里整理测试这件事的实际经验讲清楚,从分层思路到注解选型,从MockMvc到Testcontainers,把踩过的坑和能直接抄的方案都放进来。适合刚接触SpringBoot测试的初学者,也适合已经在写测试但对效率和稳定性不满意的同学。
1. 测试分层思路:先想清楚测什么再动手
1.1 为什么SpringBoot测试难写:框架替你管的太多
SpringBoot的自动配置让写业务代码变得飞快,但到了测试环节,这套便利反而成了负担。一个@SpringBootTest上去,Spring容器会把DataSource、RedisTemplate、RabbitTemplate、RestTemplate全部初始化,然后因为连不上本地Redis、找不到Kafka,测试直接报错。这不是测试难写,是你把全身体检和专项检查混在一起了。
我见过太多项目把集成测试写成“启动完整应用,然后调接口验证数据库有没有数据”。这种测试不是不能写,而是每跑一次都要等几十秒甚至几分钟,全量回归根本跑不起。测试应该是金字塔结构:底层大量单元测试跑得飞快,中部用切片测试验证单独层次,顶部少量集成测试做关键链路兜底,而不是反过来。
1.2 三类测试怎么选:单元、切片、集成
我划分测试类型时只看两个维度:是否需要Spring上下文、上下文加载到什么程度。
- 单元测试:完全不启动Spring,直接
new目标类,依赖对象用Mockito造。Service层的纯业务逻辑、工具类、DTO转换都放这层。 - 切片测试:SpringBoot提供的
@WebMvcTest、@DataJpaTest、@JsonTest等注解,只加载Web层、JPA层或JSON序列化相关的自动配置,其余依赖用@MockBean补齐。这层是SpringBoot最有特色的设计,后面单独讲。 - 集成测试:用
@SpringBootTest加载完整上下文,配合Testcontainers起真实中间件,验证跨模块链路。
我的习惯是:Mapper层不写测试,通过@DataJpaTest顺带覆盖;Service层核心业务逻辑必须写单元测试;Controller层用@WebMvcTest测接口行为;整个关键链路只挑两三条写@SpringBootTest集成测试。这样跑一次全量测试,大部分用例在几秒内完成,只有少数集成测试需要中等时间。
1.3 spring-boot-starter-test里到底带了什么
很多人建完项目就testImplementation 'org.springframework.boot:spring-boot-starter-test',但不知道里面装了什么。这个starter聚合了测试常用的所有库:
- JUnit Jupiter 5:测试框架本体,
@Test、@ParameterizedTest都从这来 - Mockito:对象mock、行为验证、参数捕获
- AssertJ:流式断言,
assertThat(result).isEqualTo()就来自它 - Hamcrest:老牌匹配器,虽然AssertJ够用,但有些老项目还在用
- JSONassert和JsonPath:接口返回的JSON断言利器,后面会用到
- Spring Test / Spring Boot Test:
@SpringBootTest、@MockBean这些注解的载体 - Awaitility:异步场景中“等待断言成立”的轮询工具
所以不需要重复引入JUnit和Mockito。版本跟着SpringBoot走就行,除非有特殊需求,否则不要手动指定这些库的版本,否则很容易和Boot管理的版本冲突。SpringBoot 3.x里这些库已经是基于Java 17+的版本,老项目升级时要留意JUnit 4和5的注解不兼容问题。
2. 核心注解深入:@SpringBootTest与它的兄弟们
2.1 @SpringBootTest四种启动模式怎么选
@SpringBootTest的webEnvironment参数有四个取值,对应四种启动方式,很多人只用了默认值。
| 取值 | 说明 | 典型场景 |
|---|---|---|
MOCK | 默认值,加载应用上下文但不启动真实Servlet容器,配合MockMvc使用 | 接口逻辑验证 |
RANDOM_PORT | 启动真实内嵌容器,端口随机,配合TestRestTemplate或WebTestClient | 完整HTTP链路测试 |
DEFINED_PORT | 启动真实容器,用配置的server.port端口 | 需要固定端口的调试场景 |
NONE | 加载上下文但不提供任何Web环境 | 测试Service、Mapper等非Web逻辑 |
有个常见误区:在MOCK模式下注入TestRestTemplate会直接报错,因为mock Servlet环境没有真实端口。同样,RANDOM_PORT模式下如果再用MockMvc也会有问题,MockMvc绑定的是mock环境,和真实端口无关,这时候要么用TestRestTemplate发真实HTTP请求,要么改成MOCK。
拿到随机端口的正确姿势是用@LocalServerPort注入端口号,这在做动态基础URL拼接时非常有用:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class UserApiIntegrationTest { @LocalServerPort private int port; @Autowired private TestRestTemplate restTemplate; @Test void getUser_shouldReturnUser() { String url = "http://localhost:" + port + "/api/users/1"; ResponseEntity<User> response = restTemplate.getForEntity(url, User.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().getName()).isEqualTo("张三"); } }2.2 切片测试:@WebMvcTest和@DataJpaTest实战
切片测试是控制测试启动时间最有效的手段。@WebMvcTest只扫描@Controller、@ControllerAdvice、@JsonComponent、Filter等Web层组件,不加载Service、Repository、消息队列。意味着你要用@MockBean把Controller依赖的Service换成mock,避免真实Service再牵连DataSource和Redis。
@WebMvcTest(UserController.class) class UserControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void getUser_shouldReturnOk() throws Exception { when(userService.getUser(1L)).thenReturn(new User(1L, "张三")); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("张三")); } }这个测试启动只加载Web层,比@SpringBootTest快一个数量级。同理,@DataJpaTest只加载JPA相关组件,默认用嵌入式数据库替换你的真实数据源,每个测试方法自动回滚事务,不会污染数据库:
@DataJpaTest class UserRepositorySliceTest { @Autowired private UserRepository userRepository; @Test void findByUsername_shouldReturnUser() { userRepository.save(new User("zhangsan", "张三")); User user = userRepository.findByUsername("zhangsan"); assertThat(user).isNotNull(); assertThat(user.getDisplayName()).isEqualTo("张三"); } }切片测试有个需要注意的地方:它们会跳过很多自动配置。如果Controller依赖了某个自定义拦截器、过滤器或HandlerMethodArgumentResolver,@WebMvcTest默认不加载,你必须用@Import手动加进来,否则接口测出来的行为和真实环境不一致。这个“不一致”问题比加载慢还坑,后面排查章节详细说。
2.3 @MockBean的老坑与新替代方案
@MockBean是SpringBoot测试里最常用的mock注解,但它有个隐藏代价:每次使用@MockBean都会让Spring上下文缓存缓存失效,重新创建整个容器。一个项目如果有十几个测试类各加一个@MockBean,那跑测试时就等于反复启动十几次完整上下文,CI时间直接爆炸。
具体机制是Spring测试框架按配置组合缓存上下文,这里的配置组合包括@MockBean定义,不同的mock配置组合会被当作不同上下文缓存,数量一多,内存和启动时间都上去了。所以我的建议是:能把多个测试共用的@MockBean集中在同一个父类或同一个测试类里就用在同一处,避免每个测试类都增加新的mock导致上下文数量膨胀。
另外,SpringBoot 3.4开始把@MockBean标记为废弃状态,推荐用@MockitoBean替代,语义更明确,底层用的是Mockito的@Mock机制而不是Spring Test的旧封装。老项目升级时看到一堆@MockBean的警告别慌,我一般是全局替换为@MockitoBean,签名基本不变,测试方法里的when逻辑完全不用动。
还要提一下代理问题。SpringBoot从2.x起默认proxyTargetClass=true,也就是无论接口还是类,都用CGLIB生成子类代理。很多人在@MockBean一个ServiceImpl类时担心mock不了,实际是完全没问题的,CGLIB代理基于类生成子类,这也算是“SpringBoot默认使用CGLIB代理”在测试场景下的一个正面收益。
3. MockMvc接口测试完整实操
3.1 第一个MockMvc测试的完整代码
MockMvc是SpringMVC测试框架的核心入口,它不启动真实Servlet容器,而是模拟DispatcherServlet到Controller的执行链路,因此执行速度很快,适合Controller层接口测试。
一个标准的Controller测试大概长这样:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void createUser_shouldReturn201() throws Exception { CreateUserRequest request = new CreateUserRequest("zhangsan", "张三", "M"); when(userService.createUser(any())).thenReturn(1L); mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"zhangsan\",\"displayName\":\"张三\",\"gender\":\"M\"}")) .andExpect(status().isCreated()) .andExpect(header().string("Location", containsString("/api/users/1"))); } }关键点有三个:contentType要指明APPLICATION_JSON,否则SpringMVC不触发@RequestBody解析;content里的JSON字符串要正确转义,我习惯用文本块来避免转义地狱;断言用status()、header()、jsonPath()组合能覆盖绝大多数场景。
我还会在切片测试里主动验证一次请求参数不合法的情况,用@Valid校验注解时,参数校验失败会返回400,通过jsonPath("$.message")可以断言错误信息是否符合预期:
@Test void createUser_shouldReturn400WhenUsernameBlank() throws Exception { mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"\",\"displayName\":\"张三\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("用户名不能为空")); }3.2 参数校验、JSONPath断言与文件上传
MockMvc不只是测GET和POST,文件上传也是接口测试的高频需求。用MockMultipartFile构造上传文件,再用multipart()发起请求:
@Test void uploadAvatar_shouldReturnFileUrl() throws Exception { MockMultipartFile file = new MockMultipartFile( "file", "avatar.png", MediaType.IMAGE_PNG_VALUE, "fake-image-content".getBytes() ); mockMvc.perform(multipart("/api/users/1/avatar").file(file)) .andExpect(status().isOk()) .andExpect(jsonPath("$.url").isNotEmpty()); }MockMultipartFile构造器的第一个参数必须和Controller的@RequestParam("file")名称一致,否则报“Required request part 'file' is not present”。这个错误信息我见过太多人排查半天,其实只是参数名字没对上。
JSONPath断言是接口返回JSON的利器。简单取值用$.data,数组用$.data[0].id,判断存在用$.data.exists(),判断长度用$.data.length()。如果返回的分页对象里包含总条数,可以这样整体校验:
.andExpect(jsonPath("$.content", hasSize(2))) .andExpect(jsonPath("$.totalElements").value(2))要注意jsonPath的语法遵循Jayway JsonPath,和JavaScript的JSONPath略有差异,常见的坑是数组切片写法不同。拿不准的表达式在测试里跑一次就明白了,比空想快得多。
3.3 测试Servlet容器还是Mock环境:选型逻辑
我一直强调选型逻辑:@WebMvcTest+ MockMvc适合验证Controller层的路由、参数绑定、参数校验、响应结构,这些是Web层的核心职责,测起来又快又稳。但如果你要验证Servlet容器层面的行为,比如真实HTTP协议解析、文件上传的Multipart真实验收、重定向跟随策略,那你得用@SpringBootTest(webEnvironment = RANDOM_PORT)配合TestRestTemplate。
实际项目里两个都用,但职责分开。Controller层逻辑回归全用@WebMvcTest,我测的是“给定输入,SpringMVC框架是否按预期调用我的Controller方法”。而@SpringBootTest(RANDOM_PORT)只放几条E2E链路,比如“注册用户→查询详情→修改资料→再查一次确认修改生效”,这类测试数量少、价值高,值得用启动完整容器的代价去换。
@AutoConfigureMockMvc是另一种方式,它配合@SpringBootTest加载完整上下文,再注入MockMvc。好处是Service、Mapper都是真实的,链路覆盖完整,坏处是启动速度和全量加载的脆弱性又回来了。我的建议是能不这么干就不这么干,除非你在测跨层交互且标明这是集成测试。
3.4 获取随机端口与TestRestTemplate
启动真实容器时,RANDOM_PORT模式默认端口是0(随机分配),测试里要拿端口拼URL,只能用@LocalServerPort注入。上面已经给了示例,这里补充一个进阶用法:测试里动态拼接请求路径时不要硬编码localhost,从环境变量读host,这样在容器化CI环境中也适用。
TestRestTemplate和普通RestTemplate的区别在于它会自动处理相对URL错误,并且支持withBasicAuth快速构造HTTP Basic认证测试,这在验证带权限接口时非常方便:
ResponseEntity<String> response = restTemplate .withBasicAuth("admin", "password") .getForEntity("/api/admin/users", String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);如果项目是WebFlux的响应式堆栈,对应的选择是WebTestClient,API风格和MockMvc更像,但支持流式响应,这里不过多展开。总之记住:Mock环境用MockMvc,真实容器用TestRestTemplate或WebTestClient,两条路线别混用。
4. 数据库测试隔离:从H2到Testcontainers
4.1 嵌入式数据库的坑:H2的假绿
@DataJpaTest默认会用嵌入式数据库替换真实数据源,如果项目的数据库是PostgreSQL或MySQL,很多人图方便就在测试配置里引入H2。但H2兼容模式只是个外壳,实际跑SQL时和真实数据库的差异非常大。
我踩过最典型的坑是MySQL的JSON类型字段。生产环境用的是MySQL 8,Repository里有原生查询用了JSON_EXTRACT和->>操作符,H2的MySQL兼容模式虽然能识别JSON类型,但对->>操作符支持不完整,结果是H2里测试全绿,代码一部署到预发环境就报SQL语法错误。这属于最典型的“假绿”事故。
再比如MySQL的FOR UPDATE行锁、ON DUPLICATE KEY UPDATE、窗口函数ROW_NUMBER() OVER (PARTITION BY ...),H2的兼容程度各有差异。除非团队明确了“测试用H2,生产用MySQL”且SQL语法完全避免了方言特性,否则我不建议在涉及JPA和原生SQL的项目里用H2做集成测试。
H2不是不能用于测试,只适合Controller切片测试里对Repository的极简交互模拟,或者纯查询逻辑的单元验证。凡是涉及SQL方言、事务锁、分页行为、字段类型映射的测试,都要用真实数据库,这就是Testcontainers出场的时候。
4.2 Testcontainers起真实MySQL的配置模板
Testcontainers的核心理念是在测试JVM进程中用Docker拉起真实中间件容器,测完自动销毁。它最大的价值是测试环境与生产环境同源,彻底消除方言差异。
先加依赖:
testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:mysql'SpringBoot 3.1+提供了@ServiceConnection注解,可以直接把Testcontainers容器注册到SpringBoot的自动配置中,不需要手写@DynamicPropertySource。代码非常简洁:
@Testcontainers @SpringBootTest @AutoConfigureMockMvc class UserRepositoryIntegrationTest { @Container @ServiceConnection static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0"); @Autowired private UserRepository userRepository; @Test void findByUsername_shouldReturnUser() { userRepository.save(new User("zhangsan", "张三")); User user = userRepository.findByUsername("zhangsan"); assertThat(user).isNotNul(); } }@ServiceConnection的原理是检测容器类型后自动生成DataSource所需的URL、用户名、密码,把spring.datasource.url等属性动态覆盖掉。这个机制极大简化了配置,不需要自己写@DynamicPropertySource那一大段重复代码。
老版本SpringBoot没有@ServiceConnection,就得用@DynamicPropertySource手动注入:
@DynamicPropertySource static void datasourceProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password", mysql::getPassword); }无论哪种方式,Testcontainers会随机分配宿主机端口,多个测试并行也不会冲突。启动速度主要取决于镜像拉取,第一次会把MySQL镜像拉到本地,之后就快了。CI环境里注意Docker是否可用,GitLab CI与GitHub Actions在现代版本的Runner上默认支持Docker套接字挂载,基本开箱即用。
4.3 测试数据准备与回滚策略
数据库测试的数据准备是另一个高发问题点。测试数据散落在各个测试方法里,跑完不清,下一次跑结果不对,而且测试之间的依赖会越来越紧,这是我最反感的代码味道。
我推荐按层级使用不同的数据准备策略:
@DataJpaTest默认自动回滚事务,测试方法里save的数据跑完就没了,不需要手工清理- 集成测试如果串行执行(非事务),用
@Sql注解在测试前后执行SQL脚本,注意脚本顺序要可控 - 复杂业务数据用测试工厂类生成,比如
UserTestFactory.createUser()返回完整可用的User实体,而不是每个测试里各写一遍new User(...)的赋值代码
@Sql的典型用法:
@Test @Sql(scripts = "/sql/insert_user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = "/sql/clean_user.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) void shouldQueryUser() { // 测试逻辑 }注意@Sql脚本路径是classpath下的相对路径,脚本内部最好不要有DROP TABLE之类的DDL,容易和并行测试互相干扰。更稳妥的做法是脚本只做INSERT和DELETE,表结构由Flyway或自动配置管理。
还有一个重要场景:用@Transactional给测试方法套事务来回滚数据时,如果被测代码内部开启了新线程处理异步任务,那新线程不参与测试事务,数据照样落库。这种场景要么用Awaitility处理异步回调的断言,要么干脆不在集成测试里依赖事务回滚,直接用@Sql清理。
5. 测试提速与稳定:上下文缓存和并行策略
5.1 上下文缓存机制:为什么越跑越慢
Spring模版测试框架默认按配置组合缓存ApplicationContext,设计目的是为了省启动时间。但实际项目里我见过上下文缓存几十个的,每个测试类一个上下文,跑一次测试用例直接卡在启动上。
导致缓存爆炸的根源有三个:@MockBean定义组合不同、@ActiveProfiles不同、@SpringBootTest参数不同。三个因素叠加,缓存数量呈乘法增长,内存占用也上去了。测试进程崩溃前的典型症状是“Caused by: java.lang.OutOfMemoryError: Java heap space”。
优化办法:
- 尽量统一profile,不要每个测试类发明一个
@ActiveProfiles("test-xxx") - 把
@MockBean收敛到最少,且相同组合的测试类可以继承同一个抽象基类,复用同一个上下文 - 加JVM参数
-Xmx给测试进程,至少512M起步,Testcontainers场景建议1G以上 - 用
surefire的reuseForks=true(默认就是true)保证不反复启动JVM
时间长了你会发现,测试慢的根源多半不是单个用例,而是上下文反复重建。优化一个好用的抽象测试基类,收益会辐射到所有继承的测试类。
5.2 并行测试与CI环境怎么做
JUnit 5从5.3起支持并行测试,但默认关闭。要在配置里打开:
junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=same_thread junit.jupiter.execution.parallel.mode.classes.default=concurrentmode.default=same_thread表示同一个测试类内方法串行,mode.classes.default=concurrent表示不同测试类并发执行。这样既有并发度,又不会让同一个类里的方法互相干扰。这是我最推荐的并行策略。
但并行测试的前提是资源隔离。Testcontainers天然适合并行,因为每个容器独立端口、独立数据。但如果你用共享的静态变量或固定的本地文件路径,并行一定会踩雷。我给过一个团队的建议:并行测试上线前,先全局搜索static可变字段、System.setProperty、读写target/test-output固定文件的代码,这些是并行测试的定时炸弹。
CI场景下,Maven的surefire插件还支持按标签过滤测试,比如打上@Tag("slow")的用例在普通PR验证时跳过,只在全量回归时执行:
mvn test -Dgroups='!slow' # 跳过慢测试 mvn test -Dgroups='slow' # 只跑慢测试这个策略尤其适合项目里既有秒级单元测试又有分钟级集成测试的情况,开发提交时跑快测试,发布前跑全量。
5.3 测试数据与随机端口稳定性
最后聊一个稳定性的细节:随机端口的选择。RANDOM_PORT模式下端口由操作系统随机分配,理论上不会冲突,但如果同时有多个测试进程或本地开发中的应用实例也在监听端口,偶发的Address already in use还是会出现。解决办法是把范围限制在安全区间:
server: port: 0port=0表示随机端口,且SpringBoot会从系统可用端口里选择,冲突概率极低。如果你真的遇到随机端口冲突,检查是不是有别的进程在使用SpringBoot默认的8080而不是随机端口——这通常是webEnvironment误配成DEFINED_PORT导致的。
6. 常见问题排查速查表与踩坑实录
半个总结表,后面几个我展开说:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
@WebMvcTest中接口返回404 | ControllerAdvice、Filter、拦截器未加载 | @Import(GlobalExceptionHandler.class) |
注入TestRestTemplate报错 | webEnvironment=MOCK没有真实端口 | 改用RANDOM_PORT |
@MockBean引起上下文频繁重启 | mock组合导致缓存失效 | 收敛mock,复用基类 |
H2里测试全绿,真实MySQL报错 | SQL方言差异 | 改用Testcontainers |
测试方法@Transactional不回滚 | 异步线程不参与事务 | 用Awaitility+显式清理 |
@MockBean标记为废弃警告 | SpringBoot 3.4+ | 全局替换为@MockitoBean |
文件上传报Required request part is not present | MockMultipartFile名称和@RequestParam不一致 | 对齐参数名 |
| 启动Testcontainers时报Docker连不上 | 本地或CI未启动Docker | 检查Docker守护进程和挂载套接字 |
这里挑两个最常见的展开说一下。
第一个是@WebMvcTest接口返回404。这种情况多半不是Controller路径写错,而是全局异常处理器或安全过滤器没被加载,导致请求在进入Controller前就被拦截或没被处理。我先在测试里加@Import(GlobalExceptionHandler.class)验证异常逻辑,如果是Spring Security的场景再用@WithMockUser模拟已登录用户。排查这类问题最快的办法是看测试日志里有没有No mapping found,有的话对照RequestMapping路径。
第二个是Testcontainers在CI里的Docker连接问题。本地Mac装了Docker Desktop一般没毛病,但CI Runner如果没有挂载Docker套接字,容器起不来,报错是Could not find a valid Docker environment。我的经验是:用官方提供的docker镜像作为Runner会更省心,或者配置DOCKER_HOST指向宿主的Docker守护进程。如果Runner本身跑在容器里,需要挂载宿主的/var/run/docker.sock,这是最常见的CI环境坑。
还有个容易被忽略的规则:@MockBean放进基类里以后,如果基类被子类继承,子类对mock的when配置不会影响基类的行为声明,但mock对象是同一个实例,所以基类里when(x).thenReturn()会在子类里也生效。这就要求基类里只声明公共mock,不要在基类里写具体业务断言。
另外,测试里如果和外部HTTP服务打交道,不要直接调用真实服务,用MockWebServer(OkHttp旗下的库)或WireMock来模拟外部接口。我一般用WireMock,它能动态设置响应体、延迟、错误码,用来验证超时和重试逻辑非常方便。这在服务间调用测试里几乎是必备的,比Mockito的when(restTemplate.exchange(...))更接近真实HTTP语义。
最后提醒一个版本相关的坑:SpringBoot版本升级到3.x以后,包名从javax变成了jakarta,测试代码里的javax.validation.constraints.NotBlank要改成jakarta.validation.constraints.NotBlank。这个改动波及面比想象中大,Controller的@Valid、DTO的校验注解、JPA的@Entity都会受影响。手头是旧版本直接用热词里提到的“springboot版本太高”这类求助,十有八九是先遇到的包名变更而不是业务逻辑问题。升级时建议全局搜索替换,然后让编译错误把所有遗漏位置揪出来。
一点个人体会
我在实际项目里最受益的一个改变,是把测试当作第一道设计评审。以前写完Service就急着写Controller,后来先写测试再补实现,虽然开始会慢一点,但测试逼着你把接口的输入输出想清楚,哪些字段必填、哪些状态可能发生、异常怎么抛,在设计阶段全暴露了。写测试不是给代码上保险,而是顺手把代码结构理了一遍。
最后分享一个调试小技巧:如果你在IDE里跑单个测试方法时发现日志信息不够,可以在测试方法的build.gradle里临时加上testLogging { events "passed", "failed", "skipped" },Gradle会输出每个测试用例的执行结果和耗时。排查“哪个用例拖慢了全量测试”时,这个配置比肉眼扫控制台高效得多。