ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JUnit与Mockito实战:Java单元测试入门到避坑指南

JUnit与Mockito实战:Java单元测试入门到避坑指南

1. 为什么要写单元测试:先说清楚JUnit和Mockito是什么

做Java开发的日子久了,我越来越觉得单元测试不是写给领导看的KPI,而是写给自己的一层保护。你有没有过这样的经历:改了一个自认为无伤大雅的方法,结果把另一个模块搞挂了;或者接手一份老代码,压根不敢动,因为一动就翻车。如果那时候有一套像样的测试在兜底,情况会好很多。

这篇博客要讲的JUnit和Mockito,是Java测试生态里最主流的一对搭档。JUnit负责提供测试框架——怎么组织测试方法、怎么断言结果、怎么管理生命周期;Mockito负责解决依赖问题——当你测的方法里调用了数据库、外部接口、消息队列这些东西时,用它造出"假对象",让被测代码只专注自身逻辑,不被打扰。两者结合起来,就能搞定大部分单元测试场景。

适合什么人看?如果你是刚接触测试的新人,这篇文章会带你从建工程开始搭起一套能跑的测试;如果你已经写了几个月测试但总觉得别扭,看完整篇后,那些"为什么mock不生效""为什么测试这么脆"的疑惑应该能解开大半。

我写这篇的时候,会尽量把代码都贴出来,并且解释每一步为什么这么写。因为我见过太多人把mock当成"随便new一个替代品",结果测试写得跟没写一样,根本不是一回事。

1.1 单元测试到底在测什么

很多人对"单元"这两个字有误解,以为"单元"越小越好,于是跑去测每一个getter/setter,测完还觉得覆盖率挺高。实际上,单元测试的核心价值是"快速反馈"和"定位问题"。它测的是你代码里的一段逻辑——通常是一个方法或一个类的行为——在给定输入下,输出是否符合预期。

这个"预期"很重要。写测试并不是把代码跑一遍就算完事,而是要先把业务规则翻译成断言。比如某个方法"用户积分满100升级为VIP",那么测试就要构造积分=100的输入,断言返回结果是VIP;积分=99,断言不是VIP。测试写得好不好,本质上是看你有没有把规则说清楚。

我见过最典型的坏味道是:测试里写了一堆System.out.println,跑完以后人肉去看输出对不对。这种测试跑一千遍,它也不会自动告诉你错在哪,只能靠眼睛盯,没有任何保护价值。真正有效的做法是断言写明确,让测试自己判断对错,这是JUnit这类框架存在的意义。

1.2 没有Mockito,单元测试根本写不下去

如果你只是测一个没有依赖的纯工具类,那写起来确实简单。但真实项目里,一个方法往往要查数据库、调外部接口、读写Redis、发消息通知。直接new出来测,结果就是测试跑一次要接库、要开网络、要等第三方服务,一个测试跑几十秒,还动不动因为环境问题失败。

这时候就需要Mockito出场了。它的思路很简单:把真实的依赖替换成一个"替身",替身不干真活,只按你编排好的剧本返回结果。被测方法认为自己调了真实依赖,实际上是在跟替身对话,这样就把它跟外部世界的耦合切断了。

我习惯把mock理解为拍戏时的替身演员。主角要去跳楼,真跳多危险,替身来;主角要开车狂飙,真开多贵,替身来。mock也一样,它替代的是那些"不方便、不安全、不稳定"的真实依赖,让被测代码在可控的环境下被演练。

2. JUnit 5基础:先把测试骨架搭对

2.1 环境搭建:Maven依赖就两行

JUnit发展到现在,主流版本是JUnit 5,也就是常说的Jupiter。它跟老牌JUnit 4最大的区别是模块化拆分得更细。你只需要在pom.xml里加依赖就能用:

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

注意两点。第一,junit-jupiter是一个聚合依赖,它会帮你把jupiter-api、jupiter-params、jupiter-engine都带进来,不需要再单独加;第二,scope必须是test,这样依赖只对测试代码可见,不会打进生产包。

如果用的是Spring Boot项目,建议顺手把mockito-junit-jupiter也加进来,这样Mockito能跟JUnit 5的扩展模型做集成,@Mock注解的自动初始化会更方便:

<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>5.12.0</version> <scope>test</scope> </dependency>

依赖加好后,写一个最简单的测试类试试水。

2.2 第一个测试类:从结构开始学写

package com.example.demo.service; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class CalculatorTest { @Test void testAdd() { Calculator calculator = new Calculator(); int result = calculator.add(2, 3); assertEquals(5, result); } }

