☰
Java单元测试实战:框架选型、分层策略与最佳实践
2026/10/20 17:50:02 网站建设 项目流程

"单元测试"这四个字,简历上人人会写,面试里个个能聊,可真到项目里,能把测试写得又快又稳、既覆盖核心逻辑又不给维护加负担的人,真不多。这篇文章是我这些年做Java项目,从"会调JUnit跑出绿条"到"能把测试当成系统设计的一部分"积累下来的经验总结,围绕单元测试展开——先讲清楚它到底是什么、边界在哪,接着横向对比Java生态里的主流工具,再按照Java项目最常见的几种类型,也就是纯业务类、服务层、数据访问层、Web层、异步任务,逐一给出对应的测试思路和完整代码,最后把最佳实践和排查技巧整理成一份能直接"抄作业"的清单。无论你是刚接触单元测试的初级开发者,还是琢磨着优化团队测试体系的资深工程师,这篇文章里都有可以直接落地到项目里的内容。

1. 单元测试的本质:先把你手里的"单元"想清楚

1.1 "单元"的粒度:方法、类,还是业务规则?

我见过不少团队,写单元测试的粒度全凭感觉。有人一个Controller测试从HTTP入口一路打到数据库,中途连Redis、消息队列一起带上;有人把工具类的每个私有方法都翻出来测一遍。这两种做法,其实都跑偏了。

"单元"到底指什么?它不完全等于语法层面的一个方法,而是一段可以被独立验证、不依赖外部环境的逻辑。同样是加法计算器,add(int a, int b) 是个单元;同样是用户注册服务,register(User user) 内部直接调用了 userRepository.save(),那么测试时就必须先把 userRepository 替换成 Mock,Mock 之后,register 方法本身才成为被测单元。

换句话说,单元的粒度取决于一段代码的输入和输出是否可控。如果方法内部不碰数据库、不调远程接口、不读系统时间,那它天然就是单元;如果它依赖了外部资源,测试时要把这些依赖隔离掉,隔离后剩下的核心逻辑才是被测单元。这个理解直接决定测试代码怎么写:把"每个方法都测"当目标,测试会又碎又没价值;把"每条业务规则都测到位"当目标,测试才可能在重构时真正保护你。

1.2 单元测试和集成测试的边界:一个类比

打个比方:单元测试是"单缸点火测试",集成测试是"整车路试"。单元测试只关心发动机这一个零件在给定输入下输出对不对,速度快、结果确定;集成测试关心的是发动机、变速箱、电控系统装到一起之后,整车能不能正常跑。两者缺一不可,但把发动机测试当成整车测试来写,或者反过来,都会出问题。

Java项目里最常见的反面教材,是遇到Spring Boot就一切以 @SpringBootTest 开局。一个只有两个接口的小模块,测试类里也把整个应用上下文拉起来,数据库、中间件、外部服务全连上,结果就是测试跑一次动辄几十秒,CI动不动因为环境问题飘红。

从定位来看,单元测试的正确位置在测试金字塔的底层。它应该能在毫秒级或者秒级内跑完,不依赖任何外部服务,结果完全确定。判断标准就这么简单:如果你的某个测试类每次跑结果都不一样,那它大概率不是单元测试,而是披着单元测试外衣的集成测试,该重新分层的不是你,是你的测试设计。

1.3 好单元测试的五个特征:FIRST原则

判断一个单元测试写得好不好,我在评审测试代码时基本都用 FIRST 五个字母去对照。

  • Fast(快速):单个测试应该在毫秒级完成,整个单元测试层在本地几秒内跑完才对。如果一个测试类里出现了几秒甚至几十秒的等待,先怀疑它是不是把不该加载的东西加载了。
  • Independent(独立):测试之间互不影响,任何一个测试方法都能单独运行,执行顺序和结果无关。这块最容易翻车的是共享的静态状态,后面"常见问题"里我会专门讲。
  • Repeatable(可重复):同一份代码、同一个环境,跑一百次结果都一样。随机数据、系统时间、网络状态都是破坏可重复性的元凶,测试里出现这些,说明被测代码没隔离干净。
  • Self-validating(自我验证):测试必须有明确的断言,能自动判定通过或失败,而不是靠人去看日志判断。一个没有断言、只打印几个输出的"测试",不是测试,是演示代码。
  • Timely(及时):测试应该和生产代码一起写,最好是在写生产代码之前写。等上线之后再补测试,往往就补不回来了——因为逻辑已经复杂到没人敢改了。

