☰
Java单元测试实战:用JUnit与EasyMock搞定依赖隔离
2026/10/20 8:09:18 网站建设 项目流程

写单元测试这件事,很多Java后端开发都经历过一个阶段:觉得它麻烦、鸡肋,甚至觉得是在给公司“表演”。但如果你接手过一个没人敢动的老模块,或者因为一次重构直接把线上接口改崩,你就明白测试不是写给领导看的文档,是一个安全网。而当你真正开始写单元测试时,遇到的第一个问题往往不是“怎么写断言”,而是“依赖的东西我根本new不出来”——数据库连不上、RPC接口没部署、缓存中间件没起。这时候JUnit和EasyMock就成了最顺手的组合。

这篇内容没什么高深理论,我直接按平时写代码的思路,把JUnit+EasyMock从设计思路到实操代码再到踩坑记录完整拆一遍。不管你是刚入职的新人,还是写了好几年业务代码但没系统接触过mock的老手,这篇都能帮你在自己的项目里直接落地。我有一个项目,就是把一些古老的Service层代码翻出来补测试,用的就是这一套组合,实测下来非常稳。

1. 为什么非得用Mock:先搞清楚测试到底在测谁

1.1 没有mock的单元测试,根本“单”不起来

先说个很常见的场景。你写了一个用户服务,里面查数据库拿用户信息,然后做脱敏处理,再给前端返回。你兴冲冲地写测试,发现自己需要先连一个真实的MySQL,往里插几条测试数据,还得保证跑测试时这个库不会被别人清掉。更麻烦的是,如果你的服务依赖别人维护的接口,对方环境一没开,你的测试就直接红。

这种测试本质上已经不是单元测试了,它是在测“我的代码+数据库+别人服务+网络环境”这一整套链路。问题在于,一旦测试失败,你根本分不清是你写错了,还是数据库抽风,还是对方的接口返回变了。排查成本极高,跑起来还慢。

所以真正的单元测试,定义非常苛刻:只测当前类的逻辑,不测别人家的代码。你的类依赖了数据库、依赖了外部接口、依赖了配置文件?没问题,这些依赖全部不要用真实实现,换成“替身”就行。这个替身,就是Mock对象。

1.2 Mock对象解决了什么问题

Mock对象的核心思想就八个字:预设行为,验证调用。

拿用户服务举例。你的Service调用了UserDao.findById(),在单元测试里你不想真的去查库,于是你mock一个UserDao,告诉它:“当你收到findById('u001')这个调用时,直接返回一个你设定好的UserProfile对象。”Service层拿到这个假返回结果,继续执行自己的脱敏、拼装逻辑。测试结束时再检查一下:“findById这个方法真的被调用了1次吗?参数对吗?”

这样做的好处非常直接。第一,测试不再依赖外部环境,随便哪台机器都能跑;第二,测试速度极快,毫秒级完成;第三,你能够刻意制造真实环境很难出现的场景,比如数据库抛异常、网络超时、返回null,从而确认自己的代码在这些极端情况下表现正常。

1.3 为什么选择EasyMock而不是其他工具

Java生态里做mock的框架不少,Mockito、EasyMock、PowerMock、JMockit各有拥趸。EasyMock最大的风格特点,是它采用显式的“录制-回放-验证”三步曲,每一步的代码边界非常清楚,特别适合理解mock到底做了什么。相比之下,Mockito的语法更倾向于写起来爽,比如when(x).thenReturn(y),看着确实简洁,但它把“行为录制”和“执行阶段”之间的切换隐藏得更深,新手很难直观感受到mock对象在不同阶段下的区别。

EasyMock还有一个让我比较认可的设计:它默认的mock对象是“严格但是宽容”的——对于没有录制过的方法,调用它会抛异常,这就倒逼你把测试关注点收敛到必要的交互上,不至于测着测着就变成“什么都mock,什么都不验证”。当然这个特性后面会细说,用不用取决于你自己的场景。

2. EasyMock的三个阶段与三种Mock类型

2.1 录制:先把剧本写清楚

EasyMock里最核心的流程是录制(Record)。在这个阶段,你要告诉mock对象:“接下来被测代码会调用你的某个方法,你预期的参数是什么,你应该返回什么。”

录制的代码长这样:

UserDao mockDao = EasyMock.createMock(UserDao.class); EasyMock.expect(mockDao.findById("u001")).andReturn(userProfile);