这段代码里有几个约定俗成的规矩,我强烈建议你照做。第一,测试类名用被测试类名加上Test后缀,比如CalculatorTest,这样在几千个文件里找对应测试,扫一眼就能定位;第二,测试方法名不要用testAdd这种泛泛的名字,最好直接描述行为,比如shouldReturnFiveWhenAddTwoAndThree,或者中文环境里直接用两个数相加返回和。方法名本身就是文档,跑测试挂掉的时候,一看方法名就知道哪条业务规则出了问题。

第三,也是最重要的:测试方法上标@Test,类本身不需要是public的,方法也不需要是public的。JUnit 5在这点上比JUnit 4宽松,包私有就能识别。但我个人习惯还是写包私有,因为省事,也符合最小可见性的原则。

2.3 生命周期方法:BeforeEach不是摆设

真实项目中,每个测试方法可能需要准备不同的数据,但也有一些公共的初始化操作——比如创建对象、启动临时目录、连接内存数据库。这些公共操作放在每个方法里会重复,放在@BeforeEach标记的方法里最合适。

class UserServiceTest { private UserService userService; @BeforeEach void setUp() { userService = new UserService(); } @AfterEach void tearDown() { // 做清理工作 } }

@BeforeEach方法在每一个@Test方法运行前都会执行一次,保证每个测试都是独立开场、互不干扰。这一点对于单元测试来说非常重要——测试之间一旦共享可变状态,就会出现"这个测试单独跑能过,一起跑就挂"的灵异现象。

与之对应的是@BeforeAll和@AfterAll,它们只执行一次,适合做那些开销大、且不会污染数据的事,比如启动一个测试用的容器或建立连接池。注意@BeforeAll方法要求是static的,除非你加了@TestInstance修饰。这个坑我见过不少人踩过,经常看到有人把@BeforeAll写成实例方法,然后启动直接报错,半天摸不着头脑。

2.4 断言:把规则说清楚

断言是单元测试的灵魂。JUnit 5的Assertions类提供了一组常用的方法:

  • assertEquals(expected, actual):判断两个值相等,这是用得最多的一个
  • assertTrue/assertFalse:判断条件真假
  • assertNull/assertNotNull:判断对象是否为空
  • assertThrows:判断是否抛出某个异常,这个非常实用
  • assertAll:把多个断言打包成一组,全部执行后统一报告失败项
@Test void shouldThrowWhenInputIsNull() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> userService.register(null) ); assertEquals("用户名不能为空", exception.getMessage()); }

这里我特别想强调assertThrows的价值。很多测试只测"正常路径",完全不管异常场景。等到线上真出了故障,才发现异常逻辑根本没被验证过。比如校验参数的方法,如果传null应该抛异常,那测试里就必须覆盖这一条,否则这个校验逻辑就是裸奔的——你根本不知道它会不会把null放过去。

3. Mockito核心用法:mock、打桩、验证三件套

3.1 先搞懂mock对象和真实对象的区别

Mockito的核心工作,就是让你用一个mock对象替代真实依赖。这个mock对象是"空壳"——它所有的方法都没有真实逻辑,默认返回类型的默认值(int回0,对象回null,集合回空集合)。你不对它做任何配置,直接调用它的方法,返回的都是默认值。

比如一个UserRepository接口,真实实现里是查数据库。你mock它之后,调用findById(1L)默认返回null。但你可以在测试里通过when(...).thenReturn(...)来编排它"该返回什么",这就是打桩(stub)。打桩之后,mock对象会按照你写好的剧本行动。

打个比方:真实对象是真服务员,会真的去后厨做菜;mock对象是排练时的替身,你告诉他"客人点宫保鸡丁,你就端那盘假菜",他就会照做。整个表演过程(就是单元测试)只关心主角演员的发挥,不需要真的把整个餐厅运转起来。

3.2 创建mock的三种姿势,选哪种是看场景的

第一种是直接在测试方法里用静态方法:

UserRepository repository = mock(UserRepository.class);

第二种是用@Mock注解加MockitoExtension:

@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository repository; }

第三种是用@RunWith(MockitoJUnitRunner.class),这是老代码里常见的写法,Mockito 4之后已经不用它跟JUnit 5搭配了。

我强烈推荐第二种。原因有两个:一是声明式写法让测试类的依赖一目了然,一眼就能看出这个测试要mock哪些东西;二是在JUnit 5的Extension机制下,Mockito能自动帮你完成初始化,不需要再写MockitoAnnotations.openMocks(this)这行样板代码。如果你还在用JUnit 4,那才需要@RunWith。