这五条里,重复性是我最看重的。我见过一个测试,依赖当天日期生成报告文件名,恰好在月末跑会跨月导致失败,修的时候得靠改系统时间复现,排查成本极高。所以后来团队定了个规矩:测试里一律不直接用 LocalDateTime.now(),要么把时间作为参数传入,要么用一个可替换的 Clock 接口。就这一个改动,测试的稳定性提升非常明显。

2. Java单元测试主流工具横向对比

2.1 测试框架层:JUnit 5与TestNG的取舍

JUnit 5 是目前 Java 生态事实上的标准,没有太大悬念。它的架构拆成了三块:JUnit Platform 负责测试的启动和发现,JUnit Jupiter 提供新的编程模型(注解、断言、参数化测试这些),JUnit Vintage 则用来兼容老项目里的 JUnit 4 测试。Spring Boot 从 2.2 开始就在 spring-boot-starter-test 里默认带上 JUnit 5,新项目基本不用额外配置,加依赖跑就行。

JUnit 5 最常用的几个特性:@ParameterizedTest 做数据驱动测试,@Nested 组织测试层级,@DisplayName 给测试方法起中文名,@Timeout 防止测试意外卡死。这些特性在后面的示例里都会实际用上。我现在写测试,已经很少用 @Test 一个个平铺了,能用参数化的尽量参数化,能分层的尽量用 @Nested 分组,可读性提升不是一点半点。

TestNG 当年在 JUnit 4 当道的时代火过一阵。它的优势集中在数据驱动(@DataProvider)、分组执行、并行测试这些能力,当年确实比 JUnit 4 原生强不少。但 JUnit 5 推出之后,参数化测试、并行执行、条件测试都已经补齐,TestNG 在 Spring 生态里的集成度又远不如 JUnit,所以新项目我默认选 JUnit 5。TestNG 目前只在维护老项目时才会碰,新写的代码不应该再引入它。

2.2 Mock工具:Mockito为什么能一家独大

单元测试要隔离外部依赖,Mock 工具是刚需。Mockito 能成为事实标准,核心原因是它的语法足够自然。用 when(xxx).thenReturn(yyy) 声明行为,用 verify(xxx) 验证调用,读起来几乎就是一组口语化的规则说明,不像某些框架那么"别扭"。

Mockito 里最常用的三个注解是 @Mock、@InjectMocks 和 @Spy。@Mock 创建一个假的依赖对象;@InjectMocks 按类型把 Mock 对象注入到被测对象里;@Spy 则是对真实对象做部分 Mock——保留真实方法的行为,只替换指定的方法。配套用 @ExtendWith(MockitoExtension.class)(JUnit 5)开启注解功能。这里有个细节:@InjectMocks 的注入策略优先选构造器注入,所以被测类最好统一用构造器注入依赖,否则 @InjectMocks 匹配不上时会静默留 null,测试一跑就 NPE,而且报错位置离根因很远。

早期要 Mock 静态方法,只能上 PowerMock。PowerMock 通过修改字节码实现 mock,缺点是测试启动慢,还经常和 JaCoCo 这类覆盖率工具打架。Mockito 新版本已经原生支持 mockStatic(),配合 try-with-resources 使用,写起来干净利落,PowerMock 基本可以退场了。唯一的场景是接手遗留的老项目,那里面的静态方法依赖还是得靠 PowerMock 兜底。

2.3 断言工具:AssertJ让断言像写句子

JUnit 自带的 assertEquals、assertTrue 能用,但有个痛点:断言失败时给的信息不够直观,复杂对象的断言写起来很啰嗦。比如比较两个列表是否相等,JUnit 的报错信息只是两个列表的 toString,想看看到底差哪个元素还得自己再打印一遍。

AssertJ 用链式调用解决了这件事。对比一下:

// JUnit 原生 assertEquals(expectedList, actualList); // AssertJ assertThat(actualList) .containsExactly("a", "b") .allSatisfy(item -> assertThat(item).isNotBlank());

AssertJ 失败时不仅告诉你哪个元素不匹配,还能直接定位到具体元素,省去大量排查时间。对于集合、Optional、异常、时间日期这些场景,AssertJ 的体验确实比原生断言好很多。它随 Spring Boot 的 starter 默认集成,所以 Spring 项目里几乎不需要额外引入,用就完事了。

2.4 Spring生态的测试模块:按层次裁剪上下文