EasyMock.expect()后面可以接一系列动作:

  • andReturn(value):指定返回值
  • andThrow(exception):模拟方法抛出异常
  • andAnswer(IAnswer<T>):用一段自定义逻辑生成返回值
  • times(n):指定该方法在测试中会被调用n次,1次可以省略不写

对于返回值是void的方法,不能用expect(),得换一种写法:

Notifier mockNotifier = EasyMock.createMock(Notifier.class); mockNotifier.notifyNameChanged("u001", "新名字"); EasyMock.expectLastCall();

expectLastCall()的意思是“刚才那一次void调用是一个预期的行为”,后面同样可以接times()、andThrow()。

2.2 回放:从“编剧模式”切换到“表演模式”

录制完成后,你必须在调用被测代码之前,把mock对象切换到“回放(Replay)”状态。这个步骤不写的话,mock对象会一直认为自己还在录制。

EasyMock.replay(mockDao);

真正到了回放阶段,mock对象就变成了一个“严格遵守剧本的演员”。被测代码调用它时,它会看当前这次调用的方法名和参数是否和录制时匹配。如果匹配,就按剧本返回;如果不匹配,会抛异常,提示你“这里有一个我没有准备过的调用”。

在线下授课和带新人时,我经常用一句话概括:录制阶段是在告诉替身演员“等会儿有人对暗号,你就说这句话”;回放阶段是把替身演员推上台,让它开始接戏。

2.3 验证:确认剧本真的演完了

被测代码执行完毕之后,你还需要调用EasyMock.verify(mock)检查一下,确保录制的每个调用都实际发生了,并且次数和录制时声明的一致。

EasyMock.verify(mockDao);

如果你录了findById("u001")但被测代码压根没走这一步,verify会抛出AssertionError,提示你“Expected 1 matcher but 0 matched”。这一步极其重要,很多人会漏掉,结果就是代码没测到该测的分支也不知道,测试绿得没有意义。

2.4 三种Mock类型的取舍

EasyMock提供了三种mock创建方式,区别在于“未录制的调用”和“调用顺序”的处理策略上。

Mock类型创建方式对未录制调用的行为对调用顺序的要求
默认MockcreateMock()抛异常不校验顺序
NiceMockcreateNiceMock()返回默认值(null、0、false)不校验顺序
StrictMockcreateStrictMock()抛异常严格校验录制顺序

默认Mock是最常用的,它要求你得把测试路径上所有会被调用的方法都想到,否则测试就炸,反而是在帮你保持测试的严谨性。

NiceMock适合对无关紧要的依赖做宽容处理。比如你的Service构造时依赖一个Logger组件,你不想为每个日志调用都写录制备注,那直接用一个NiceMock,它会在你调用log.info()时默默返回,什么都不做。但要注意,正因为NiceMock“什么都接受”,你要是把核心依赖也做成NiceMock,很容易出现“代码执行到了诡异分支但测试飘绿”的假象,所以核心依赖一律用默认Mock。

StrictMock适合在协议型交互中使用,比如你的代码必须按“先认证再取数据”的顺序调用两个方法,顺序反了就会出问题。这种场景下StrictMock能在测试时就暴露顺序错误。

3. 一个完整的JUnit+EasyMock实操案例

3.1 被测代码:一个用户资料服务

为了不空谈,我直接用平时项目里很常见的用户资料服务作为例子。它的逻辑是:根据userId查库拿到用户资料,如果用户不存在,抛业务异常;如果存在,把真实姓名做脱敏(只保留第一个字符),再设置一个展示名,返回给调用方。

public class UserProfileService { private final UserDao userDao; private final Notifier notifier; public UserProfileService(UserDao userDao, Notifier notifier) { this.userDao = userDao; this.notifier = notifier; } public UserProfile getProfile(String userId) { UserProfile profile = userDao.findById(userId); if (profile == null) { throw new UserNotFoundException("user not found: " + userId); } String maskedName = maskName(profile.getRealName()); profile.setMaskedName(maskedName); return profile; } public boolean updateDisplayName(String userId, String newDisplayName) { if (newDisplayName == null || newDisplayName.trim().isEmpty()) { return false; } UserProfile profile = userDao.findById(userId); if (profile == null) { return false; } profile.setDisplayName(newDisplayName.trim()); boolean updated = userDao.update(profile); if (updated) { notifier.notifyNameChanged(userId, newDisplayName.trim()); } return updated; } private String maskName(String realName) { if (realName == null || realName.length() <= 1) { return realName; } return realName.charAt(0) + "***"; } }

