
线上出过一次事故问题出在一段只有十几行的金额计算逻辑上改动它的人只测了主流程边界条件没覆盖结果优惠券和会员折扣叠加时多扣了钱。从那次之后我对 Java 单元测试的态度彻底变了它不是写给领导看的覆盖率数字而是给未来的自己留的一份后悔药。单元测试这件事说白了就是用一段可重复执行的代码去验证另一段代码在给定输入下是否给出了预期输出。它能帮你把重构的风险压到最低把联调时才发现的问题提前到编译后几秒内暴露也能让接手你代码的人看懂每个方法到底承诺了什么。这篇文章适合三类人刚学完 Java 基础、不知道测试从哪下手的同学写了几年业务代码、测试写得零零散散想成体系的后端以及想给自己项目加质量门禁、但不知道怎么落地的技术负责人。我会从选型、工程骨架、核心写法、完整案例、分层策略一直讲到踩坑排查尽量把每个为什么这么写都讲透。1. 先把边界划清楚Java 单元测试到底在测什么1.1 单元的粒度该怎么定很多人一开始会纠结一个类算一个单元还是一个方法算一个单元我的经验是单元测试的粒度应该跟着可独立验证的行为走而不是跟着文件结构走。比如一个DiscountService.calculate()方法它的行为是根据会员等级、原价、券码算出应付金额那这就是一个单元哪怕它内部调用了别的对象。真正决定粒度的标准是这段逻辑能不能在毫秒级内跑完、能不能不依赖网络和数据库、失败时能不能一眼定位到是哪条规则出了问题。如果反过来你写一个测试要启动 Spring 容器、连 MySQL、拉起 Redis跑一次十几秒那它已经不是单元测试了那是集成测试。名字叫什么不重要重要的是你要清楚自己写的是哪一类因为它们的目标完全不同单元测试追求快和稳负责锁定逻辑集成测试追求真负责验证各部件拼起来能不能用。把两者混在一起写最后的结果通常是又慢又脆改一行代码红一片团队里没人愿意维护。这里我要提醒一个常见的误区。有些人觉得我依赖了别的类所以必须整个链路都真实调用才算测到位。这个想法在业务代码里非常危险。假设你的服务依赖一个远程风控接口单元测试真去调它那你的测试结果就取决于网络抖不抖、对方服务挂没挂今天绿明天红这就是典型的脆测试。正确做法是把这类外部依赖用替身Stub/Mock挡掉只验证你自己的逻辑分支。注意判断一段测试该不该隔离依赖问自己一个问题——这个依赖出问题时我希望这条测试失败吗如果答案是不希望那就必须隔离。顺便提一句做嵌入式方向的朋友经常问单元测试怎么做他们的环境跑不了 JVM用的是专门的宿主环境仿真工具比如在 PC 上模拟目标芯片的行为来打桩。工具链和 Java 完全不一样但思路是一致的隔离硬件、构造输入、断言输出、跑回归。而前端同学现在用 Vitest 写组件测试思路也大同小异。方法论是通用的变的只是工具名。1.2 主流技术选型的取舍逻辑Java 这边的工具生态非常成熟组合方式基本是固定的但每个选择背后都有理由我按我的习惯说一遍。测试框架选JUnit 5也就是 Jupiter。理由很直接JUnit 4 的Test不支持多参数、没有DisplayName这种可读性友好的注解、扩展机制也弱。JUnit 5 的Nested能把测试类按场景分组报表输出层次清楚新人看测试报告就能明白业务规则。现在新项目还上 JUnit 4 的唯一理由是老框架或者老中间件对它做了强绑定这种情况下可以用junit-vintage-engine让两者共存逐步迁移不用一次性重写。依赖替换选Mockito。它的核心价值是让你能在不引入真实依赖的情况下控制被依赖对象的行为。比如你想验证远程接口抛异常时降级逻辑是否生效用 Mockito 一行when(repo.query(any())).thenThrow(new RuntimeException())就能模拟不用去想办法让真实服务出错。相比手写 Stub 类Mockito 省掉了大量样板代码尤其是依赖方法多的时候手写 Stub 要实现的空方法能写到你怀疑人生。断言库我推荐AssertJ不推荐裸用 JUnit 自带的assertEquals。原因有两个一是参数顺序容易记反assertEquals(expected, actual)写反了报错信息会误导你半天二是失败信息太干只告诉你expected 200 but was 199不告诉你是哪个字段、哪个维度出的问题。AssertJ 的链式 API 能写出assertThat(order).extracting(Order::getStatus).isEqualTo(PAID)这种自带说明的断言配合as()加描述排查效率完全不是一个量级。关注点推荐组合不推荐的写法原因测试框架JUnit 5 DisplayName纯 JUnit 4分组、参数化、扩展能力弱依赖隔离Mockito 5.x手写大量 Stub 类样板代码多维护成本高断言AssertJassertEquals直接比对象报错信息不友好易记反参数覆盖率JaCoCo靠肉眼估算无法在 CI 里做门禁时间/随机源注入Clock、Supplier直接调System.currentTimeMillis()结果不可复现这张表看起来简单但第三列那些不推荐的写法我在真实项目里全都见过而且往往是老代码里成片存在的。我的建议是不要一次性全量改造先把新增代码写规范老代码在你修改它的时候顺手补测试这就是业界常说的童子军军规。2. 从零搭一套能跑起来的测试工程2.1 依赖组合与版本坑Maven 项目的依赖清单我一般这么写properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target junit.version5.10.2/junit.version mockito.version5.11.0/mockito.version assertj.version3.25.3/assertj.version /properties dependencies dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version${junit.version}/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version${mockito.version}/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version${mockito.version}/version scopetest/scope /dependency dependency groupIdorg.assertj/groupId artifactIdassertj-core/artifactId version${assertj.version}/version scopetest/scope /dependency /dependencies关于版本有几个坑必须说清楚。JUnit 5 的聚合包junit-jupiter已经包含了 api、params、engine 三个模块不用一个个引。Spring Boot 项目如果用了spring-boot-starter-test这些依赖它全都帮你带进来了只是版本由父 POM 统一管理你不需要自己指定version指定了反而可能和 Spring 管理的版本冲突。我见过有人手动写了 Mockito 4.x 的版本号结果和 Spring Boot 3.x 带的版本打架MockBean注入一直为空排查了两个小时。另外一个高频问题想 mock 静态方法或者 final 类时提示不支持。Mockito 5.x 已经把内联 mock maker 作为默认实现mockStatic()、mockConstruction()直接可用。如果你维护的是 Mockito 4.x 的项目需要额外引mockito-inline这个 artifact 才能做到光引mockito-core会在运行时抛MockitoException。顺手说一句能用对象注入解决的就别去 mock 静态方法静态 mock 会让测试和实现细节绑得太死重构时特别难受。2.2 目录结构与命名约定标准的 Maven/Gradle 布局是src/test/java对应src/main/java包名保持完全一致。这个要求不是形式主义因为 IDEA 和 Maven 都靠这个约定去定位测试类包名不一致会出现测试类找不到无法解析符号等一堆怪问题。类的命名我习惯用被测类名 Test方法名用业务动作 场景 预期结果的三段式比如calculate_goldMember_overFiveHundred_applyHighestReduction。方法名长一点没关系可读性优先因为大多数时候你是从 CI 的失败列表里看到这个方法的名字本身就是信息。测试类里面我用人话描述代替注释ExtendWith(MockitoExtension.class) DisplayName(订单折扣计算服务) class DiscountServiceTest { Mock private CouponRepository couponRepository; private DiscountService discountService; BeforeEach void setUp() { discountService new DiscountService(couponRepository); } }这里用BeforeEach而不是直接在字段上初始化是为了保证每条测试拿到的是全新实例避免上一条测试改了对象状态影响到下一条。这个细节在早期写业务测试时特别关键我踩过一次某个测试类里 Service 是有状态缓存Map的成员变量测试按任意顺序执行结果都不一样浪费了整整半天。2.3 构建插件配置与并行执行测试要跑得快除了代码本身高效插件配置也有讲究。surefire 插件负责执行测试它的forkCount决定起几个 JVM 并行跑reuseForks决定是否复用 JVMplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration argLine${surefireArgLine}/argLine parallelmethods/parallel threadCount4/threadCount useUnlimitedThreadsfalse/useUnlimitedThreads /configuration /plugin这里argLine写成了${surefireArgLine}而不是空着是为了给 JaCoCo 留位置。这是覆盖率接入路上最大的一个坑JaCoCo 的prepare-agentgoal 默认会往argLine这个属性里塞 JVM 参数如果你在 surefire 里硬写了自己的一套argLine就把 JaCoCo 的参数覆盖掉了最后.exec文件生成为空报告显示覆盖率为 0你还以为是测试没跑。解决办法就是上面这样在 JaCoCo 里换个属性名两边接力plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions execution idprepare-agent/id goalsgoalprepare-agent/goal/goals configuration propertyNamesurefireArgLine/propertyName /configuration /execution execution idreport/id phasetest/phase goalsgoalreport/goal/goals /execution /executions /pluginparallel配置是把双刃剑它能显著缩短测试总时长但对那些忘了做隔离、共享静态状态的测试类是致命的。我的建议是先把测试写干净确保每条测试独立可重复再打开并行。打开之后全量跑三遍三遍都绿才敢往 CI 上放。3. 核心写法逐条拆解3.1 断言把验什么说清楚断言是测试的牙齿写得含糊相当于没写。我举几个我常用的写法// 基本值比较 assertThat(result).isEqualTo(new BigDecimal(180.00)); // BigDecimal 必须用这个不能用 isEqualTo assertThat(result).isEqualByComparingTo(180.00); // 对象集合的字段提取 assertThat(orders) .hasSize(3) .extracting(Order::getStatus) .containsOnly(OrderStatus.PAID); // 带上下文的失败信息 assertThat(result) .as(金卡 500 元应触发最高档满减) .isEqualByComparingTo(430.00);BigDecimal 的比较是新手最容易翻车的地方。new BigDecimal(180.0).equals(new BigDecimal(180.00))返回false因为equals会比较精度scale而compareTo只比数值。你的代码里如果有个setScale(2, RoundingMode.HALF_UP)而测试里写的是new BigDecimal(180)用isEqualTo断言就会失败报错信息看起来还特别荒谬——两个值肉眼看着一样。所以只要涉及金额我一律用isEqualByComparingTo或者在业务里定义好统一的 scale。as()这个方法看着不起眼价值却很高。当 CI 上一次性红了 20 条测试你需要快速判断哪条代表哪个业务规则一句好的描述能省掉打开源码的时间。3.2 参数化测试用数据表代替复制粘贴同一个方法有多个输入组合需要验证时千万别复制十遍测试方法用ParameterizedTestParameterizedTest(name [{index}] {0} 会员原价 {1}期望 {2}) CsvSource({ NORMAL, 199.99, 199.99, NORMAL, 200.00, 180.00, NORMAL, 500.00, 440.00, SILVER, 300.00, 265.00, GOLD, 500.00, 430.00, GOLD, 600.00, 480.00 }) void calculate_memberLevelAndPrice_returnsExpectedAmount( MemberLevel level, String price, String expected) { BigDecimal actual discountService.calculate( level, new BigDecimal(price), null); assertThat(actual).isEqualByComparingTo(expected); }CsvSource的好处是用例变成了一张数据表产品经理或者测试同学也能看懂新增一条边界值只需要加一行。当数据量大或者需要构造复杂对象时换MethodSource从另一个静态方法取数据流。需要注意的是CsvSource里的字符串会被自动转换成目标类型枚举靠valueOf匹配所以枚举名必须和字符串完全一致大小写不对会直接报ParameterResolutionException这个报错信息有时候不太好懂看到的时候先检查拼写。还有一个隐藏福利参数化测试的每一条数据在报告里是独立的某一条失败不影响其他条目继续执行你能一次性看到所有不满足的数据而不是改一个跑一次。3.3 Mockito打桩、验证与参数捕获Mockito 的三个核心动作用法分别是打桩when、验证verify、捕获ArgumentCaptor我用一个例子串起来ExtendWith(MockitoExtension.class) class DiscountServiceCouponTest { Mock private CouponRepository couponRepository; InjectMocks private DiscountService discountService; Test DisplayName(券门槛未达到时不抵扣但仍然要查询券信息) void calculate_couponBelowThreshold_notDeducted() { // 1. 打桩构造券信息 Coupon coupon new Coupon(SUMMER50, new BigDecimal(300), new BigDecimal(50)); when(couponRepository.findByCode(SUMMER50)).thenReturn(Optional.of(coupon)); // 2. 执行 BigDecimal result discountService.calculate( MemberLevel.NORMAL, new BigDecimal(200.00), SUMMER50); // 3. 断言业务结果200 触发满 200 减 20券门槛 300 未达到不抵扣 assertThat(result).isEqualByComparingTo(180.00); // 4. 验证交互确认依赖被调用了一次 verify(couponRepository, times(1)).findByCode(SUMMER50); verifyNoMoreInteractions(couponRepository); } }verifyNoMoreInteractions这个断言很多人不用但它其实很有价值。它会检查你 mock 的对象除了声明过的方法之外没有别的调用发生。业务代码里如果有人不小心在循环里查了券或者加了多余的额外查询这条断言会直接报出来。代价是它对重构不友好如果只是内部实现调整测试就会红。我的原则是核心依赖的交互次数要验证非核心的别加太死。参数捕获是验证复杂传参的利器Test DisplayName(计算时应把原价而不是折后价传给券校验) void calculate_passesOriginalPriceToCouponValidation() { ArgumentCaptorBigDecimal captor ArgumentCaptor.forClass(BigDecimal.class); when(couponRepository.findByCode(anyString())) .thenReturn(Optional.of(new Coupon(X, new BigDecimal(100), new BigDecimal(10)))); discountService.calculate(MemberLevel.GOLD, new BigDecimal(400.00), X); verify(couponRepository).findByCode(X); verify(couponRepository).validateThreshold(captor.capture()); assertThat(captor.getValue()).isEqualByComparingTo(400.00); }我为什么专门写这条测试因为门槛按原价算还是按折后价算是一个非常容易在需求变更里被改错的点而且改错了线上未必立刻暴露。用参数捕获把契约锁住以后谁再动这块逻辑测试立刻提醒他。注意Mock配合ExtendWith(MockitoExtension.class)时如果某个打桩的语句在测试中根本没被用到Mockito 会抛UnnecessaryStubbingException让测试失败。这是有意设计的防止你复制粘贴了一堆没用的桩代码。但如果你确实需要宽松模式可以用MockitoSettings(strictness Strictness.LENIENT)不过建议先想想为什么会有没用的桩——大概率是测试方法太大了该拆了。3.4 异常、超时与并发场景业务代码里的参数校验分支必须测到用assertThatThrownBy最舒服Test DisplayName(原价为负数时抛出 IllegalArgumentException) void calculate_negativePrice_throwsException() { assertThatThrownBy(() - discountService.calculate(MemberLevel.NORMAL, new BigDecimal(-1), null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(价格不合法); }注意.hasMessageContaining而不是.hasMessage全等匹配。全等匹配意味着别人改一个字测试就红那是把测试绑在了文案上维护成本高。除非这条消息是给用户看的、有明确契约否则一律用包含匹配。涉及异步的场景绝对不要用Thread.sleep(1000)然后再断言。这种写法在本地跑没问题到了 CI 的共享机器上因为负载高就必然偶发失败。推荐引 Awaitilityawait().atMost(Duration.ofSeconds(3)) .pollInterval(Duration.ofMillis(50)) .untilAsserted(() - assertThat(cache.get(k)).isEqualTo(v));它的逻辑是轮询等待条件成立成立就立即返回不等满整个超时时间既快又稳。如果是线程池相关的测试记得在AfterEach里shutdown()否则 JVM 会因为非守护线程不退出而挂住构建就一直卡在那儿不动这种看起来卡死的情况十有八九是线程没关。4. 拿一个真实业务场景走完整流程4.1 被测代码与规则梳理我上面一直用折扣计算举例现在把它的规则完整写出来这样你能看清测试用例是怎么从规则推导出来的。需求是这样的会员等级基础折扣普通会员不打折银卡打 95 折金卡打 9 折。折扣之后按金额区间满减满 200 减 20满 500 减 60两档只取最高的一档。如果传了券码从券仓库查出券信息当原价达到券的门槛时抵扣券的面额。最终结果保留两位小数四舍五入。public class DiscountService { private static final BigDecimal SILVER_RATE new BigDecimal(0.95); private static final BigDecimal GOLD_RATE new BigDecimal(0.90); private static final BigDecimal TIER_ONE new BigDecimal(200); private static final BigDecimal TIER_TWO new BigDecimal(500); private static final BigDecimal TIER_ONE_CUT new BigDecimal(20); private static final BigDecimal TIER_TWO_CUT new BigDecimal(60); private final CouponRepository couponRepository; public DiscountService(CouponRepository couponRepository) { this.couponRepository couponRepository; } public BigDecimal calculate(MemberLevel level, BigDecimal originalPrice, String couponCode) { if (originalPrice null || originalPrice.signum() 0) { throw new IllegalArgumentException(价格不合法: originalPrice); } BigDecimal payable originalPrice.multiply(rateOf(level)); payable applyTierReduction(payable); if (couponCode ! null !couponCode.isBlank()) { payable couponRepository.findByCode(couponCode) .filter(c - originalPrice.compareTo(c.getThreshold()) 0) .map(c - payable.subtract(c.getAmount())) .orElse(payable); } if (payable.signum() 0) { payable BigDecimal.ZERO; } return payable.setScale(2, RoundingMode.HALF_UP); } private BigDecimal rateOf(MemberLevel level) { return switch (level) { case SILVER - SILVER_RATE; case GOLD - GOLD_RATE; default - BigDecimal.ONE; }; } private BigDecimal applyTierReduction(BigDecimal amount) { if (amount.compareTo(TIER_TWO) 0) { return amount.subtract(TIER_TWO_CUT); } if (amount.compareTo(TIER_ONE) 0) { return amount.subtract(TIER_ONE_CUT); } return amount; } }4.2 用例设计的推导过程写测试之前先做设计这一步比敲代码重要得多。我的方法是先列等价类再找边界值。等价类划分会员等级三档各自乘上折扣后金额落点不同。价格区间有四段低于 200、等于 200 到 499.99 之间、等于 500、高于 500。券这条维度有三个等价类不传券、传了券且原价达标、传了券但原价没达标。边界值则是这些数字199.99、200.00、499.99、500.00。为什么是这两个阈值上下各取一点因为和的差别、精度丢失都只会出现在边界上。数量上不需要穷举所有组合我通常取每档会员 × 关键阈值的交叉再补券的正反两个场景十几条用例就能把主要逻辑覆盖住。具体算一下几条关键数据的期望值方便你对答案。金卡原价 500先乘 0.9 得到 450450 落在 200 到 500 之间触发满 200 减 20得到 430.00。银卡原价 300乘 0.95 得到 285触发减 20得到 265.00。普通会员原价 500不减价直接命中 500 档减 60 得 440.00。注意满减的判断依据是折扣后的金额而券门槛的判断依据是原价这个不一致是需求决定的也正因为它反直觉才更需要测试锁住。4.3 测试代码实现ExtendWith(MockitoExtension.class) DisplayName(订单折扣计算) class DiscountServiceTest { Mock private CouponRepository couponRepository; private DiscountService discountService; BeforeEach void setUp() { discountService new DiscountService(couponRepository); } ParameterizedTest(name 第{index}条: {0}会员 原价{1} 期望{2}) CsvSource({ NORMAL, 199.99, 199.99, NORMAL, 200.00, 180.00, NORMAL, 499.99, 479.99, NORMAL, 500.00, 440.00, SILVER, 199.99, 189.99, SILVER, 300.00, 265.00, GOLD, 500.00, 430.00, GOLD, 600.00, 480.00 }) void calculate_noCoupon_returnsExpected(MemberLevel level, String price, String expected) { BigDecimal actual discountService.calculate(level, new BigDecimal(price), null); assertThat(actual).isEqualByComparingTo(expected); } Test DisplayName(券门槛按原价判定原价300恰好达标正常抵扣50) void calculate_couponThresholdExactlyReached_deducted() { when(couponRepository.findByCode(C50)) .thenReturn(Optional.of(new Coupon(C50, new BigDecimal(300), new BigDecimal(50)))); BigDecimal actual discountService.calculate( MemberLevel.NORMAL, new BigDecimal(300.00), C50); // 300 触发满200减20 - 280券再减50 - 230 assertThat(actual).isEqualByComparingTo(230.00); } Test DisplayName(券门槛未达标仍然查询了券但不抵扣) void calculate_couponBelowThreshold_notDeducted() { when(couponRepository.findByCode(C50)) .thenReturn(Optional.of(new Coupon(C50, new BigDecimal(300), new BigDecimal(50)))); BigDecimal actual discountService.calculate( MemberLevel.NORMAL, new BigDecimal(299.99), C50); assertThat(actual).isEqualByComparingTo(279.99); } Test DisplayName(券码不存在走原逻辑不抛异常) void calculate_couponNotFound_keepsOriginalResult() { when(couponRepository.findByCode(UNKNOWN)).thenReturn(Optional.empty()); BigDecimal actual discountService.calculate( MemberLevel.SILVER, new BigDecimal(300.00), UNKNOWN); assertThat(actual).isEqualByComparingTo(265.00); } Test DisplayName(券面额大于应付金额时最低付到0) void calculate_couponLargerThanPayable_floorAtZero() { when(couponRepository.findByCode(BIG)) .thenReturn(Optional.of(new Coupon(BIG, new BigDecimal(100), new BigDecimal(999)))); BigDecimal actual discountService.calculate( MemberLevel.NORMAL, new BigDecimal(100.00), BIG); assertThat(actual).isEqualByComparingTo(0.00); } Test DisplayName(空券码视为不使用券不应触发任何查询) void calculate_blankCouponCode_noRepositoryCall() { BigDecimal actual discountService.calculate( MemberLevel.GOLD, new BigDecimal(600.00), ); assertThat(actual).isEqualByComparingTo(480.00); verifyNoInteractions(couponRepository); } ParameterizedTest NullSource ValueSource(strings {-0.01, -100}) DisplayName(非法价格抛出异常) void calculate_invalidPrice_throws(String price) { assertThatThrownBy(() - discountService.calculate( MemberLevel.NORMAL, price null ? null : new BigDecimal(price), null)) .isInstanceOf(IllegalArgumentException.class); } }这份测试里我最想强调第三条和第七条。第三条验证了而不是需求说满 300 减 50那 299.99 就不能减差一分钱都不行这种精度问题在真实业务里经常引发客诉。第七条验证了边界上的不调用行为空字符串和 null 都不应该去查库因为查库本身是有成本的多一次无效查询在高并发下就是多一份压力。用verifyNoInteractions把这个契约锁死比写在注释里靠谱。4.4 覆盖率报告怎么看跑mvn test之后JaCoCo 报告默认生成在target/site/jacoco/index.html。打开之后先看行覆盖率和分支覆盖率两个指标。行覆盖率告诉你哪些代码执行过分支覆盖率告诉你 if/else 的两条路是不是都走过了。很多人只盯行覆盖率结果出现行覆盖 90% 但分支覆盖 50%的情况——代码都跑到了但else分支从来没验证过这等于没测。如果要加门禁在jacoco-maven-plugin的checkgoal 里配规则execution idcheck/id goalsgoalcheck/goal/goals configuration rules rule elementBUNDLE/element limits limit counterLINE/counter valueCOVEREDRATIO/value minimum0.60/minimum /limit /limits /rule /rules /configuration /execution关于门禁阈值我的看法很明确不要一上来就定 80% 然后强制卡死团队会为了过线去写一堆没有任何断言的空测试覆盖率数字上去了质量一点没涨。比较务实的做法是先定 50%~60% 的兜底线重点模块单独提高要求比如金额计算、权限判断这类核心类要求 85% 以上同时用excludes把 DTO、配置类、自动生成的代码排掉别让这些拉低整体数字还逼着你写无意义的测试。5. Spring Boot 项目里的分层测试策略5.1 三种测试的适用边界Spring 项目里测试分三层代价递增纯单元测试用 JUnit Mockito不启动容器毫秒级切片测试只加载一层的组件比如WebMvcTest只加载 MVC 相关配置、DataJpaTest只加载 JPA 相关配置秒级集成测试用SpringBootTest加载完整上下文可能还要连数据库十秒起步。我的经验比例大概是 70% 单元、20% 切片、10% 集成。这个比例背后的逻辑是越便宜的测试写得越多越贵的测试只用来验证拼装正确性。很多人把 Controller 的测试全写成SpringBootTest结果一个测试类启动要八秒一百个测试方法全跑一遍十几分钟慢慢地就没人愿意在本地跑了CI 上排队更久最后变成能跳过就跳过。其实大部分参数校验、业务分支逻辑完全可以下沉到 Service 层的纯单元测试里Controller 那一层用WebMvcTest只验证路由、参数绑定、JSON 序列化就够了速度能提升一个数量级。需要提醒的是 Spring Boot 3.4 之后MockBean已经被MockitoBean取代SpyBean对应MockitoBean和MockitoSpyBean。老写法虽然还能用但会有废弃警告。如果你正在写新代码直接用新的注解避免以后再改一轮。5.2 SpringBootTest 的正确使用姿势真要用完整上下文时有几个细节能省很多时间。第一尽量复用上下文。Spring 会按配置缓存 ApplicationContext如果你的测试类之间MockBean声明不一致缓存就会被击穿每个类都重新启动一遍容器。我在项目里见过同一个模块三十多个测试类每个启动一次跑一轮要五分钟把MockBean统一抽到父类之后降到四十秒。第二数据库依赖尽量用 H2 内存库或者容器化数据库避免测试之间互相污染。第三Transactional加在测试类上可以让每条测试结束后回滚但要注意这也会影响被测代码里手动提交的事务语义有些场景会测不准用之前想清楚。6. 常见问题与排查技巧实录6.1 速查表现象大概率原因处理方式覆盖率一直是 0JaCoCo 的 argLine 被 surefire 覆盖用独立属性名接力传参BigDecimal 断言莫名失败equals 比较了精度改用 isEqualByComparingToUnnecessaryStubbingException有桩从未被使用删掉或拆小测试方法测试本地过、CI 挂依赖了时间、随机数、执行顺序注入 Clock固定随机种子JVM 不退出、构建卡住测试里创建的线程池没关AfterEach 中 shutdown静态 mock 报错Mockito 版本过低升级到 5.x 或补 mockito-inline测试相互影响共享了静态状态或单例缓存每条测试重建被测对象断言偶发失败用了 Thread.sleep 等异步换 Awaitility 轮询6.2 让测试不脆的几条经验时间依赖必须注入。任何直接用LocalDate.now()、Instant.now()的代码都不可测因为你没法断言昨天提交的单据是否过期。正确做法是构造方法注入Clock生产代码传Clock.systemDefaultZone()测试传Clock.fixed(...)。这个改动成本很低但收益是让所有跟时间相关的规则都能被精确验证。随机性要可控。如果你的代码里有 UUID 生成、随机抽奖、打散顺序这类逻辑把它们抽象成一个Supplier或者策略接口注入进来。测试里传一个固定序列的 Supplier你就能精确断言第三次抽中了什么。我见过有人为了测随机逻辑跑一万遍看分布那种测试又慢又不可靠属于用错误的方法解决错误的问题。不要在单元测试里断言日志。有些测试方案会去捕获 logback 输出然后断言日志内容这属于把测试绑在了日志文案上改个日志格式测试全红。日志是给人看的不是契约。如果某个行为重要到必须验证它就应该有返回值或者状态变化可断言。测试也要做代码审查。我坚持的一条规则是如果一个测试方法超过 30 行说明它验证了太多东西该拆。理想状态的测试是准备、执行、断言三段清晰中间没有复杂的条件分支。测试里出现 if/else 和 for 循环几乎一定意味着设计出了问题。7. 落地到团队从个人习惯变成工程能力7.1 门禁怎么定才不会被绕过覆盖率门禁最容易失败的落地方式是把它设成一条硬性百分比然后交给 CI 强制卡住。团队的反应一定是写空测试凑数或者在配置里加一堆 exclude 把难点代码排除掉。我参与过的项目里效果比较好的一套组合是这样的增量覆盖率卡死存量覆盖率只做趋势监控。也就是说只对本次提交改动的行做覆盖率要求比如新增代码必须 80%历史代码不追溯但每周看一次整体趋势如果一直往下掉就找人聊聊。这样既不会让人为了老代码疲于奔命又能保证新代码的质量底线。另外门禁失败的时候CI 的提示信息一定要写清楚。我见过 CI 只输出一句 coverage check failed然后大家一脸茫然地去翻报告。把失败的那几个类的名字和当前数值打在构建日志里能省掉大量沟通成本。7.2 测试可维护性的三个抓手第一个抓手是测试命名规范。我们约定测试方法名必须能读懂禁止出现test1、testMethod、testCalc这类命名也禁止用Disabled长期挂着一个测试然后没人管。团队里可以定期扫一下被禁用的测试每个都要有明确的复活计划或者删除理由挂着不动的测试比没有测试更糟因为它给人一种这里有覆盖的错觉。第二个抓手是测试数据的组织方式。不要在每条测试里重复堆砌构造对象的代码抽一个TestDataFactory或者用构造器模式的 Builder。但要小心别做成万能工厂参数多到十几个的工厂方法本身就是坏味道说明你的领域对象职责太重了。第三个抓手是把测试当成文档维护。新人接手一个模块时我一般让他先看测试类而不是源码因为测试类写得好业务规则一目了然。反过来说如果新人看完测试还是不知道业务怎么跑那说明测试写得有问题这本身就是一次很好的代码审查机会。7.3 和前端、嵌入式场景的对照顺带说一个观察。前端项目现在用 Vitest 做单元测试配合组件测试库思路和 Java 这边几乎是镜像的describe对应测试类it对应测试方法vi.mock对应 Mockito 的 mock覆盖率同样是核心指标。如果你两边都写会发现真正需要重新学习的只有工具 API设计用例的思维方式是共通的等价类、边界值、契约验证这套东西放到哪个语言都成立。做嵌入式的团队用 Testbed 之类的工具在 PC 上模拟目标环境本质也是隔离依赖 构造输入 断言输出只是打桩的方式换成了存根函数和硬件抽象层。所以别把单元测试当成某门语言的技能它是一种工程习惯。我自己这些年最大的转变是以前觉得写测试是在给代码加负担现在觉得没有测试的代码才是负担。一个有测试覆盖的方法我改起来是放松的改完跑一遍绿的就去提交没有测试覆盖的方法我改一行要看三遍还要找人一起 review。这个心态上的差别比任何工具配置都更能决定你的代码质量走多远。如果现在让你从一个小工具类开始补测试别想着一步到位就先写三条一条正常路径、一条边界值、一条异常路径把mvn test跑绿的瞬间你就已经入门了。