Spring Boot 提供的测试能力很容易被用歪,最典型的就是所有测试一律 @SpringBootTest。其实 Spring 官方按层把测试模块拆开了,每个注解的定位都不同:

  • @SpringBootTest:加载完整应用上下文,适合端到端或者跨模块集成级别的验证。它最重、最慢,必须谨慎使用。
  • @WebMvcTest:只装配 Web 层(Controller、@ControllerAdvice、过滤器等),Service 和 Repository 用 Mock 替代。它验证的是 HTTP 入口的请求映射、参数校验、响应结构,启动速度快很多。
  • @DataJpaTest:只装配数据访问层,默认用内嵌数据库并开启事务回滚,用来验证 Repository 的查询逻辑。
  • @JsonTest:专门测试 JSON 序列化和反序列化。

很多人问"为什么我的 @WebMvcTest 还是加载了一堆 Bean",这个问题的根源是 Spring 会自动装配 Web 层依赖的组件。想精确控制,用 @WebMvcTest(controllers = UserController.class) 指定只测哪个 Controller,再用 @MockBean 替换它依赖的 Service,上下文裁剪得越干净,测试就越快。

2.5 工具对比速查表

分类工具核心定位选择建议
测试框架JUnit 5标准测试框架,支持参数化、嵌套、并行新项目默认
测试框架TestNG数据驱动、分组、并行能力强维护老项目时用
Mock工具Mockito行为Mock,语法自然Java项目首选
Mock工具EasyMock录制-回放-验证模式老项目存量使用
Mock工具PowerMock可Mock静态方法、私有方法、构造器新代码不推荐,遗留代码兜底
断言AssertJ链式断言,错误信息友好推荐与JUnit 5搭配
断言Hamcrest基于匹配器的断言可用可不用
Spring模块spring-boot-starter-test测试全家桶任何Spring Boot项目都要引入

3. Java项目的常见类型与测试切入点

3.1 纯业务逻辑与领域对象:成本最低、价值最高

这类代码是单元测试的"最佳主场"。电商里的价格计算、优惠券满减规则、订单状态流转、字符串解析、日期计算,全部不依赖外部资源,输入确定、输出确定,测试代码也最好写。

很多团队有个误区:这么简单的逻辑还测什么?恰恰相反,越是核心的业务规则,越应该被测试锁死。我自己遇到过不止一次,重构一个看似简单的价格计算方法,因为没有测试兜底,把折扣精度问题改出来了。等线上用户下单才发现,那个代价就不是几个测试类能比的了。从投入产出比看,单测成本最低、收益最高的就是这一层。

3.2 工具类与静态方法:别让它们裸奔

工具类没有实例状态,方法大多静态,很多人觉得"静态方法不好测"就干脆不测。JUnit 4 时代确实麻烦,得靠 PowerMock,但 JUnit 5 加上新版 Mockito 的 mockStatic() 已经把这个问题解决得比较体面。

值得多说一句:工具类测试的第一步不是写测试,而是审视这个类是否"设计得可测"。如果一个工具类内部读了 System.currentTimeMillis() 或者调了全局配置,那要么把时间作为参数传进来,要么把这部分依赖隔离出去。可测试性差往往是设计问题的信号,而不是测试工具不够强。把时间、随机数、环境配置这类不稳定的东西从纯函数里剥离出去,不仅是为了测试,更是为了代码本身的健壮性。

3.3 数据访问层:用@DataJpaTest配内嵌数据库

Repository 测试属于"轻集成"测试,推荐用 @DataJpaTest。它只装配数据访问层,自动扫描 @Entity 和 Spring Data Repository,默认用配置好的内嵌数据库(H2 最常见),并且每个测试方法跑在事务里、结束后自动回滚,测试之间不会互相污染。从启动速度到隔离性,这个注解都为数据层测试设计好了。

但有一个点要特别提醒:@DataJpaTest 默认不会加载 MyBatis 或者 Flyway 这类自定义配置。如果你的项目数据访问层用了 MyBatis,或者查询逻辑依赖特定的数据库方言,那要么定制 @DataJpaTest 的配置,要么直接上 Testcontainers 起一个真实的数据库实例。后者慢一些,但更接近生产环境。尤其是涉及 JSON 字段、全文索引这类数据库特有能力的查询,内嵌数据库测过并不代表生产环境没问题。

3.4 Service服务层:Mock掉一切外部协作者

Service 是 Java 项目里业务逻辑最集中的地方,也是单元测试最重要的一层。测试 Service 时,它的依赖——Repository、外部 HTTP 客户端、MQ 生产者、配置中心客户端等等——一律用 Mock 替代。被测的核心是这么几件事:输入参数校验、分支逻辑、异常抛出、对依赖的调用顺序和调用次数。