这里UserDao和Notifier都是接口,这是使用EasyMock的前提之一:EasyMock基于动态代理实现mock,它mock的是接口或者可以被继承重写的类。如果UserDao是个final类或者它的方法没被设计成可重写,EasyMock默认情况下是搞不定的,后面我会专门说这个限制。

3.2 第一个测试:覆盖正常返回与脱敏逻辑

首先写一个最简单的测试,覆盖“用户存在且正常返回”的路径。

import org.easymock.EasyMock; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class UserProfileServiceTest { @Test public void getProfile_WhenUserExists_ShouldMaskRealName() { UserDao mockDao = EasyMock.createMock(UserDao.class); Notifier mockNotifier = EasyMock.createNiceMock(Notifier.class); UserProfileService service = new UserProfileService(mockDao, mockNotifier); UserProfile dbProfile = new UserProfile("u001", "张三", "old_display"); EasyMock.expect(mockDao.findById("u001")).andReturn(dbProfile); EasyMock.replay(mockDao); EasyMock.replay(mockNotifier); UserProfile result = service.getProfile("u001"); assertEquals("张三***", result.getMaskedName()); assertEquals("old_display", result.getDisplayName()); EasyMock.verify(mockDao); } }

这个测试里有两个细节值得说明。一是Notifier用的是createNiceMock(),因为getProfile方法的正常路径里根本不会调用Notifier,如果我用默认mock,不录制不相关的调用没关系,但用NiceMock更稳妥,万一以后代码加了通知逻辑,测试不会因为“存在未录制的调用”直接挂掉,能留出时间专门补齐预期。

二是verify(mockDao)只验证了UserDao的调用。即便不验证Notifier也没关系,因为你在该路径上根本没预期它会被调用。

3.3 第二个测试:模拟依赖抛异常与断言异常

真实环境里,数据库查询不一定是返回null,还可能直接抛数据访问异常。比如连接池爆了、SQL写错了。你的Service如果没做异常包装,异常会直接抛给上层。这个行为要通过mock来锁定。

@Test public void getProfile_WhenDaoThrows_ShouldPropagate() { UserDao mockDao = EasyMock.createMock(UserDao.class); Notifier mockNotifier = EasyMock.createNiceMock(Notifier.class); UserProfileService service = new UserProfileService(mockDao, mockNotifier); EasyMock.expect(mockDao.findById("u001")) .andThrow(new DataAccessException("db connection failed")); EasyMock.replay(mockDao); EasyMock.replay(mockNotifier); assertThrows(DataAccessException.class, () -> service.getProfile("u001")); EasyMock.verify(mockDao); }

这里用了andThrow(),模拟的是“依赖方抛异常”的行为。它跟返回null的区别是:返回null走的是业务分支,程序会判断为“用户不存在”然后抛出UserNotFoundException;而andThrow()跳过了业务分支,把底层异常直接抛出来。两种都要测。

我在实际项目中见过不少同事只测正常路径。说实话,这是把单元测试当成“给自己写的代码鼓掌”而不是“给代码上保险”。异常路径才是bug高发区,你写的时候手一抖,异常就被吞了或者被包装错了,等到线上报警才发现,代价就大了。

3.4 第三个测试:void方法、参数匹配器与捕获参数

现在来看updateDisplayName这个稍微复杂的方法。它先做参数校验,再查用户,接着更新,如果更新成功,还要调用Notifier的void方法发通知。void方法在EasyMock里要用expectLastCall()。

同时我想验证一下Service传给Notifier的参数到底是什么,这时候要用到Capture。

@Test public void updateDisplayName_WhenSuccess_ShouldSendNotificationWithTrimmedName() { UserDao mockDao = EasyMock.createMock(UserDao.class); Notifier mockNotifier = EasyMock.createMock(Notifier.class); UserProfileService service = new UserProfileService(mockDao, mockNotifier); Capture<String> capturedUserId = Capture.newCapture(); Capture<String> capturedName = Capture.newCapture(); UserProfile dbProfile = new UserProfile("u001", "张三", "old_display"); EasyMock.expect(mockDao.findById("u001")).andReturn(dbProfile); EasyMock.expect(mockDao.update(EasyMock.anyObject(UserProfile.class))).andReturn(true); mockNotifier.notifyNameChanged(EasyMock.capture(capturedUserId), EasyMock.capture(capturedName)); EasyMock.expectLastCall(); EasyMock.replay(mockDao); EasyMock.replay(mockNotifier); boolean result = service.updateDisplayName("u001", " 新名字 "); assertTrue(result); assertEquals("u001", capturedUserId.getValue()); assertEquals("新名字", capturedName.getValue()); EasyMock.verify(mockDao); EasyMock.verify(mockNotifier); }