3.3 打桩的艺术:when和doReturn的区别

打桩最基础的写法是这样的:

when(repository.findById(1L)).thenReturn(user);

这句话的意思是:"当调用repository.findById并且参数是1L时,返回user对象。"之后被测代码调用这个mock对象时,就能拿到预设结果。这是最简单的场景。

但有两个情况需要注意。一种是方法返回void,比如想要让某个void方法抛异常,就得换一种写法:

doThrow(new RuntimeException("保存失败")) .when(repository) .save(any(User.class));

另一种是方法执行本身有副作用,或者返回值需要在调用时才计算,这时候用doAnswer:

doAnswer(invocation -> { Long id = invocation.getArgument(0); return new User(id, "测试用户"); }).when(repository).findById(anyLong());

doAnswer比较灵活,可以对调用参数做加工再返回结果,适合动态响应的场景。但日常开发中,绝大多数打桩用when...thenReturn就够了,别一上来就整花活,能让测试简单的地方就别绕弯。

还有一点必须提醒:打桩不是越多越好。很多新手把测试里所有的方法都打桩,连计算器加法都mock掉。这是一种误解。Mockito存在的意义是隔离外部依赖,而不是把被测类自身的逻辑也替换掉。打桩的范围应该是"被测方法直接依赖的外部组件",而不是"被测类内部的一切"。如果你发现自己把被测类里的私有方法行为都编排出来了,那说明设计可能有问题,该考虑重构而不是硬mock。

3.4 verify验证:光有返回还不够,还得确认交互

打桩解决的是"依赖返回什么",而verify解决的是"被测代码有没有正确地跟依赖交互"。这是Mockito另一个强大的能力。

verify(repository, times(1)).findById(1L);

这句话断言repository.findById(1L)恰好被调用了一次。如果被测代码压根没调用,或者调了两次,测试就会失败。类似地还能用never()验证一次都没调、atLeast(1)/atMost(3)验证最少/最多调用次数。

我见过最典型的场景是:业务代码里本应该调用消息推送通知用户,结果因为某种原因漏掉了。如果测试里只有返回值的验证,这个漏调根本发现不了。但加上verify,就能把"调用行为"也纳入测试的监控范围。

verify(notificationClient).sendWelcomeMessage(user.getId());

此外,ArgumentCaptor也是一种常用的验证手段。当你想确认传入依赖的参数值到底是什么的时候,用captor能把这个参数"捕获"出来细细检查:

ArgumentCaptor<Long> captor = ArgumentCaptor.forClass(Long.class); verify(repository).findById(captor.capture()); assertEquals(1L, captor.getValue());

它跟verify配合,可以验证的粒度更细——不仅调用过,而且是按预期的参数调用的。

4. 实战演练:一个完整的用户服务单元测试

4.1 场景设定与业务代码

前面讲的都是零散的知识点,这一节我把它们揉进一个完整的例子。场景是这样的:某系统里有一个用户注册服务UserService,它的核心逻辑是:

  1. 根据手机号查用户仓库,判断用户是否已存在;
  2. 如果存在,抛业务异常;
  3. 不存在则创建新用户,保存到仓库;
  4. 保存成功后调用消息通知客户端推送一条欢迎消息。

依赖有两个:UserRepository负责数据访问,NotificationClient负责推送通知。在真实的项目里,这两个都是跟外部系统打交道的组件——查数据库、调推送服务,如果测试时直接连真服务,速度慢不说,还会产生脏数据,甚至可能因为环境不通让测试彻底跑不起来。所以它们自然要用mock。

public class UserService { private final UserRepository userRepository; private final NotificationClient notificationClient; public UserService(UserRepository userRepository, NotificationClient notificationClient) { this.userRepository = userRepository; this.notificationClient = notificationClient; } public User register(String phone, String name) { User existing = userRepository.findByPhone(phone); if (existing != null) { throw new BusinessException("手机号已注册"); } User user = new User(phone, name); userRepository.save(user); notificationClient.sendWelcomeMessage(user.getId()); return user; } }

这里还要补充一个细节:UserService是通过构造函数接收依赖的,而不是直接new一个UserRepository。这么做的好处之一就是测试友好——测试代码可以轻而易举地把mock对象塞进来,不需要任何额外的框架。如果代码里到处是new或静态方法调用,那才是测试的噩梦。

4.2 测试用例设计:先列清单再动手

写测试最怕的就是想到哪写到哪。我的习惯是先把用例清单列出来,把各种场景分好类:

用例编号场景预期结果
TC-01手机号不存在,正常注册返回新建用户,保存一次,推送一次
TC-02手机号已存在抛业务异常,不保存,不推送
TC-03保存时仓库抛异常异常向上抛,不推送
TC-04手机号或姓名为空抛参数校验异常

这个清单本身就是对业务规则的梳理。列清单的时候,你就已经把"正确路径"和"异常路径"都考虑到了。很多测试覆盖不全,就是因为根本没有做过这一步,上来就写代码,写完才发现,忘了测已存在用户的情况。

4.3 完整测试代码:对照清单逐个落地

@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private NotificationClient notificationClient; private UserService userService; @BeforeEach void setUp() { userService = new UserService(userRepository, notificationClient); } @Test void shouldRegisterUserSuccessfullyWhenPhoneNotExists() { when(userRepository.findByPhone("13800000000")).thenReturn(null); User result = userService.register("13800000000", "张三"); assertNotNull(result); assertEquals("13800000000", result.getPhone()); assertEquals("张三", result.getName()); verify(userRepository, times(1)).save(result); verify(notificationClient, times(1)) .sendWelcomeMessage(result.getId()); } @Test void shouldThrowBusinessExceptionWhenPhoneAlreadyExists() { User existingUser = new User("13800000000", "李四"); when(userRepository.findByPhone("13800000000")).thenReturn(existingUser); BusinessException exception = assertThrows( BusinessException.class, () -> userService.register("13800000000", "张三") ); assertEquals("手机号已注册", exception.getMessage()); verify(userRepository, never()).save(any(User.class)); verify(notificationClient, never()).sendWelcomeMessage(anyLong()); } @Test void shouldNotSendNotificationWhenSaveFails() { when(userRepository.findByPhone("13800000000")).thenReturn(null); doThrow(new DataAccessException("数据库连接失败")) .when(userRepository) .save(any(User.class)); assertThrows(DataAccessException.class, () -> userService.register("13800000000", "张三")); verify(notificationClient, never()) .sendWelcomeMessage(anyLong()); } }

对照清单看这段代码,你会发现每个用例的验证点很清晰。第一个用例验证了成功路径的返回值和行为;第二个用例验证了异常路径下"不允许保存"和"不允许推送";第三个用例验证了保存失败时,推送绝对不会执行。这些验证点都是从业务规则反向推出来的。

写这种测试的时候,我的体会是:验证"不应该发生的事"和验证"应该发生的事"同样重要。比如TC-02里,如果代码在用户已存在时执行了save,那是一个严重的bug,但返回值可能依然是抛异常。只有verify(never())能捕捉到这种微妙的问题。

4.4 跑起来:观察测试结果与覆盖率

在IDE里直接点击运行测试,最理想的情况是两个测试都绿。如果其中一个红了,框出来的失败信息会直接告诉你哪条断言没通过。这是JUnit 5最直观的反馈机制。

跑测试的时候顺手可以看一眼覆盖率。IDEA自带的覆盖率工具或者JaCoCo都行。但我想说,覆盖率并不是越高越好,千万不要为了凑数字去测一堆getter/setter。真正的覆盖率应该体现在"业务规则覆盖"上——你列的用例清单覆盖了哪些分支,反过来一对照就知道还有哪些分支没测。比如TC-04参数校验,如果写成代码了但测试清单里没有,那覆盖率再高也有漏洞。

4.5 参数化测试:同一份逻辑测多组数据

如果上面那个注册场景,你想验证多组手机号和姓名的组合,可以不用写多个测试方法,直接用JUnit 5的参数化测试:

@ParameterizedTest @ValueSource(strings = {"", " ", "abc"}) void shouldRejectInvalidPhone(String phone) { assertThrows(IllegalArgumentException.class, () -> userService.register(phone, "张三")); }

注意这个测试要能过,生产代码里必须真的有对应的参数校验逻辑。先写测试、再写实现,这就变成了TDD的节奏。参数化测试特别适合那种"同一套逻辑,多条输入数据"的场景,能把测试代码的重复度大幅度压下来。

5. 踩坑记录与排查技巧

5.1 Mockito常见异常,一句话讲清原因

用Mockito时间长了,基本会把下面这几个异常认全。我把它们的出现原因和解决思路整理成一张表,方便你遇到问题时快速对号入座:

报错信息出现原因解决思路
Wanted but not invokedverify某个方法时发现压根没调用确认被测代码是否执行了该分支;检查是否用错了mock对象
Unnecessary stubbing打了桩但测试里从来没走到删除多余打桩,或在mock创建时用lenient()声明宽松打桩
NotANullException / return null打桩方法返回了默认null检查when的条件是否匹配,参数是否一致
InvalidUseOfMatchersException混用了原始参数和matcher参数要么全用matcher,要么全用原始参数,不能混搭
MockitoException: FailedToReleasemock对象生命周期管理不当检查是否使用了MockitoAnnotations.openMocks,确保释放

这里面最常见的还是Unnecessary stubbing。Mockito是很严格的,一旦你打了桩却没用上,直接会算测试失败。这在刚开始用的时候挺烦的,但其实是好设计——它会逼你维护测试的整洁度。如果某个打桩并非所有分支都用得上,可以改成lenient().when(...):

lenient().when(repository.findByPhone(anyString())).thenReturn(null);

5.2 测试为什么"脆":甩开时间和随机数

单元测试最怕的就是不稳定。一个测试今天能过,明天挂了,代码一行没改,这种测试最让人抓狂。最常见的不稳定因素就是时间和随机数。

比如被测方法里有LocalDate.now(),那你不管怎么构造,只要测试在跨天前后跑,结果就可能在边界处翻车。解决思路是引入一个可以控制的时钟源。最简单的做法是给被测类注入一个Clock对象,测试时把时钟固定在某一天:

Clock fixedClock = Clock.fixed( LocalDate.of(2024, 6, 1).atStartOfDay().toInstant(ZoneOffset.UTC), ZoneOffset.UTC ); UserService service = new UserService(repository, notification, fixedClock);

随机数的处理同理。如果业务代码里用到Random,最好的做法也是把随机数生成器注入进去,或者抽出一个专门的可mock的方法。

5.3 静态方法能不能mock:Mockito 3.4之后的答案

曾经有很长一段时间,Mockito不能mock静态方法,遇到静态工具类只能干瞪眼。但从Mockito 3.4.0开始,官方引入了mockStatic能力。有了它,处理静态方法调用不再是无解的难题:

try (MockedStatic<PhoneValidator> mocked = mockStatic(PhoneValidator.class)) { mocked.when(() -> PhoneValidator.isValidPhone("13800000000")) .thenReturn(true); // 执行被测代码 }

注意这里用的是try-with-resources,它保证这个静态mock只在该作用域内生效,离开后自动清理。静态mock这类能力能不用尽量不用,因为它意味着设计上有静态依赖,但遇到老代码改造的场景,它就是救命稻草了。

5.4 版本差异:JUnit 4迁移到JUnit 5的注意点

现在的项目大概率已经在用JUnit 5了,但也会遇到老项目需要迁移的情况。JUnit 4里常见的@Test、@Before、@After、@RunWith,在JUnit 5里分别对应@Test、@BeforeEach、@AfterEach、@ExtendWith。断言也从org.junit.Assert换到了org.junit.jupiter.api.Assertions。

迁移时最容易踩的坑是import语句没改全,导致混用了JUnit 4和5的注解。代码能编译,但运行的时候JUnit 5的引擎可能压根不识别JUnit 4的注解。建议迁移时一次性把所有import都替换,然后跑一遍所有测试,别图省事只改部分。

Mockito方面,5.x版本要求JDK 11以上,如果你还在用JDK 8,就老老实实用Mockito 3.x。这个适配问题最容易被人忽略,因为报错信息通常不直说"版本不兼容",而是各种奇怪的初始化失败。

6. 写在最后的个人体会

我刚开始写测试那两年,也走过一段弯路:以为把mock对象一个个配齐、把覆盖率刷到90%,测试就算大功告成了。直到有一次,一个同事改了一行判断条件的代码,几十个测试全绿,线上却出了问题。排查了很久才发现,问题出在一个外部接口超时的分支上,而那段代码从来没有被任何测试覆盖过。

从那以后我学乖了。测试的真正价值不在于数量,也不在于覆盖率数字,而在于它能不能帮你守住业务规则。JUnit和Mockito本质上都是工具,工具用得怎么样,要看使用者对业务的理解。现在我每接到一个需求,会先想清楚核心业务规则有几条,再把它们一条条翻译成测试用例。等需求开发完了,测试也写得差不多了,反而是越写越顺。

最后再分享一个小技巧:跑测试的时候,尽量保证每个测试方法之间完全独立,不共享任何可变状态。遇到挂掉的测试,第一反应不是改代码,而是先看它是不是单独跑也能复现。很多灵异问题都是"一起跑就挂,单独跑就过",这种通常是测试之间互相污染了。把这一条守住,你的测试会稳定很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进