一个典型的 Service 测试应该有这些断言点:参数错误时抛什么异常;依赖返回某种结果时,业务走向哪个分支;异常发生时是否被捕获并包装转发;在什么条件下外部依赖被调用了几次。Mockito 的 verify 在这些场景里非常好用。我后来看一个 Service 测试有没有写到位,就看它对 verify 用得密不密——只 stub 不 verify,等于只测了"输入输出",没测"协作关系"。

3.5 Web控制器层:MockMvc验证HTTP契约

Controller 层测试用 @WebMvcTest 加 MockMvc。MockMvc 可以模拟 HTTP 请求,验证路径映射、请求参数校验、响应状态码、响应体结构、异常处理行为,而不用真的启动服务监听端口,这是它最大的价值。

不少团队会把 Controller 测试写成"什么都测",结果 Controller 里其实没有业务逻辑,测试只是在复述一遍 Spring 的绑定规则,价值极低。更合理的做法是:用 @WebMvcTest 验证 HTTP 契约——URL 对不对、参数校验触发不触发、异常 handler 返回什么结构;真正的业务逻辑已经在 Service 层测过了,不该在这里重复。换句话说,Controller 层测试看的是"门面规不规范",不是"门面背后干了什么"。

3.6 异步任务、定时任务与并发场景:控制时间与并发

异步方法、定时任务是最容易被测试坑到的对象。Thread.sleep 等待异步结果,是典型的坏味道——它让测试变慢、还随机失败。机器负载高的时候,1 秒不够;机器跑得快的时候,白等 1 秒没必要。正确做法是用 Awaitility 按条件等待,我一般配合 @Async 方法测试用,代码大概长这样:

await().atMost(Duration.ofSeconds(5)) .pollInterval(Duration.ofMillis(100)) .untilAsserted(() -> assertThat(resultList).hasSize(3));

Awaitility 会在条件满足时立刻返回,超时才失败,测试既快又稳定。定时任务测试的另一个关键点是把时间控制住。Spring 的 @Scheduled 在单测里不会自动触发,测试时要么直接调用任务方法本身(推荐),要么用 Clock 接口把时间注入到逻辑代码里,测试时给一个固定时钟。后者的好处是,像"每月账单日执行"这种和时间强相关的规则也能测出边界情况。

4. 完整代码示例:五个真实场景从零到一

为了让这部分能直接照着用,先统一说明项目假设:JDK 17 + Maven + Spring Boot 3.x,用 JUnit 5、Mockito、AssertJ。Spring Boot 项目里的 spring-boot-starter-test 已经包含 JUnit 5、Mockito、AssertJ、Hamcrest、JSONAssert,所以 Spring 项目不需要额外加依赖。非 Spring 的纯 Java 项目,加下面这两个就够了:

<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.25.3</version> <scope>test</scope> </dependency> </dependencies>

4.1 场景一:纯业务对象——订单价格计算器

先写一个支持商品列表合计、VIP 打九折的价格计算器。纯业务逻辑,不依赖任何外部资源,这是最理想的单元测试对象。

import java.math.BigDecimal; import java.math.RoundingMode; import java.util.List; public class OrderPriceCalculator { private static final BigDecimal VIP_DISCOUNT_RATE = new BigDecimal("0.9"); public BigDecimal calculateTotal(List<OrderItem> items, boolean vip) { if (items == null || items.isEmpty()) { return BigDecimal.ZERO; } BigDecimal total = BigDecimal.ZERO; for (OrderItem item : items) { BigDecimal lineTotal = item.getPrice().multiply( BigDecimal.valueOf(item.getQuantity()) ); total = total.add(lineTotal); } if (vip) { total = total.multiply(VIP_DISCOUNT_RATE); } return total.setScale(2, RoundingMode.HALF_UP); } }

OrderItem 是一个包含 price 和 quantity 两个字段的简单对象,用 record 实现就行。这里有两个关键设计:金额计算全用 BigDecimal,避免 double 带来的浮点误差;最后统一用 HALF_UP 保留两位小数。这种细节一旦出错,线上就是实打实的资损,必须用测试锁死。