这个测试里有两个点要注意。

第一个是anyObject(UserProfile.class)。因为被测代码在更新前会给profile.setDisplayName(...)重新赋值,所以传给userDao.update(profile)的已经是修改后的同一个对象。你可以用精确的eq(profile)去匹配,但前提是你得能拿到那个被修改后的对象引用;而anyObject()匹配器则放宽了参数约束,只要求是UserProfile类型即可。实际测试中,我用eq经常因为对象状态被中途修改而烦恼,anyObject更省心,但代价是参数匹配的校验变弱了。

第二个是Capture。它像一个捕兽夹,会把调用mock方法时实际传入的参数“夹住”,测试跑完之后你可以拿夹住的值做断言。这对验证“方法是否把参数加工后传给依赖”非常有效。比如这里,传入的展示名是" 新名字 ",Service内部会把前后空格去掉,所以Notifier收到的应该是"新名字"。不用Capture的话,你根本无从验证这个加工行为。

3.5 再补一个:验证调用次数与严格顺序

前面几个测试只强调了“调用存在”和“返回值正确”,但在有些领域里,“调用次数”是核心不变量。比如这个Notifier,你要是录制的业务逻辑里出现了一个Bug,导致通知被发了两次,用户就会收到两条一模一样的短信——这种事发生过不止一次。

@Test public void updateDisplayName_WhenNotificationShouldBeSentOnlyOnce() { UserDao mockDao = EasyMock.createMock(UserDao.class); Notifier mockNotifier = EasyMock.createMock(Notifier.class); UserProfileService service = new UserProfileService(mockDao, mockNotifier); EasyMock.expect(mockDao.findById("u001")) .andReturn(new UserProfile("u001", "张三", "old_display")); EasyMock.expect(mockDao.update(EasyMock.anyObject(UserProfile.class))) .andReturn(true); mockNotifier.notifyNameChanged(EasyMock.anyString(), EasyMock.anyString()); EasyMock.expectLastCall().times(1); EasyMock.replay(mockDao); EasyMock.replay(mockNotifier); service.updateDisplayName("u001", "新名字"); EasyMock.verify(mockNotifier); }

times(1)在这里是显式声明:只允许调用一次。如果你不写times,EasyMock默认就是一次,但写出来阅读性更强,团队里其他人看测试时能直观知道这里是“有次数要求”的。

如果要顺带验证调用顺序,把mock换成StrictMock,然后把录制顺序按照真实代码的执行顺序写一遍即可。一旦被测代码的调用顺序和录制顺序不一致,replay阶段还没等业务执行完就会报错。

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

4.1 replay忘写导致IllegalStateException

这是我见过最频繁的低级错误。你录制完了,直接调用被测代码,mock对象立刻抛IllegalStateException: missing behavior definition for the method。原因很简单:test在录制阶段调用mock方法只是为了把期望行为记下来,如果你还没有replay就进入业务代码,mock对象会认为你仍然在录制,而录制阶段是不允许“真正返回数据”的。

排查方法:代码里从上到下看,确认每个mock都调用了EasyMock.replay()。我个人的一个习惯是,把replay语句全部放在测试代码的业务调用之前,集中排列,不拆散,方便一眼检查。

4.2 调用匹配失败:Unexpected method call

你录了findById("u001"),实际代码却传了一个"u002",回放阶段mock对象会直接抛异常,提示Unexpected method call findById("u002")。

这个问题有两种处理思路:

  • 如果你确实只想测“u001被查到”的路径,那说明被测代码传错了参数,这反而是好消息——测试帮你揪出了bug。
  • 如果你不在乎具体参数是什么,只是想模拟“无论传什么id都返回该用户”,那就用参数匹配器,把录制改成EasyMock.expect(mockDao.findById(EasyMock.anyString())).andReturn(profile)。

这里有一个极度容易踩的坑:一旦你用了任何一个参数匹配器,该方法的每个参数都必须使用匹配器,不能混着来。比如一个方法有两个参数,你写mock.findById("u001", EasyMock.anyInt()),编译没问题,运行时会直接报错。我之前带过的新人遇到这个报错完全懵了,其实是EasyMock对参数匹配器的一致性有强制要求。

4.3 verify失败:Expected but not invoked

测试跑完,断言全过了,但EasyMock.verify()抛出了AssertionError,提示Expected 1 matcher but 0 matched。

