ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java单元测试从覆盖率到安全网:JUnit 5与Mockito实战

Java单元测试从覆盖率到安全网:JUnit 5与Mockito实战 1. 覆盖率很好看线上还是出事Java单测到底在测什么我见过太多团队的单元测试是这样的CI 面板上覆盖率 85%绿色一片看起来无比健康。结果某个周末凌晨一个只有金额为 0 才会触发的分支把订单金额算成了负数线上告警炸响。回头翻代码那个 if 分支根本没被测过因为写测试的人当时只 mock 了正常路径。这件事让我重新思考一个问题Java 单元测试到底在测什么它测的不是代码跑没跑过而是你对这段代码行为的假设对不对。一行被执行的代码不等于一个被验证的断言这两者在覆盖率工具眼里长得一模一样在事故复盘时会显出巨大差别。单元测试是把一个类、一个方法从它的依赖网里单独薅出来给它输入断言它的输出和副作用。它管的是逻辑分支、边界条件、异常路径这些人脑容易漏掉的地方。适合读这篇文章的人有三类刚学完 Java 语法、能写业务但没系统写过测试的新人写了几年测试但总觉得写了跟没写一样的中级工程师以及需要在团队里推行测试规范、被覆盖率指标折磨的技术负责人。接下来我会从 JUnit 5 的骨架讲到 Mockito 的依赖隔离从参数化测试讲到覆盖率数字的骗人之处最后拿一个真实的订单服务案例把测试从摆设改造成安全网的完整过程摊开讲。1.1 单元测试的边界它管不了的事先把边界划清楚否则后面全是无用功。单元测试不负责验证数据库连不连得上、HTTP 接口通不通、消息队列有没有投递成功。这些属于集成测试和端到端测试的地盘。你一旦在单元测试里真的去连数据库测试就变成了看运气——本地能过CI 挂了因为 CI 容器里没起 MySQL。单元测试真正要守住的是一个类内部的条件判断、循环边界、状态流转和异常抛出。比如一个金额计算方法输入是 BigDecimal输出也是 BigDecimal中间不依赖任何外部资源这种就是单元测试的蜜糖。反过来一个方法里又是查库又是发短信又是调第三方硬要给它做单元测试你会被迫 mock 一大堆东西测试代码比被测代码还长维护成本高到没人愿意碰。我个人的判断标准很粗暴如果一个类的测试里出现了超过 5 个 mock那八成是设计出了问题不是测试出了问题。该拆的职责没拆开该抽的依赖注入没抽出来。这时候正确的动作是重构生产代码而不是硬着头皮堆 mock。1.2 三种看起来很努力的无效测试第一种是断言缺失型。测试方法调用了被测方法跑完没抛异常就算通过。这种测试唯一的作用是保证代码不崩跟编译器的作用重叠价值极低。你打开一看方法体里就一行orderService.create(request);连个 assert 都没有。第二种是断言过宽型。用assertEquals(1, list.size())这种断言去测一个返回复杂对象的接口只验了长度对象内容对不对完全没管。数据显示错误、字段映射错位这类 bug 能从它眼皮底下大摇大摆走过去。第三种是镜像实现型。写测试的人把生产代码的逻辑在测试里又抄了一遍然后用同样的算法算出期望值去比对。这种测试永远不会失败因为它测的是我的算法等于我的算法。真出了问题两边一起错测试照样绿。注意判断一个测试有没有价值就问自己一句话——如果我把这段生产代码的某个判断条件改反了这个测试会不会红如果不会它就是无效测试。2. JUnit 5 的骨架注解、生命周期与断言JUnit 5 是现在 Java 单测的默认底座它的结构比 JUnit 4 清晰得多拆成了三个模块JUnit Platform 负责跑测试、JUnit Jupiter 是新的编程模型和扩展、JUnit Vintage 用来兼容老的 JUnit 4 用例。日常写测试你只需要关心 Jupiter 的注解和断言。引入方式用 Maven 的话注意 JUnit 5 的依赖 scope 是test别打进生产包。很多人第一次配会把junit-jupiter直接写进 compile 依赖导致生产 jar 莫名变大。2.1 常用注解与执行顺序注解是 JUnit 5 的入口。核心的几个必须烂熟于心Test标记测试方法BeforeEach和AfterEach在每个测试前后各跑一次BeforeAll和AfterAll在整个测试类前后各跑一次注意必须是 static 方法除非用TestInstance(Lifecycle.PER_CLASS)Disabled临时禁用DisplayName给人看的可读名字。class OrderCalculatorTest { private OrderCalculator calculator; BeforeAll static void initAll() { System.out.println(整个类开始); } BeforeEach void setUp() { calculator new OrderCalculator(); } Test DisplayName(满减规则下金额正确计算) void shouldCalcDiscount() { BigDecimal result calculator.calc(new BigDecimal(200)); assertEquals(new BigDecimal(180), result); } AfterEach void tearDown() { calculator null; } }执行顺序上默认情况下 JUnit 5 不保证同一层级的测试方法顺序这是故意的目的是让测试之间彼此独立。如果你真的需要固定顺序比如有状态的场景可以用TestMethodOrder(MethodOrderer.OrderAnnotation.class)配合Order。但我要提醒一句一旦你开始依赖测试执行顺序说明测试之间已经不独立了这是坏味道的起点。优先去修测试的隔离性而不是靠排序掩盖问题。2.2 断言体系的选型JUnit 5 自带的Assertions够用但如果你想写出可读性更高的断言可以上 AssertJ。两者的差别在于报错时你能不能一眼看出哪里不对。断言方式写法示例失败信息JUnit 原生assertEquals(expected, actual)expected: 180 but was: 200AssertJ 链式assertThat(actual).isEqualByComparingTo(180)expected: 180 but was: 200并打印对象信息JUnit 异常断言assertThrows(IllegalArgumentException.class, () - ...)明确抛没抛、抛的对不对浮点数和 BigDecimal 是重灾区。assertEquals(double, double)必须带 delta否则精度误差会让你怀疑人生。BigDecimal 的相等我强烈建议用compareTo而不是equals因为equals会把1.0和1.00判成不等而业务上它们是一个数。2.3 生命周期钩子的使用陷阱BeforeEach里做重活是很常见的错误。比如在里面查数据库、读大文件、启动 mock server结果 200 个测试方法每个都跑一遍测试总时长从 30 秒涨到 15 分钟然后没人愿意在本地跑了。快的东西放BeforeEach慢的东西想办法放BeforeAll或者干脆挪到集成测试里去。还有一个坑是BeforeAll写成了非 static 方法编译期就报错。如果你确实想在BeforeAll里访问实例字段加TestInstance(TestInstance.Lifecycle.PER_CLASS)让测试类只实例化一次。但这会带来副作用——每个测试方法共享同一个实例如果测试里有可变状态就会互相污染。权衡点在于你要的是初始化性能还是测试隔离性。大多数业务测试应该选隔离性。3. Mockito 的依赖隔离打桩、验证与假对象单元测试之所以单元靠的就是把依赖切断。Mockito 是 Java 世界里做得最成熟的 mock 框架和 JUnit 5 能无缝集成。它的核心能力就两件事打桩stub规定依赖被调用时返回什么和验证verify检查依赖有没有被按预期调用。先说一个现在基本不用手动写的语法变化。Mockito 2 之后MockitoAnnotations.initMocks(this)已经过时改用ExtendWith(MockitoExtension.class)配合Mock注解框架自动帮你注入。手写 initMocks 不仅啰嗦还会漏掉严格打桩strict stubbing的检查。3.1 Mock、Spy、Fake 怎么选这三个概念经常被混着用但它们的语义完全不同。Mock完全假的替身所有方法默认返回 null 或 0你打桩什么它才会什么。Spy真实对象的部分替身默认调用真实方法你只针对个别方法打桩。Fake有真实逻辑的简化实现比如内存版的 UserRepository。我踩过一个很典型的坑有人为了测一个带缓存的类用spy包了一层真实对象然后只 stub 了缓存读取。结果测试跑起来把真实的数据库查询也执行了测试跑得很慢还时不时因为数据问题挂掉。能用 Mock 的地方别用 Spy因为 Spy 会悄悄执行真实代码你以为隔离了其实没有。3.2 when/thenReturn 与 verify 的正确姿势标准写法是这样ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private OrderRepository orderRepository; Mock private PaymentClient paymentClient; InjectMocks private OrderService orderService; Test void shouldCreateOrderAndCallPayment() { OrderRequest req new OrderRequest(U100, new BigDecimal(99.00)); when(orderRepository.save(any(Order.class))) .thenAnswer(inv - inv.getArgument(0)); orderService.create(req); verify(orderRepository, times(1)).save(any(Order.class)); verify(paymentClient, times(1)).charge(eq(U100), eq(new BigDecimal(99.00))); } }几个容易翻车的细节。第一when(...).thenReturn(...)里面的方法调用是真的会被执行的所以对 void 方法没用得用doReturn().when()或者doNothing()。第二verify里的参数最好用精确匹配any()用多了会让测试变得很虚完全测不出参数传错的情况。第三times(1)是默认值不写也行但写出来可读性更好尤其是团队规范要求显式的时候。提示Mockito 的严格打桩会检查你声明的 stub 有没有被用到。如果声明了却没用测试会报 UnnecessaryStubbingException。这个检查一开始会让人烦但它能揪出一大批过期测试——那些因为生产代码改了、stub 早就失效却没人发现的用例。3.3 ArgumentCaptor 与参数匹配器有些场景你没法用简单的verify(equalTo(...))验证因为参数是个复杂对象或者你关心的只是里面某一个字段。这时候用ArgumentCaptor把参数抓出来再断言。ArgumentCaptorOrder captor ArgumentCaptor.forClass(Order.class); verify(orderRepository).save(captor.capture()); Order saved captor.getValue(); assertEquals(U100, saved.getUserId()); assertEquals(OrderStatus.CREATED, saved.getStatus());参数匹配器还有一个隐藏规则一旦你用了匹配器所有参数都要用匹配器。混用原始值和any()会报 InvalidUseOfMatchersException。所以要么全用eq()要么全用any()别一半一半。4. 参数化测试与测试数据组织一个方法有五种边界输入你是写五个Test还是一个参数化测试前者的成本是五倍的样板代码后者的成本是一份数据表。JUnit 5 的ParameterizedTest就是为这种场景准备的用好了能让测试代码瘦一半。4.1 ParameterizedTest 的几种数据源最常用的是ValueSource和CsvSource。前者给单个参数塞一串值后者按 CSV 格式给多参数。ParameterizedTest(name 金额 {0} 打 {1} 折后应为 {2}) CsvSource({ 100, 0.9, 90.0, 200, 0.8, 160.0, 0, 0.5, 0.0 }) void shouldApplyDiscount(String amount, String rate, String expected) { BigDecimal result calculator.apply(new BigDecimal(amount), new BigDecimal(rate)); assertEquals(new BigDecimal(expected), result); }如果数据量大或者需要动态生成用MethodSource指向一个返回StreamArguments的 static 方法。我一般会把测试数据单独放进一个*TestData类里让测试方法保持干净数据变更时也只改一处。NullSource、EmptySource用来覆盖 null 和空值这类边界恰恰是最容易让线上炸掉的。注意CsvSource里字符串带逗号或者引号要小心转义中文逗号不会出问题但英文逗号会。测试名称里的{0}、{1}是占位符加上以后失败信息会告诉你哪一组数据挂了排查效率立竿见影。4.2 测试数据构造Builder 与测试夹具随着业务变复杂构造一个测试用的实体可能会需要十几行代码。new Order()然后 set 一大堆字段写十个测试就重复十遍。两种解法一是给实体加个测试用的 Builder可以用 Lombok 的Builder也可以手写二是抽一个TestFixtures工具类放默认值只覆盖你关心的字段。public class OrderFixtures { public static Order.OrderBuilder defaultOrder() { return Order.builder() .userId(U000) .amount(new BigDecimal(10.00)) .status(OrderStatus.CREATED) .createdAt(LocalDateTime.of(2024, 1, 1, 0, 0)); } }这样测试里就是OrderFixtures.defaultOrder().amount(new BigDecimal(99)).build();只有变化的部分在测试体里出现读者一眼能看出这个用例关心的是金额。测试的可读性和生产代码一样重要因为测试的读者往往是半年后的你自己。5. 覆盖率、测试坏味道与重构信号覆盖率是个被过度神话的指标。我见过团队为了凑覆盖率指标专门写一堆没有断言的 getter、setter 测试把数字刷到 90%实际防护能力几乎为零。真正该看的不是总覆盖率而是分支覆盖率和新增代码覆盖率。5.1 覆盖率的正确读法行覆盖率告诉你哪些行被执行过分支覆盖率告诉你哪些 if/else 的岔路都走过了。一个if (a b)如果把 a 为真 b 为假的情况测过行覆盖率就满了但分支覆盖率会显示有分支没走。所以总覆盖率作为健康度参考别当 KPI 硬压新增代码覆盖率差量覆盖率才是有价值的门槛通常要求 70% 以上高风险模块计费、结算、风控可以单独设更高的门槛。JaCoCo 是目前最主流的选择。它能生成 HTML 报告点进去能看到每行代码被哪个测试覆盖。我排查这段逻辑到底测没测的时候经常直接看 JaCoCo 报告的行颜色比翻测试代码快得多。5.2 常见测试坏味道清单坏味道表现修复方向测试间共享可变状态单独跑过、一起跑挂每个测试独立 set up断言过弱只断长度/非空断到具体字段值过度 mockmock 数量超过 5 个重构生产代码拆职责依赖执行顺序必须按顺序才过消除隐式依赖测试睡眠Thread.sleep(1000)用 Awaitility 轮询魔法数字期望值是凭空写的提取常量并注释来历Thread.sleep是异步测试里最常见的偷懒做法问题是它既慢又不稳。网络抖动或者机器负载高的时候睡 1 秒根本不够测试随机红。正确姿势是用 Awaitility 这类轮询库设置超时和轮询间隔条件满足就返回比死等高效得多。6. 接进构建流水线Maven 与 Gradle 配置实战测试写得再好如果不能在本地一条命令跑起来、在 CI 上自动卡住问题价值就大打折扣。构建工具这一环必须配明白。6.1 Maven Surefire 与 JaCoCo 配置Maven 跑单测靠 Surefire 插件生成覆盖率报告靠 JaCoCo 插件。build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration argLine{argLine} -Dfile.encodingUTF-8/argLine /configuration /plugin plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions execution goalsgoalprepare-agent/goal/goals /execution execution idreport/id phasetest/phase goalsgoalreport/goal/goals /execution /executions /plugin /plugins /build这里有个高频坑{argLine}是 JaCoCo 通过prepare-agent注入的 JVM 参数如果你自己又在 Surefire 里写了一份argLine把它覆盖掉覆盖率会直接变成 0。解决办法就是像上面那样把{argLine}显式带上别自己新写一个覆盖它。6.2 Gradle 配置与并行执行Gradle 侧的配置更简洁test { useJUnitPlatform() testLogging { events passed, skipped, failed exceptionFormat full } maxParallelForks Runtime.runtime.availableProcessors().intdiv(2) ?: 1 }useJUnitPlatform()必须写否则 JUnit 5 的测试根本不会被识别症状是所有测试显示为 0 个。maxParallelForks开启并行能显著缩短测试时间但前提是测试之间真的相互独立——并行会把顺序依赖这种隐藏问题直接暴露出来。我一般先在本地开并行跑一遍把红掉的测试逐个修成独立的再开进 CI。CI 里我会用两段式策略快速单测在每次 push 时跑超过 5 分钟的重测试放 nightly。让开发者等 20 分钟拿反馈的流水线最后一定会被绕过。7. 一个订单服务的测试演进实录理论讲完上个真实案例。前阵子接手一个订单创建服务原来的测试一塌糊涂我用三个版本把它改成了稳定安全网。7.1 第一版什么都不 mock测试全在连真实环境最早的测试是这样的直接 new 一个 OrderService然后在测试方法里连真实数据库、真实支付网关。症状非常典型——本地偶尔能过CI 必挂而且跑一个测试要 3 秒以上。有人为了让它过把支付网关换成了测试环境的假地址结果测试之间还互相污染A 测试创建的订单影响了 B 测试的查询。根本问题是没有做依赖隔离测试根本没单元化。修复动作是把依赖通过构造器注入然后全部 mock 掉。改完之后单测跑到了毫秒级。7.2 第二版过度 mock 的教训修完隔离问题后走另一个极端。因为要 mock 的东西太多测试里堆了 8 个Mock每个都 stub 一堆返回值测试体 60 行其中 50 行是铺数据。改个生产逻辑得同步改五六个测试团队怨声载道。复盘后发现真正的问题在生产代码OrderService 一个类干了参数校验、库存扣减、金额计算、持久化、发消息五件事。我们把它拆成了OrderValidator、AmountCalculator、OrderPersister、OrderNotifier四个类每个类的测试都只需要一两个 mock可读性和稳定性同步提升。这段经历给我最大的启发是单元测试难写十有八九是生产代码该重构了。测试是设计的探针探针卡住了先看设计而不是先写更多 mock。7.3 第三版分层测试结构与运行策略最终稳定下来的结构是三层纯逻辑类计算、校验用纯 JUnit 测试不引入任何 mock有依赖的编排类用 Mockito 做单元测试重点测流程分支跨组件的行为用SpringBootTest的切片测试只测关键路径数量克制。运行策略上本地开发跑前两层200 个用例 15 秒内出结果切片测试在 CI 合并前跑。这样既保证了反馈速度又守住了关键路径。还有个小技巧我一直在用给每个测试类加DisplayName写中文说明方法名用should_预期_当_条件的格式。半年后回来排查回归问题光看测试列表就能大致定位到出问题的行为省下大量读代码的时间。7.4 配套的团队约定最后落地了几条约定比任何工具都管用新提交的生产代码必须带对应测试差量覆盖率不达标 PR 直接打回禁止Thread.sleep异步一律用轮询单个测试方法超过 40 行要拆mock 超过 5 个要评审设计测试命名统一规范失败信息要能直接告诉人哪里错了。这几条执行了三个月最直观的变化是上线前的回归缺陷少了大家改老代码时敢动了。单元测试真正的价值就在这里——它让你有底气去改而不是绕着老代码走。我在实际带团队的过程中最大的体会是别一上来就追求覆盖率数字先把一个核心类的测试写扎实让团队尝到改代码不慌的甜头剩下的推广自然就顺了。工具和框架都是现成的难的是把测试当成一份要认真写的代码而不是交差用的装饰品。
返回列表