import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import java.math.BigDecimal; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class OrderPriceCalculatorTest { private final OrderPriceCalculator calculator = new OrderPriceCalculator(); @Test @DisplayName("空列表返回0") void shouldReturnZeroWhenItemsEmpty() { assertThat(calculator.calculateTotal(List.of(), false)) .isEqualByComparingTo(BigDecimal.ZERO); } @Test @DisplayName("普通用户按原价合计") void shouldReturnSumWhenNotVip() { OrderItem a = new OrderItem(new BigDecimal("10.00"), 2); OrderItem b = new OrderItem(new BigDecimal("5.50"), 3); assertThat(calculator.calculateTotal(List.of(a, b), false)) .isEqualByComparingTo(new BigDecimal("36.50")); } @ParameterizedTest @CsvSource({ "100.00, 1, true, 90.00", "99.99, 2, true, 179.98", "0.10, 3, false, 0.30" }) @DisplayName("VIP折扣与数量计算的组合场景") void shouldCalculateVipOrder(String price, int quantity, boolean vip, String expected) { OrderItem item = new OrderItem(new BigDecimal(price), quantity); BigDecimal actual = calculator.calculateTotal(List.of(item), vip); assertThat(actual).isEqualByComparingTo(new BigDecimal(expected)); } }

这里必须强调一个细节:比较 BigDecimal 用的是 isEqualByComparingTo,不是 isEqualTo。因为 BigDecimal 的 equals 方法会连精度一起比较,1.0 和 1.00 在 equals 看来不相等,但数值上应该相等。这个坑我踩过不止一次,后来直接写进团队测试规范里了。

4.2 场景二:Mock外部依赖——用户注册服务

来看一个更典型的 Service 层例子。用户注册服务依赖 UserRepository 和 EmailSender,测试需要把这两个依赖都 Mock 掉,只验证注册逻辑本身。

public class UserService { private final UserRepository userRepository; private final EmailSender emailSender; public UserService(UserRepository userRepository, EmailSender emailSender) { this.userRepository = userRepository; this.emailSender = emailSender; } public User register(String username, String email) { if (userRepository.existsByUsername(username)) { throw new DuplicateUsernameException(username); } User user = userRepository.save(new User(username, email)); emailSender.sendWelcomeEmail(user); return user; } }

注意构造器注入,这既让 @InjectMocks 的注入行为可预测,也让依赖关系一目了然。一个经验之谈:Service 的构造器里依赖多到排不下的时候,先别急着加测试,先想想这个类是不是职责太多了——可测性和设计质量往往是同步变化的。

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private EmailSender emailSender; @InjectMocks private UserService userService; @Test void shouldRegisterSuccessfullyWhenUsernameNotExists() { when(userRepository.existsByUsername("alice")).thenReturn(false); when(userRepository.save(any(User.class))) .thenAnswer(invocation -> invocation.getArgument(0)); User result = userService.register("alice", "alice@example.com"); assertThat(result.getUsername()).isEqualTo("alice"); verify(userRepository).save(any(User.class)); verify(emailSender).sendWelcomeEmail(result); } @Test void shouldThrowWhenUsernameExists() { when(userRepository.existsByUsername("alice")).thenReturn(true); assertThatThrownBy(() -> userService.register("alice", "alice@example.com")) .isInstanceOf(DuplicateUsernameException.class); verify(userRepository, never()).save(any()); verify(emailSender, never()).sendWelcomeEmail(any()); } @Test void shouldStillRegisterWhenEmailSendingFails() { when(userRepository.existsByUsername("bob")).thenReturn(false); when(userRepository.save(any(User.class))) .thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new EmailSendException("smtp down")) .when(emailSender).sendWelcomeEmail(any()); User result = userService.register("bob", "bob@example.com"); assertThat(result.getUsername()).isEqualTo("bob"); verify(emailSender).sendWelcomeEmail(any()); } }

三个测试点分别覆盖了正常注册、用户名重复、邮件发送失败但注册仍然成功。这个第三个场景特别值得写进测试里,因为它是常见业务定义——注册成功是主路径,欢迎邮件失败不能影响注册。如果这类规则没有测试锁死,某天有人觉得"邮件失败应该返回失败",改一行代码就可能把整个注册流程搞挂。

两个心得:第一,用 thenAnswer 返回传入的实参,模拟"数据库持久化后的对象"非常方便,比笨拙地在测试里 new 一个对象再返回要自然得多;第二,verify 的断言要验证到"该发邮件的时候发、不该发的时候绝不多发",这是 Service 层测试最容易漏掉的点。

4.3 场景三:Spring Data JPA Repository测试

数据访问层测试用 @DataJpaTest,配内嵌数据库,每个测试自动回滚。这里假设 UserRepository 里定义了一个 findByUsername 方法。

import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void shouldFindUserByUsername() { userRepository.save(new User("bob", "bob@example.com")); Optional<User> found = userRepository.findByUsername("bob"); assertThat(found).isPresent(); assertThat(found.get().getEmail()).isEqualTo("bob@example.com"); } }

