单元测试这事儿,说大不大,说小不小。我见过不少项目,业务代码写得飞起,一到测试就草草了事,要么只测几个happy path,要么连测试都没建,线上出问题全靠用户当“自动报警器”。但这几年SpringBoot项目越做越复杂,接口一多、依赖一深,没有一套像样的测试兜底,改需求的时候心里是真没底。我自己的习惯是,从项目的第一天就把JUnit5和Mockito这块骨架搭好,覆盖率不求100%,但核心链路必须看得住。这篇文章就围绕SpringBoot里怎么把单元测试真正落地来聊,从框架选型到Mockito的核心用法,再到覆盖率怎么统计怎么设门槛,一步一步拆开讲,希望能给正在补测试这块短板的同学一些能直接抄作业的参考。
1. 为什么SpringBoot项目的单元测试总被“将就”
很多人容易混淆一个概念,觉得SpringBoot项目里的测试,只要把应用跑起来、发个HTTP请求、看看返回结果对不对就够了。这种属于集成测试或端到端测试,不是单元测试。单元测试的粒度要小得多,它的核心是只验证某一个类或某一个方法的逻辑,通过Mock掉所有外部依赖(数据库、Redis、第三方接口),把被测对象隔离在一个“真空环境”里,专门验证自己的逻辑是否正确。
SpringBoot项目之所以单元测试容易被“将就”,主要是三个原因。
第一个原因是环境依赖太重。项目一启动就要连数据库、拉配置中心、初始化Redis连接池,开发环境但凡有点网络波动,测试就红一片。于是很多人觉得“测试跑不起来”,索性就不写了。第二个原因是Mockito用得不够熟,面对层层嵌套的Service依赖,不知道该怎么打桩,怎么验证行为,写出来的测试又长又脆,改一行业务代码就碎一片。第三个原因是覆盖率没有形成约束,项目没有覆盖率门槛,也没有人监督,写了多少算多少,最后统计一看三成不到,和没写差别不大。
但单元测试恰恰是SpringBoot项目里性价比最高的一层防护。它能精确地指出是哪一行代码出了问题,而且毫秒级跑完,不依赖外部环境。特别是Service层和Utils工具类,这两个地方业务逻辑最集中,最容易藏bug,也是最值得用单测覆盖的地方。
我的做法是:Controller层做轻量级的切片测试(@WebMvcTest),Service层做纯Mockito的单元测试,外部依赖全部打桩,数据访问层做少量集成测试兜底。每一层测试的目标都很明确,不会出现“想测Service却把整个Spring容器都拉起来”的情况。
2. 技术选型为什么是JUnit5和Mockito,以及怎么搭骨架
提到Java测试框架,就绕不开JUnit和TestNG,而SpringBoot默认集成的就是JUnit,Spring Initializr创建项目的时候会自动带上spring-boot-starter-test这个依赖。这个starter里面其实已经打包了JUnit Jupiter(也就是JUnit5)、Mockito、AssertJ、Hamcrest等一堆常用测试库,开箱即用。
不过这里有一个坑需要提醒一下:spring-boot-starter-test在不同版本里包含的组件不太一样。在SpringBoot 2.x时代,这个starter默认包含了JUnit4的兼容层(JUnit Vintage),以及Mockito主库和Mockito的JUnit5扩展(mockito-junit-jupiter)。到SpringBoot 2.4之后,默认就换成JUnit5了。如果项目比较老,用的是SpringBoot 2.2以前的版本,可能还要手动排除JUnit4依赖,否则跑测试的时候容易冲突,出现一些“找不到测试方法”之类的诡异错误。
建议的依赖是下面这套,基于Maven:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>如果只想用JUnit5和Mockito,不想引入多余的测试框架,可以再加细化排除,但一般没必要。SpringBoot默认这一套已经够用了。
搭骨架的时候,有几个基础配置我建议在项目初期就搞定。
第一,在src/test/resources下放一个application-test.yml,把数据库、Redis这些外部依赖的自动配置关掉,或者用内嵌的H2数据库顶上。这样测试的时候不会真的去连开发环境的数据库,既能提速又隔离污染。
第二,在测试基类里配置好Mockito的扩展。JUnit5和Mockito的集成用@ExtendWith(MockitoExtension.class)这个注解,它替代了JUnit4时代的@RunWith(MockitoJUnitRunner.class)。这个扩展还可以配置lenient模式,等后面我们聊到UnnecessaryStubbingException的时候会详细说。
第三,建议测试类命名统一用类名+Test的格式,比如UserServiceTest、OrderBizServiceImplTest。Maven的surefire-plugin默认匹配*Test、*Tests、*TestCase这几个后缀,直接用约定俗成的命名,不需要额外做配置就能被识别执行。
3. JUnit5的核心注解和断言,别再一股脑用JUnit4写法
JUnit5和JUnit4最大的差异是架构完全重写了。JUnit5由三个子模块组成:JUnit Platform是启动测试框架的基础,JUnit Jupiter是新版编程模型(也就是我们写代码用的注解和断言),JUnit Vintage是运行JUnit4用例的兼容层。所以JUnit5并不是在JUnit4上做加法,而是直接换了赛道。
写测试类的时候,JUnit5常用的注解就这几种,但用法上有些细节值得展开说说。
class UserServiceTest { @BeforeEach void setUp() { // 每个测试方法执行前都会跑这里 } @AfterEach void tearDown() { // 每个测试方法执行后清理资源 } @Test void shouldReturnUserById() { // 单测正文 } }@BeforeEach对应的是JUnit4的@Before,@AfterEach对应@After。如果整个测试类只需要做一次初始化,类似连接池这种重资源,可以用@BeforeAll,但注意它在JUnit5里要求是静态方法。之前就有人在这里踩坑,@BeforeAll用非静态方法写,结果测试跑起来什么都不执行,还没有报错信息,排查了大半天才发现是方法签名问题。
断言这块,JUnit5的Assertions类提供了很丰富的API。以前JUnit4用assertEquals带个消息参数,是这么写的:
assertEquals("xxx", result.getCode(), "结果码不对");注意JUnit5里参数顺序变了,期望值在前,实际值在后,第三个参数才是提示消息。习惯JUnit4写法的人经常把实际值和期望值传反,这样断言失败的时候报错信息是反的,看日志容易产生误导。
Assertions.assertEquals("SUCCESS", result.getCode()); Assertions.assertTrue(result.getItems().isEmpty()); Assertions.assertNotNull(result.getUser()); Assertions.assertThrows(IllegalArgumentException.class, () -> { userService.create(null); });assertThrows这个方法是JUnit5里非常有用的新能力,专门用来验证异常分支。以前JUnit4只能通过@Test(expected = XxxException.class)来声明,但这种方式有个缺陷,它无法精确到“某一行代码抛异常”,如果测试方法里前面几行因为别的原因抛了同一种异常,测试一样会“绿”。而assertThrows直接把可执行逻辑包进Lambda表达式,只有这一段代码抛了指定异常才算通过。
如果是验证异常消息里的特定字段,还可以配合assertThrows的返回值继续断言:
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, () -> { userService.create(null, -1)); }); Assertions.assertTrue(ex.getMessage().contains("invalid"));这套组合拳可以很精确地锁定异常内容和异常类型,对Service层参数校验逻辑的覆盖非常友好。
JUnit5还有一个特性叫参数化测试,也就是@ParameterizedTest配合@ValueSource、@CsvSource、@MethodSource使用,同一个测试方法用多组参数反复执行。特别是判空校验、边界值这种场景,写一个参数化测试能替代以前写五六个重复方法的工作量。
@ParameterizedTest @NullSource @ValueSource(strings = {"", " "}) void shouldRejectBlankName(String name) { Assertions.assertThrows(IllegalArgumentException.class, () -> userService.create(name, 8000)); }执行报告里会清楚地显示每一组参数对应的执行结果,不需要在测试方法里另外打日志,效果非常直观。
4. Mockito打桩和验证行为,这几个要点绕不过去
Mockito是整个单元测试实战里的重头戏。它的核心思路英文叫“programming to interfaces, mock the dependencies”,用中文翻译过来就是我们熟知的:依赖倒置,面向接口编程,测试时把依赖Mock掉,只专注被测类的逻辑。
先明确两个基础概念:
mock():创建一个Mock对象,所有方法默认返回默认值(对象返回null,int返回0,boolean返回false),不会走真实逻辑。@Mock注解:在JUnit5里要配合@ExtendWith(MockitoExtension.class)才能生效,否则注入不进去。
除了Mock对象,还有个高频用法叫@Spy,它和@Mock的最大区别是:Spy对象会调用真实方法,除非显式打桩。这在测试中非常有用,比如你想跑真实的某段业务逻辑,但又需要把其中一个远程调用改写掉。
再看验证行为。Mockito验证行为用的就是verify(),它用来确认某个Mock对象的某个方法有没有被调用、调用了多少次。
verify(userRepository, times(1)).findById(1001L); verify(orderClient, never()).cancel(anyLong()); verify(userRepository, atLeastOnce()).save(any(UserEntity.class));times(1)是默认次数,省略不写也可以。never()常常用于验证某个分支不应该被触发,比如参数校验失败后,后续的数据库操作不应该执行。
打桩这一块,最基础的是when(...).thenReturn(...):
when(userRepository.findById(1001L)).thenReturn(Optional.of(userEntity));这里有一个容易忽略的小细节:when里面传的参数必须是确定值或者使用Mockito的匹配器。如果写的是when(userRepository.findById(anyLong())).thenReturn(...),那么调用的时候也必须用anyLong()去匹配,不能混用确定值和匹配器。混用会直接抛InvalidUseOfMatchersException,而且这个异常信息经常不指向真正出错的那一行,排查起来比较痛苦。
遇到void方法,不能用when,得换个思路:
doNothing().when(userRepository).deleteById(2002L); doThrow(new RuntimeException("DB down")).when(orderClient).cancel(anyLong());特别是doThrow这种用法,在测试方法里验证“抛异常后调用的后续逻辑”很有用。比如一个Service方法里,先扣库存再发Mq消息,Mq消息发送失败要回滚库存,就可以用doThrow打桩MqClient的send方法,然后断言库存回滚逻辑被正确执行。
Mockito还有一个让我又爱又恨的能力,就是ArgumentCaptor,参数捕获器。它能捕获方法调用时传入的真实参数,然后对参数内容做断言。这个在验证“到底往数据库里存了什么”的场景非常关键,因为直接verify只能确认方法调了,拿不到入参。
ArgumentCaptor<UserEntity> captor = ArgumentCaptor.forClass(UserEntity.class); verify(userRepository).save(captor.capture()); UserEntity savedUser = captor.getValue(); Assertions.assertEquals("张三", savedUser.getName()); Assertions.assertEquals(8000, savedUser.getSalary());还有一点容易被测试新人忽略,但资深工程师几乎天天在用的,就是**@InjectMocks**。它会把Mock对象自动注入到被测类里,不用我们手写setter或者反射去塞依赖。比如UserService里面依赖了UserRepository和OrderClient:
@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private OrderClient orderClient; @InjectMocks private UserService userService; }@InjectMocks的注入策略是:先按构造器注入,再按setter注入,最后按字段反射注入。这个顺序在项目里如果喜欢用构造器注入,基本可以做到无脑注入。但如果被测类有多个构造器,建议确保被@InjectMocks选中的是SpringBoot实际会用的那一个,否则可能出现“Mock对象没注入进去但测试没报错”的情况,逻辑怎么跑都跟预期不一致,特别迷惑。
5. Service层单元测试实操:从零写一个加薪功能测试
理论知识摆了一堆,现在来走一个完整的实战。模拟一个UserService类的场景,它的功能是给员工调薪,并且调薪之后要通知下游系统刷新绩效快照。这个Service依赖了三样东西:UserRepository(查用户和保存用户)、SalaryCalculator(计算新的薪资)、OrderClient(调外部系统同步)。
业务规则:
- 员工不存在,直接抛
IllegalArgumentException - 新薪资必须在3000到50000区间,否则抛
IllegalArgumentException - 新薪资不能低于现薪资,否则抛
IllegalArgumentException - 计算新薪资、保存、通知下游,顺序不能乱
被测类大概是这种结构:
@Service public class UserService { private final UserRepository userRepository; private final SalaryCalculator salaryCalculator; private final OrderClient orderClient; public UserService(UserRepository userRepository, SalaryCalculator salaryCalculator, OrderClient orderClient) { this.userRepository = userRepository; this.salaryCalculator = salaryCalculator; this.orderClient = orderClient; } public UserEntity adjustSalary(Long userId, int baseSalary, int bonusRate) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("user not found")); int newSalary = salaryCalculator.calculate(baseSalary, bonusRate); if (newSalary < 3000 || newSalary > 50000) { throw new IllegalArgumentException("salary out of range"); } if (newSalary < user.getSalary()) { throw new IllegalArgumentException("new salary lower than current"); } user.setSalary(newSalary); userRepository.save(user); orderClient.syncPerformanceSnapshot(userId, newSalary); return user; } }完整测试类如下:
@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private SalaryCalculator salaryCalculator; @Mock private OrderClient orderClient; @InjectMocks private UserService userService; private UserEntity existingUser; @BeforeEach void setUp() { existingUser = new UserEntity(); existingUser.setId(1001L); existingUser.setName("张三"); existingUser.setSalary(5000); } @Test void shouldAdjustSalaryAndNotifyOrderClient() { when(userRepository.findById(1001L)).thenReturn(Optional.of(existingUser)); when(salaryCalculator.calculate(8000, 10)).thenReturn(8800); UserEntity result = userService.adjustSalary(1001L, 8000, 10); Assertions.assertSame(existingUser, result); Assertions.assertEquals(8800, result.getSalary()); ArgumentCaptor<UserEntity> captor = ArgumentCaptor.forClass(UserEntity.class); verify(userRepository).save(captor.capture()); Assertions.assertEquals(8800, captor.getValue().getSalary()); verify(orderClient, times(1)).syncPerformanceSnapshot(1001L, 8800); } @Test void shouldRejectWhenUserNotFound() { when(userRepository.findById(9999L)).thenReturn(Optional.empty()); Assertions.assertThrows(IllegalArgumentException.class, () -> userService.adjustSalary(9999L, 8000, 10)); verify(userRepository, never()).save(any(UserEntity.class)); verify(orderClient, never()).syncPerformanceSnapshot(anyLong(), anyInt()); } @Test void shouldRejectWhenNewSalaryOutOfRange() { when(userRepository.findById(1001L)).thenReturn(Optional.of(existingUser)); when(salaryCalculator.calculate(30000, 20)).thenReturn(36000); Assertions.assertThrows(IllegalArgumentException.class, () -> userService.adjustSalary(1001L, 30000, 20)); verify(userRepository, never()).save(any(UserEntity.class)); } @Test void shouldRejectWhenNewSalaryLowerThanCurrent() { when(userRepository.findById(1001L)).thenReturn(Optional.of(existingUser)); when(salaryCalculator.calculate(1000, 0)).thenReturn(1000); Assertions.assertThrows(IllegalArgumentException.class, () -> userService.adjustSalary(1001L, 1000, 0)); verify(userRepository, never()).save(any(UserEntity.class)); verify(orderClient, never()).syncPerformanceSnapshot(anyLong(), anyInt()); } }这个测试类看起来不长,但是把Service的分支全部覆盖到了:正常链路、用户不存在、薪资越界、新薪资低于现薪资。每一组测试都对应着业务规则,功能逻辑写错一条,运行测试就会立刻暴露。
实操过程中有几个细节值得说。比如findById方法的返回类型是Optional,打桩的时候必须返回Optional.of(existingUser),不能直接thenReturn(existingUser),这俩类型不匹配,编译器会直接报错,但报错往往在打桩那行,有人会误以为是Mockito版本问题。
再比如anyLong()、anyInt()这些匹配器,写verify(orderClient, never()).syncPerformanceSnapshot(anyLong(), anyInt())的时候,所有参数都得用匹配器,不能一个用1L一个用anyInt()。这也是Mockito的硬性规则,违反了会抛InvalidUseOfMatchersException,而且执行顺序和代码行号都对不上,很坑。
assertSame和assertEquals的区别也需要明确。assertSame断言的是同一个引用,这里直接返回了从Repository拿到的那个对象;而assertEquals是调用了equals方法做值比较。如果UserEntity重写了equals,那么用哪个效果不一样,这个细节能让测试在查找问题时更精准。比如我想验证“方法返回的确实是用户对象本身,而不是重新构造的对象”,那就该用assertSame。
6. Controller层测试:用MockMvc发请求,隔离掉Service
Controller层测试有两种常见姿势。第一种是直接用MockMvc配合@WebMvcTest做切片测试,只会创建Controller这一层的Spring上下文,Service、Repository这些都用Mock替代。第二种是启动完整SpringBoot应用,再用TestRestTemplate发请求,这种偏集成测试,启动慢而且依赖真实的数据源。
针对单元测试的目的,我更推荐@WebMvcTest。它在SpringBoot 1.4以后就有了,用法也比较成熟。
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void shouldReturnUserWhenServiceReturnsData() throws Exception { UserEntity user = new UserEntity(); user.setId(1001L); user.setName("李四"); when(userService.getUserById(1001L)).thenReturn(user); mockMvc.perform(get("/api/users/1001")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("李四")); } }这里需要注意@MockBean这个注解,它来自SpringBoot测试包,可以直接把Mock对象放进Spring容器。但SpringBoot 3.4版本开始,@MockBean被标记为废弃,官方推荐用@MockitoBean,功能上是平替。如果你的项目还在特别老的版本,就用@MockBean。
Controller层测试的核心验证点有两个:一个是HTTP状态码和响应体的JSON结构,另一个是Controller本身没有做太重的业务逻辑。如果Controller里塞了大量逻辑,那么测试会非常考验人的耐心。我自己写Controller的时候有个原则:Controller只做参数接收和结果包装,所有业务判断一律下沉到Service。这个原则能让Controller层测试保持轻量,也让Service层测试真正覆盖到业务风险点。
jsonPath这个断言工具非常灵活,可以取JSON里的某一个字段做校验,也可以用jsonPath("$.users.length()").value(3)校验数组长度。数组、嵌套对象、null值都可以覆盖到。如果遇到响应结果比较复杂,可以先跑一次测试把返回JSON打印出来,再对着结构写jsonPath,比凭空猜路径要靠谱得多。
7. 测试覆盖率怎么统计,JaCoCo这关怎么过
单元测试写了不统计覆盖率,等于不知道自己漏了多少。覆盖率并不等于代码质量,但覆盖率很低的时候,代码质量大概率是没底的。这就像射箭射在靶子上,虽然射中靶心不一定是神箭手,但如果箭都在靶外,那肯定有问题。
Java生态里统计覆盖率事实上的标准就是JaCoCo(Java Code Coverage),它作为Maven插件集成进项目,跑完测试后自动生成覆盖率报告,还能设置覆盖率门槛让构建失败。
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.12</version> <configuration> <excludes> <exclude>**/config/**</exclude> <exclude>**/entity/**</exclude> <exclude>**/dto/**</exclude> <exclude>**/mapper/**</exclude> </excludes> </configuration> <executions> <execution> <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>test</phase> <goals> <goal>check</goal> </goals> <configuration> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>INSTRUCTION</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> </limit> </limits> </rule> </rules> </configuration> </execution> </executions> </plugin>在这个配置里,我排除了entity和mapper这一类“纯数据载体”的类。因为实体类大多是getter和setter,对它们做覆盖率要求意义不大。真正需要盯的是Service层、Controller层、工具类、规则引擎这几块。
prepare-agent这个goal会在测试运行前挂载探针,report在test阶段生成报告,check则定义了一个门槛:整个项目BUNDLE的指令覆盖率不得低于80%。低于80%,构建直接失败。这个机制在设计上是想保证:以后跑CI的时候,只要有人新增了没测过的代码,测试率一旦跌破阈值,构建就挂掉。
以一个实际项目的测试结果来看,在刚引入单测时覆盖率通常是45%~55%,这个阶段属于“主要链路覆盖了,但分支和异常路径大量缺失”。把异常分支补齐之后,可以到75%~80%。再往上冲到85%以上,边际成本就急剧增加了,常见是一些防御性代码、极端分支、异常转换逻辑,费很大力气去构造场景,收益率越来越低。所以我的经验是:80%是一个比较合理的工程红线,再高靠自觉,不要为了数字好看而堆测试。
不过这里要提醒一点:JaCoCo的指令覆盖率(INSTRUCTION)是看字节码级别的执行路径,行覆盖率(LINE)是按源码行算的。两者差别不小。同一个分支里多行代码,行覆盖率会表现得更“粗”,而指令覆盖率更细致。所以设置门槛的时候用INSTRUCTION会更严格、更真实,建议直接用INSTRUCTION和BRANCH结合着看。
BRANCH覆盖率也很好理解,就是分支覆盖率。一个if语句有两个分支(true和false两种走向),如果你的测试只覆盖了true分支,BRANCH率就只有50%。这个数字对后端项目来说很有参考价值,因为很多bug恰恰藏在else分支、exception分支里。
8. 常见问题排查实录与实操技巧
单元测试的坑,写多了以后你会发现其实就那么几个套路,在这里我把最常遇到的问题整理成一张速查表,方便以后排查时直接对照。
| 现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
测试报UnnecessaryStubbingException | 某个when配置的桩在整个测试过程中从未被调用 | 检查测试逻辑是否有遗漏,比如某条链路根本没走完;确认是Mockito 1.x之后的默认严格模式,需要去掉多余的桩配置 |
测试报InvalidUseOfMatchersException | when或verify里混用了确定值和匹配器 | 检查同一方法调用的所有参数是否全部使用匹配器,不能一个用any()另一个用字面量 |
@Mock注入的是null | 测试类上没有加@ExtendWith(MockitoExtension.class),或者@InjectMocks注入目标不匹配 | 检查JUnit5扩展注解是否遗漏,检查被测类的依赖是否都用构造器方式统一注入 |
assertThrows里Lambda执行时总报“方法未打桩” | 某些方法不需要打桩,但Mock默认返回null之后又在业务代码里调用了它的方法 | 给该依赖打上合理默认值,或改为@Spy让真实逻辑跑通 |
| 覆盖率突然下跌 | 新增代码没有配套测试 | 用JaCoCo报告找到新增的未覆盖代码,优先补Service和工具类;如果是分支缺失,则增加边界值测试用例 |
| 测试执行慢得离谱 | 可能启动了完整Spring上下文,比如用了@SpringBootTest | 确认是否可以用@WebMvcTest或纯Mockito替代;单测不应该拉全量Spring容器 |
除了这些固定问题,再分享两个实用的调试小技巧。
第一个是Mockito的lenient模式。有时候打桩是给多个测试方法共用的,比如放在@BeforeEach里统一设置,但某个测试方法没走到那一条分支,严格模式下Mockito会抛UnnecessaryStubbingException,测试虽然通过就是会报这个错,很烦。解决办法是在@ExtendWith(MockitoExtension.class)上标注成严格模式,但单独测试方法上可以改用@MockitoSettings(strictness = Strictness.LENIENT),这样可以针对特定测试类关闭严格校验,避免共享桩导致误报。
第二个技巧是用Mockito的mockStatic来Mock静态方法。在老项目里,经常会有一些静态工具类被调用,比如IdGenerator.generate()、DateUtils.now()。Mockito从3.4版本开始支持mockStatic,用法如下:
try (MockedStatic<IdGenerator> mockedStatic = Mockito.mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn("UUID-123"); // 调用被测方法 }需要注意mockStatic需要用try-with-resources包裹,确保测试结束后静态Mock被还原,否则静态状态会在多个测试类之间串场,出现非常诡异的“上个测试类没跑完影响下一个测试类”的情况。我建议能用参数传入的方式就不要静态Mock,优先让设计更可测,静态Mock只用来处理历史遗留代码。
9. 给新项目和老项目各一套落地建议
刚起步的新项目,单元测试应该在搭骨架的那一天就引入。该做的事情就是三件:引入spring-boot-starter-test依赖、配置JaCoCo插件和覆盖率门槛、准备测试基类和application-test.yml。这三件套下来,后面写测试只是“顺着写”的问题,不存在“架构不可测”的硬伤。
老项目补测试,别想着一步登天。我最开始接手一个老旧服务模块的时候,一看总代码量好几万个类,根本没可能短期内全覆盖。我就给自己定了一个策略:每次改动一个方法,就把这个方法的测试补上;每次修一个bug,就为这个bug写一个回归测试。三个月下来,原来30%不到的覆盖率慢慢爬到了70%以上。这个过程中最有价值的不只是覆盖率,而是那些“为了复现bug而写的测试”,它们才是防止同一个坑踩两次的最有效手段。
保持测试的可持续性,还可以做到以下几点:
- 写测试的时候尽量把“行为验证”和“状态验证”结合起来。
verify是行为验证,断言返回值或对象状态是状态验证。只验证行为不验证状态,容易造成“方法确实调了但参数是错的”这种假绿。 - Mock外部依赖时,不要过度堆砌
when和verify的个数。测试的意图应该一目了然,一个测试方法讲清楚一件事。 - 注意测试的可读性。把测试方法名做到“读名字就知意图”,比如
shouldReturnUserWhenIdExists、shouldThrowExceptionWhenSalaryOutOfRange这类命名,比test1、test2有意义得多。 - 在CI上跑一次完整单元测试链路,确保测试报告和JaCoCo报告都能被归档和展示,至少团队内部开发时能看到自己的改动有没有引入覆盖率下降。
单元测试这件事,说白了不存在“不会做”,只有“想不想好好做”。工具链就那几样,JUnit5负责组织和断言,Mockito负责隔离外部依赖,JaCoCo负责把覆盖情况晾在阳光下。第一周写起来可能慢,每写一个测试都要反复调试打桩的方式和匹配器的写法,但写顺手之后,它带给你的安全感和效率提升是值得的。我在实际项目里最深的体会是:单元测试不是给别人交差的作业,而是给自己留的退路——无论是重构、升级版本,还是三个月后改自己写过的代码,那一堆绿色通过的测试,就是敢动手改的底气。