这说明被测代码压根没走你录制的那条分支。常见原因是被测代码在某个if条件上提前return了,或者走了catch块。这时候你不是去“把verify去掉”,而是应该思考:我是不是用了错误的输入参数,导致被测代码没有进入预期分支?这通常是测试数据的锅,不是代码的锅。把输入数据改正确,verify自然就过了。

4.4 JUnit5适配EasyMock的版本认知问题

很多人在JUnit4时代用的是@RunWith(EasyMockRunner.class)或者EasyMockRule。但如果你现在用JUnit5,不要再碰这几个类了,它们派不上用场。

在JUnit5里,EasyMock提供了EasyMockExtension,配合@ExtendWith使用。测试代码可以这样写:

@ExtendWith(EasyMockExtension.class) public class UserProfileServiceJUnit5Test { @Mock private UserDao mockDao; @Mock private Notifier mockNotifier; @TestSubject private UserProfileService service; }

@Mock注解可以让EasyMock自动创建mock对象,@TestSubject则让它自动把mock注入到被测对象的构造器或字段里。不过这个方式有一个前提:被测对象的构造器或者字段注入得能让框架找得到。如果被测对象是通过复杂的工厂、配置文件创建的,那注入就会失败。我的建议是:简单场景用注解简化代码清晰度,复杂场景老老实实用手动createMock()和手动new被测对象。

4.5 模拟final类或静态方法的限制

很多人写到后面会问:EasyMock能不能mock final类?答案分版本:

  • EasyMock 4.x 配合org.easymock:easymock在JVM默认配置下仍然不能mock final类,需要依赖额外配置。
  • 如果你的被测代码使用了静态方法调用、私有方法、final类等,EasyMock单独搞不定,要么重构代码使其依赖可注入,要么引入PowerMock。

这里我想多说一句:如果你的项目里大量代码依赖静态工具类,单元测试写不下去,那真正的问题不是测试框架不够强,而是代码设计本身存在缺陷。静态方法调用本质上就是隐藏依赖,你连mock都不知道从哪下手。我在实际项目里处理过的那批老代码,补测试的过程其实也是重构的过程——把静态调用改成通过接口注入,代码可测性瞬间上来。这也是单元测试的一个隐藏价值:它倒逼你写出更可测试的代码。

4.6 一个排查速查表

现象常见原因排查路径
IllegalStateException: missing behavior definition忘记replay检查是否调用了EasyMock.replay
Unexpected method call参数或方法不匹配检查参数值,考虑使用anyString/anyObject
AssertionError: Expected but not invoked被测代码没走预期分支检查测试输入数据是否绕过了目标分支
verify失败但断言全过mock调用次数或顺序不对给录制方法加上times(n),检查是否多调用了一次
ClassCastExceptionmock和真实类型不匹配确认mock创建的类型与依赖声明一致
JUnit5下注解不生效缺少EasyMockExtension检查是否加了@ExtendWith(EasyMockExtension.class)

5. 写在最后的几个操作习惯

我个人做了几年Java测试,踩过的坑不计其数,最后形成了一套自己的习惯。

第一,凡是涉及依赖交互的测试,我必写verify。一个测试如果把mock的返回值和最终断言都做了,但忽略了verify,就好比你请了个人来帮忙盯梢,结果你连他有没有到场都没确认。

第二,每个方法至少写两条测试:一条成功路径、一条失败路径。失败路径有两种,依赖返回null和依赖抛异常,它们走的是完全不同的业务分支,都要覆盖。

第三,如果测试需要构造大量数据,优先考虑简化被测方法的参数设计,而不是在测试里堆一堆无意义的字段赋值。我在处理一个项目时就发现,Service层的参数对象有十多个字段,每次构造都是噩梦,后来把不必要的字段从接口方法里剔除,测试代码量直接下降一半。

第四,不要追求100%覆盖率。行覆盖率达到70%-80%,核心业务分支全覆盖,就已经比大多数项目强了。与其反复打磨那些没价值的getter/setter覆盖,不如把时间花在写异常场景的测试上。

EasyMock这套工具,表面上看只是帮你生成了一个“假的依赖对象”,但它真正教会我的思维转变是:写代码时就会下意识地去想“这个依赖将来怎么被替换、怎么被测试”。这个习惯比任何一个测试框架都值钱。如果你还没在自己的项目里写过真正有效的单元测试,建议今天就挑一个Service类,把数据库依赖和通知依赖mock掉,写一个正常返回、一个异常返回的用例,跑通之后再回头看这段代码,你会发现代码的脆弱点全部暴露出来了,这就是测试带给你的最大收益。

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

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

立即咨询