用 @DataJpaTest 时有个很实用的默认行为:它在事务里执行测试,结束后自动回滚,所以测试里 save 的数据不会泄漏到下一个用例。这保证了测试之间的独立性,也让数据准备变得很轻——不用写一堆清理逻辑。

但要特别提个醒:@DataJpaTest 默认用的是 H2 内嵌数据库,方言是 H2。如果生产环境是 MySQL,而查询里有 MySQL 特有函数或者 JSON 操作,单测里跑得过去、生产上可能翻车。我在一个项目里遇到过这种情况:本地 H2 里 LIKE 查询正常,生产 MySQL 上因为字符集设置不同,中文匹配直接失效。如果是这种对数据库方言敏感的逻辑,建议用 Testcontainers 真正启动一个 MySQL 容器来测,慢一点但真实。数据访问层恰恰是最值得花这个成本的。

4.4 场景四:Controller层——MockMvc验证HTTP契约

Controller 层测试的要点是"不启动全量上下文"。先用 @WebMvcTest 只加载 Web 层,Service 用 @MockBean 替换。

生产代码先看一个最简单的 Controller:

@RestController @RequestMapping("/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping public User register(@RequestBody RegisterRequest request) { return userService.register(request.getUsername(), request.getEmail()); } }

RegisterRequest 是一个包含 username、email 的普通请求体类,测试直接用 JSON 字符串模拟。

import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void shouldReturnCreatedUserWhenRequestValid() throws Exception { when(userService.register("alice", "alice@example.com")) .thenReturn(new User(1L, "alice", "alice@example.com")); String requestBody = """ { "username": "alice", "email": "alice@example.com" } """; mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("alice")) .andExpect(jsonPath("$.email").value("alice@example.com")); } }

MockMvc 的 jsonPath 是验证 JSON 响应结构的神器。另一个常见的需求是验证参数校验失败时的状态码。比如 username 为空时,期望返回 400 并带错误信息。这需要 Controller 方法上加 @Valid 注解、请求体字段上加校验注解,然后在测试里断言返回结构中的具体字段。这类测试本质上是在定义"接口对外承诺什么",不仅是给自己看,也是给前端、给文档、给后续的维护者看的。

4.5 场景五:参数化测试与静态方法Mock

最后一个场景处理工具类。假设一个通知消息构建器,根据事件类型生成不同文案:

public class NotificationMessageBuilder { public static String buildMessage(String eventType, String name) { if ("ORDER_PAID".equals(eventType)) { return "【订单已支付】感谢您的购买," + name; } if ("ORDER_SHIPPED".equals(eventType)) { return "【订单已发货】" + name + ",您的商品已发出"; } return "【通知】" + name; } }

这种纯静态方法不需要 Mock,参数化测试就够了:

import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import static org.assertj.core.api.Assertions.assertThat; class NotificationMessageBuilderTest { @ParameterizedTest @CsvSource({ "ORDER_PAID, 张三, 【订单已支付】感谢您的购买,张三", "ORDER_SHIPPED, 李四, 【订单已发货】李四,您的商品已发出", "UNKNOWN, 王五, 【通知】王五" }) @DisplayName("不同类型事件生成对应文案") void shouldBuildMessageByEventType(String eventType, String name, String expected) { assertThat(NotificationMessageBuilder.buildMessage(eventType, name)) .isEqualTo(expected); } }

如果工具类内部依赖了系统的随机数、时间或者配置服务,静态 Mock 就要出场了。新版 Mockito 的写法是:

try (MockedStatic<IdGenerator> mocked = mockStatic(IdGenerator.class)) { mocked.when(IdGenerator::nextId).thenReturn("fixed-id-001"); String id = SomeService.generateId(); assertThat(id).isEqualTo("fixed-id-001"); }

mockStatic 必须放在 try-with-resources 里,出了作用域会自动释放,避免污染同进程里的其他测试。这是静态 Mock 最容易踩的坑——忘了关闭,后面所有测试拿到的都是同一个固定 ID,排查起来非常迷惑。

4.6 场景测试策略总结表

项目类型被测对象方向隔离方式关键断言示例推荐测试方式
纯业务/工具类计算方法、生成规则无数值相等、文案相等JUnit 5 + 参数化测试
Service层业务编排、异常分支Mock依赖调用次数、异常类型Mockito + AssertJ
数据访问层SQL映射、查询结果H2/内嵌库结果存在、字段正确@DataJpaTest
Web层HTTP契约、参数校验Mock Service状态码、JSON结构@WebMvcTest + MockMvc
静态方法生成器、工具方法mockStatic返回值、调用交互JUnit 5 + mockStatic

