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

发布时间:2026/10/21 9:42:53
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 groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version5.12.0/version scopetest/scope /dependency /dependencies注意两点。第一junit-jupiter是一个聚合依赖它会帮你把jupiter-api、jupiter-params、jupiter-engine都带进来不需要再单独加第二scope必须是test这样依赖只对测试代码可见不会打进生产包。如果用的是Spring Boot项目建议顺手把mockito-junit-jupiter也加进来这样Mockito能跟JUnit 5的扩展模型做集成Mock注解的自动初始化会更方便dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version5.12.0/version scopetest/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注解加MockitoExtensionExtendWith(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));另一种是方法执行本身有副作用或者返回值需要在调用时才计算这时候用doAnswerdoAnswer(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能把这个参数捕获出来细细检查ArgumentCaptorLong captor ArgumentCaptor.forClass(Long.class); verify(repository).findById(captor.capture()); assertEquals(1L, captor.getValue());它跟verify配合可以验证的粒度更细——不仅调用过而且是按预期的参数调用的。4. 实战演练一个完整的用户服务单元测试4.1 场景设定与业务代码前面讲的都是零散的知识点这一节我把它们揉进一个完整的例子。场景是这样的某系统里有一个用户注册服务UserService它的核心逻辑是根据手机号查用户仓库判断用户是否已存在如果存在抛业务异常不存在则创建新用户保存到仓库保存成功后调用消息通知客户端推送一条欢迎消息。依赖有两个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 静态方法能不能mockMockito 3.4之后的答案曾经有很长一段时间Mockito不能mock静态方法遇到静态工具类只能干瞪眼。但从Mockito 3.4.0开始官方引入了mockStatic能力。有了它处理静态方法调用不再是无解的难题try (MockedStaticPhoneValidator 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本质上都是工具工具用得怎么样要看使用者对业务的理解。现在我每接到一个需求会先想清楚核心业务规则有几条再把它们一条条翻译成测试用例。等需求开发完了测试也写得差不多了反而是越写越顺。最后再分享一个小技巧跑测试的时候尽量保证每个测试方法之间完全独立不共享任何可变状态。遇到挂掉的测试第一反应不是改代码而是先看它是不是单独跑也能复现。很多灵异问题都是一起跑就挂单独跑就过这种通常是测试之间互相污染了。把这一条守住你的测试会稳定很多。