单元测试这事儿说大不大说小不小。我见过不少项目业务代码写得飞起一到测试就草草了事要么只测几个happy path要么连测试都没建线上出问题全靠用户当“自动报警器”。但这几年SpringBoot项目越做越复杂接口一多、依赖一深没有一套像样的测试兜底改需求的时候心里是真没底。我自己的习惯是从项目的第一天就把JUnit5和Mockito这块骨架搭好覆盖率不求100%但核心链路必须看得住。这篇文章就围绕SpringBoot里怎么把单元测试真正落地来聊从框架选型到Mockito的核心用法再到覆盖率怎么统计怎么设门槛一步一步拆开讲希望能给正在补测试这块短板的同学一些能直接抄作业的参考。1. 为什么SpringBoot项目的单元测试总被“将就”很多人容易混淆一个概念觉得SpringBoot项目里的测试只要把应用跑起来、发个HTTP请求、看看返回结果对不对就够了。这种属于集成测试或端到端测试不是单元测试。单元测试的粒度要小得多它的核心是只验证某一个类或某一个方法的逻辑通过Mock掉所有外部依赖数据库、Redis、第三方接口把被测对象隔离在一个“真空环境”里专门验证自己的逻辑是否正确。SpringBoot项目之所以单元测试容易被“将就”主要是三个原因。第一个原因是环境依赖太重。项目一启动就要连数据库、拉配置中心、初始化Redis连接池开发环境但凡有点网络波动测试就红一片。于是很多人觉得“测试跑不起来”索性就不写了。第二个原因是Mockito用得不够熟面对层层嵌套的Service依赖不知道该怎么打桩怎么验证行为写出来的测试又长又脆改一行业务代码就碎一片。第三个原因是覆盖率没有形成约束项目没有覆盖率门槛也没有人监督写了多少算多少最后统计一看三成不到和没写差别不大。但单元测试恰恰是SpringBoot项目里性价比最高的一层防护。它能精确地指出是哪一行代码出了问题而且毫秒级跑完不依赖外部环境。特别是Service层和Utils工具类这两个地方业务逻辑最集中最容易藏bug也是最值得用单测覆盖的地方。我的做法是Controller层做轻量级的切片测试WebMvcTestService层做纯Mockito的单元测试外部依赖全部打桩数据访问层做少量集成测试兜底。每一层测试的目标都很明确不会出现“想测Service却把整个Spring容器都拉起来”的情况。2. 技术选型为什么是JUnit5和Mockito以及怎么搭骨架提到Java测试框架就绕不开JUnit和TestNG而SpringBoot默认集成的就是JUnitSpring 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依赖否则跑测试的时候容易冲突出现一些“找不到测试方法”之类的诡异错误。建议的依赖是下面这套基于Mavendependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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的BeforeAfterEach对应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对象所有方法默认返回默认值对象返回nullint返回0boolean返回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只能确认方法调了拿不到入参。ArgumentCaptorUserEntity 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和OrderClientExtendWith(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()); ArgumentCaptorUserEntity 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发请求隔离掉ServiceController层测试有两种常见姿势。第一种是直接用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生态里统计覆盖率事实上的标准就是JaCoCoJava Code Coverage它作为Maven插件集成进项目跑完测试后自动生成覆盖率报告还能设置覆盖率门槛让构建失败。plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version configuration excludes exclude**/config/**/exclude exclude**/entity/**/exclude exclude**/dto/**/exclude exclude**/mapper/**/exclude /excludes /configuration executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution execution idcheck/id phasetest/phase goals goalcheck/goal /goals configuration rules rule elementBUNDLE/element limits limit counterINSTRUCTION/counter valueCOVEREDRATIO/value minimum0.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之后的默认严格模式需要去掉多余的桩配置测试报InvalidUseOfMatchersExceptionwhen或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 (MockedStaticIdGenerator 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负责把覆盖情况晾在阳光下。第一周写起来可能慢每写一个测试都要反复调试打桩的方式和匹配器的写法但写顺手之后它带给你的安全感和效率提升是值得的。我在实际项目里最深的体会是单元测试不是给别人交差的作业而是给自己留的退路——无论是重构、升级版本还是三个月后改自己写过的代码那一堆绿色通过的测试就是敢动手改的底气。
阅读完成 · 觉得有帮助?