5. 常见问题与排查技巧实录

5.1 Mock没有生效:先检查注入路线

测试里经常遇到"方法明明 Mock 了,但测试还是走了真实逻辑"的情况。最常见的原因是 @InjectMocks 没有把 Mock 注入进去。@InjectMocks 优先选择构造器注入,如果被测类没用构造器注入依赖,而是字段注入,Mockito 默认按类型匹配,匹配不上就会静默留 null,测试一跑就 NPE,而且报错位置看起来和注入风马牛不相及。

排查手段其实很简单:在测试里加一条断言 assertThat(userRepository).isNotNull(),一眼就能看出 Mock 注入成没成功。更优雅的做法是生产代码统一用构造器注入,让 @InjectMocks 的行为可预测。我还见过一种情况:被测类里同一个接口有两个实现字段,类型一样、名字不同,@InjectMocks 按类型注入就分不清了,这时候得用 @Mock(name = "xxx") 显式指定字段名。这类问题通常在项目里出现一次之后,大家就自发约定"能构造器注入就构造器注入"了。

5.2 thenReturn与doThrow的选择

Mockito 里对 void 方法模拟异常,必须用 doThrow,不能用 when:

doThrow(new EmailSendException("smtp down")) .when(emailSender).sendWelcomeEmail(any());

这是因为 when(emailSender.sendWelcomeEmail(any())) 这种写法,在"stub 时"其实已经调用了 void 方法本身。void 方法没有返回值,调用它没有实际意义,但语义上很怪,还容易让人误以为方法真的被调用了。doThrow().when() 的写法保证了在 stub 阶段不会真正执行方法调用,语义也更明确。同理,对 void 方法做行为验证时 verify 照常使用,但打桩阶段务必用 doXxx 系列,这是团队测试规范里一个很实在的铁律。

5.3 测试间的数据与状态污染

最典型的表现是:单个测试全绿,全量测试时某个用例挂掉,单独跑又恢复,十有八九是共享状态没清理。静态变量、单例、Spring 上下文中的 Bean、系统属性、文件系统,都是污染源。

举一个我实际排查过的例子:某个工具类用静态 Map 缓存配置,测试 A 写入了缓存,测试 B 预期缓存为空,执行顺序一变 B 就失败。解决办法很简单,在 @BeforeEach 里重置缓存,或者把缓存改成实例字段。判断标准就一条:每个测试都应该是从"干净的起点"开始的。如果被测试类持有状态,测试类的 @BeforeEach 里就要负责把这些状态重置干净,不要指望执行顺序。

5.4 随机失败:别再用固定sleep等异步

异步测试里的 Thread.sleep(1000) 是团队里测试飘红的头号元凶。机器负载高的时候 1 秒不够;机器跑得快的时候白等 1 秒没必要。这个问题我在前面 3.6 节里给了 Awaitility 的解法,但真正值得说的是背后的思考方式:与其用固定时间等一个不确定的结果,不如把"条件成立"作为测试的结束信号。这也是可重复性原则在实践层面的落地——用时间做判断,结果就依赖了机器的负载和调度,可重复性当然无从谈起。

我们团队后来立了一条铁律:测试代码里一旦出现 Thread.sleep,代码评审必须打回。这条规则执行了三个迭代之后,CI 的随机失败数量肉眼可见地掉下来了。

5.5 中文乱码和JSON编码的坑

MockMvc 测试中文接口时,经常遇到响应乱码或者 jsonPath 断言匹配不上。常见原因是请求时没有显式设置字符集,或者响应 Content-Type 里没带 charset=UTF-8。Spring 框架内部的字符集处理在不同版本间有差异,最稳妥的写法是:

contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8))

比直接写 MediaType.APPLICATION_JSON 要多打几个字,但这几个字能省掉大量的排查时间。这条经验帮我们团队排查了不止一次 CI 上因为中文导致的诡异失败,后来也直接进了团队测试规范。

5.6 Spring上下文加载慢:裁剪、裁剪、再裁剪

项目里测试从几十秒变成几分钟,几乎都是 @SpringBootTest 滥用引起的。每一次 @SpringBootTest 都会初始化一次完整上下文,如果被测模块根本不需要数据库、MQ、Redis,这个成本就是白付的。

