拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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: <200>
AssertJ 链式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把参数抓出来再断言。

ArgumentCaptor<Order> 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指向一个返回Stream<Arguments>的 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> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> <configuration> <argLine>@{argLine} -Dfile.encoding=UTF-8</argLine> </configuration> </plugin> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.12</version> <executions> <execution> <goals><goal>prepare-agent</goal></goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals><goal>report</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 配套的团队约定

最后落地了几条约定,比任何工具都管用:

  1. 新提交的生产代码必须带对应测试,差量覆盖率不达标 PR 直接打回;
  2. 禁止Thread.sleep,异步一律用轮询;
  3. 单个测试方法超过 40 行要拆,mock 超过 5 个要评审设计;
  4. 测试命名统一规范,失败信息要能直接告诉人"哪里错了"。

这几条执行了三个月,最直观的变化是:上线前的回归缺陷少了,大家改老代码时敢动了。单元测试真正的价值就在这里——它让你有底气去改,而不是绕着老代码走。

我在实际带团队的过程中最大的体会是,别一上来就追求覆盖率数字,先把一个核心类的测试写扎实,让团队尝到"改代码不慌"的甜头,剩下的推广自然就顺了。工具和框架都是现成的,难的是把测试当成一份要认真写的代码,而不是交差用的装饰品。

返回列表