排查思路很简单:按被测层次去替换。Web 层用 @WebMvcTest,数据访问层用 @DataJpaTest,只有跨模块集成的场景才用 @SpringBootTest。另一个技巧是 @SpringBootTest 里用 @MockBean 把重量级依赖替换掉,能把上下文启动从几十秒压到几秒。这里的关键不是某个注解本身,而是要建立一种意识:测试加载的东西越少,跑得越快,越不容易受环境干扰。每次看到 @SpringBootTest,先问一句"真的需要全量上下文吗",这句话值得贴在工位上。

6. 最佳实践:让单元测试真正成为团队的资产

6.1 命名规范:测试类名与方法名

测试类命名用"被测类名 + Test",这是团队协作成本最低的约定,没有任何歧义。方法名我推荐两种风格:阅读型长命名,比如 should_return_vip_price_when_user_is_vip;或者用 @DisplayName 写中文描述,比如 @DisplayName("空列表返回0")。两种都可以,但不要混合使用,否则团队里一半英文一半中文,找测试代码时很割裂。

我个人更倾向用 @DisplayName 写中文,因为测试本质上是在描述业务规则,中文可以直接对照产品需求。一个新同学接手测试代码时,不需要再翻译一层就能理解"这条测试在保护什么",这个理解成本在整个团队里的收益非常可观。

6.2 Arrange、Act、Assert的代码结构

每个测试方法内部,按三阶段用空行分隔,这是最容易执行也最能提升可读性的约定。Arrange 阶段准备数据和 Mock 行为,Act 阶段执行被测方法,Assert 阶段做断言。三个阶段一目了然,评审的人扫一眼就能抓住测试意图。

超过三个断言的时候,停下来想一想:是不是一个测试方法里塞了多个行为?如果是,拆开反而更好维护。JUnit 5 的 @Nested 也值得多用,它可以把同类场景组织成内层类,比如"用户注册成功场景""用户名重复场景",比平铺的几十个方法直观得多。测试代码的可读性和生产代码同等重要,因为它也是要长期维护的资产,而不是一次性的脚本。

6.3 覆盖率:别追100%,要盯业务规则

很多人关注行覆盖率,我更关注"每个业务分支是否都有一条对应的测试路径"。80% 的行覆盖率,如果核心的价格计算分支一条没测,一样存在线上市事故;反过来,工具类的每一行都测满,业务核心逻辑没覆盖,覆盖率数字再漂亮也没意义。

团队里更好的抓手是:把关键业务规则列成清单,每条规则对应一条测试用例,评审时逐条对照。覆盖率工具(比如 JaCoCo)的定位是发现"有没有完全没测到的类",而不是用来追指标数字。我以前见过一个团队为了把覆盖率从 90% 推到 95%,写了大量纯 setter/getter 的测试,最后除了让覆盖率数字好看,对产品质量没有任何帮助,还白白增加了维护成本。

6.4 TDD要不要推:分场景

我自己的态度是:不搞教条式的全量 TDD,但两种场景必须"先写测试再写代码"。

第一种是修复 Bug 时,先写一个暴露 Bug 的失败测试,再改生产代码让测试通过。这样能确认 Bug 真的被修好了,而不是"我以为修好了"。第二种是新增核心业务规则时,先写测试定义规则,再实现逻辑。我在经历过几次"改完代码以为修好了第二天又复现"之后,再也不敢不写回归测试直接改了。

TDD 最大的好处不是"测试先行"这个仪式,而是它倒逼你在动手前先把规则想清楚。写测试的过程就是在回答"这段代码到底应该做什么"的问题。答案清楚,代码自然写不歪。

6.5 测试代码也是要评审和重构的代码

测试代码写久了同样会腐化。Mock 滥用、断言缺失、测试数据语义不清晰,都会让测试慢慢变成"改了不敢跑、跑了不敢信"的负担。

我们在代码评审里加了两个硬性条目:第一,任何功能变更必须带测试变更,新增逻辑没有测试提测不允许合入;第二,测试代码必须遵守和业务代码一样的命名规范与结构约定。刚开始执行的时候,团队确实会花点时间,但坚持两个迭代之后,这套约束带来的回报非常直观——回归的信心、重构的底气,全都建立在这些看起来不起眼的测试代码上。

最后分享一点个人体会。单元测试这件看起来"低技术含量"的事,真正拉开差距的其实不是工具用得多花哨,而是你是否想清楚"这段代码到底要保护什么规则"。把每个测试当作系统的一份可执行文档来写,它就会在日后的重构、排查、交接里持续给你回报。希望这篇文章里从理解框架到工具选型、从分层策略到具体示例的内容,能帮你少走一些我当年走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询