1. EasyMock不是“Easy”Mock,而是“Easy to Misuse”的Mock
刚接触EasyMock那会儿,我正带一个三人小团队赶一个金融类后台接口联调。前端已经等不及要测UI了,但第三方支付网关的沙箱环境还在审批流程里,后端同事说:“用EasyMock搭个假服务吧,五分钟搞定。”——结果我们花了整整两天半,反复重启、清缓存、重写expect语句、查JDK版本兼容性,最后发现根本不是代码问题,而是EasyMock 3.x默认使用Java动态代理,而我们mock的是final类。这个坑,连官方文档首页都没加粗标红。
EasyMock不是“轻松模拟”的代名词,它是Java生态中一个高度约定式、强生命周期管理、对测试哲学有隐含假设的Mock框架。它的名字里那个“Easy”,其实是对熟悉JUnit+反射+代理机制的老手而言的;对新手来说,它比Mockito更易踩坑,因为它的报错信息往往指向“期望未满足”或“方法调用顺序错误”,却从不告诉你“你正在mock一个不能被代理的类”。
它解决的核心问题非常具体:在单元测试中,隔离被测对象(SUT)与其依赖的协作对象(Collaborator),尤其是当协作对象具有以下特征时:
- 依赖外部系统(数据库、HTTP服务、消息队列)
- 构造成本高(如Spring容器初始化)
- 行为不可控(如时间、随机数、网络延迟)
- 状态难复现(如支付回调的多种成功/失败分支)
关键词“EasyMock”背后,实际承载的是三个技术锚点:动态代理(Proxy)、行为驱动开发(BDD)风格的录制-回放(Record-Replay)模型、以及严格的调用验证(Verification)机制。它不提供“宽松模式”或“自动stub”,一切行为都必须显式声明——这既是它的严谨性来源,也是新手上手门槛的根源。
如果你现在正打开IDE,准备给一个Service类写单元测试,而它的某个依赖是final class PaymentClient,或者你希望mock一个静态方法,又或者你只是想“随便返回个字符串”,那我建议你立刻合上这个页面,去学Mockito。EasyMock不是万能胶,它是精密手术刀——只适合切开那些明确符合其设计契约的依赖。
提示:EasyMock的适用边界非常清晰——它只mock非final的接口和非final的类,且要求被mock类的构造函数可被反射调用(无参或参数可由EasyMock自动注入)。超出此范围,它不会给你友好提示,只会抛出
IllegalArgumentException: Cannot create mock for class XXX,然后让你在Stack Overflow上翻三页才找到答案。
2. 录制-回放模型:为什么你的test方法总在“expect”阶段就挂掉
EasyMock最标志性的语法,就是那段看起来像在拍电影分镜脚本的代码:
PaymentService paymentService = EasyMock.createMock(PaymentService.class); EasyMock.expect(paymentService.process("ORDER-001")).andReturn("SUCCESS").times(1); EasyMock.replay(paymentService);这段代码不是在“定义行为”,而是在录制一段未来将被回放的交互剧本。整个过程严格分为三个阶段:Create → Record → Replay,缺一不可,且顺序不可逆。这是理解EasyMock所有诡异行为的总开关。
2.1 Create阶段:创建Mock对象的本质是生成代理实例
EasyMock.createMock(PaymentService.class)这行代码,底层调用的是Enhancer(CGLIB)或Proxy.newProxyInstance()(JDK Proxy),取决于你mock的是接口还是类。关键点在于:
- Mock接口:强制使用JDK动态代理,生成
$ProxyXX字节码,性能好、无侵入; - Mock类:默认使用CGLIB(EasyMock 3.2+可配置),通过继承目标类并重写所有非final方法来实现,因此目标类不能是final,且构造函数不能有不可满足的参数。
我曾遇到一个经典案例:mock一个Spring@Service类,该类构造器注入了DataSource和RedisTemplate。EasyMock在create阶段就抛InvocationTargetException,堆栈末尾是NoSuchMethodException: com.example.PaymentService.<init>()。原因?它试图调用无参构造器,但目标类根本没有。解决方案不是改代码,而是改mock方式:用createStrictMock()或createNiceMock()并不能解决,必须显式传入构造参数:
// 正确做法:告诉EasyMock如何构造目标类 PaymentService paymentService = EasyMock.createMock( PaymentService.class, new Class<?>[]{DataSource.class, RedisTemplate.class}, new Object[]{mockDataSource, mockRedisTemplate} );这个细节,90%的入门教程都不会提,因为它违背了“Easy”的直觉——你得先懂目标类的构造逻辑,才能mock它。
2.2 Record阶段:expect()不是设置返回值,而是注册“期望事件”
EasyMock.expect(paymentService.process("ORDER-001")).andReturn("SUCCESS")这行,常被误解为“当调用process时返回SUCCESS”。错。它的真实含义是:在Replay阶段,当paymentService.process("ORDER-001")被调用时,我期望这个事件发生,并且它应该返回"SUCCESS"。
这意味着:
expect()必须在replay()之前调用,否则抛IllegalStateException: Missing method call before replay();expect()的参数必须与Replay阶段实际调用的参数完全一致(引用相等或equals()相等,取决于EasyMock.eq()等匹配器的使用);- 如果你在Record阶段写了
expect(x.method(1)).andReturn("a"),但在Replay阶段调用了x.method(2),EasyMock不会返回null或抛NPE,而是直接在verify()阶段报错:“Unexpected method call x.method(2)”。
这就是为什么很多人说“EasyMock报错位置很奇怪”——错误不在调用处,而在verify()那一行。因为EasyMock把所有期望都缓存在内部队列里,等到验证时才逐条比对。
2.3 Replay阶段:mock对象进入“只读剧本”模式
EasyMock.replay(paymentService)是一道分水岭。执行后,mock对象进入严格模式:它只响应Record阶段声明过的调用,且必须按声明的顺序、次数、参数精确匹配。此时再调用expect()会抛异常。
这里有个极易被忽略的陷阱:mock对象的状态是单次录制、单次回放的。如果你在一个test方法里多次调用replay(),或者在多个test方法间复用同一个mock实例,结果必然是IllegalStateException: Unexpected method call。因为EasyMock内部状态机已从RECORD切换到REPLAY,无法倒带。
正确姿势永远是:每个test方法,独立create → record → replay → verify → reset(可选)。我见过最离谱的反模式,是把mock对象声明为@BeforeClass静态变量,导致整个测试类所有test方法共享同一份期望队列——结果A测试的expect污染了B测试的replay,debug三天才发现。
注意:
EasyMock.verify()不是可选的“锦上添花”,而是强制的契约验证环节。它检查两件事:1)所有recorded的expect是否都被replay过(times(1)没被调用就报错);2)所有replay的调用是否都在record中声明过(多调用一个就报错)。跳过verify,等于mock形同虚设。
3. 从StrictMock到NiceMock:三种预设行为模式的实战取舍
EasyMock提供了三种创建mock的工厂方法:createMock()、createStrictMock()、createNiceMock()。它们的区别不是“严格程度”,而是对未声明调用(Unexpected Call)的容忍策略,这直接决定了你的测试是“脆弱但精准”还是“健壮但模糊”。
3.1 createStrictMock:外科医生模式,零容忍
PaymentService strictMock = EasyMock.createStrictMock(PaymentService.class); EasyMock.expect(strictMock.process("ORDER-001")).andReturn("SUCCESS"); EasyMock.replay(strictMock); // 下面这行会直接在replay阶段抛异常! strictMock.getStatus(); // IllegalStateException: Unexpected method call PaymentService.getStatus()StrictMock是EasyMock的“原教旨主义”实现。它要求:
- 所有方法调用(包括
toString()、hashCode()、equals(Object))都必须在Record阶段显式expect; - 调用顺序必须与expect声明顺序完全一致;
- 次数必须精确匹配(
times(1)就是只能调一次)。
适用场景极其有限:当你需要100%保证被测代码没有调用任何未声明的依赖方法,比如安全审计、协议合规测试。日常开发中,它会让你的测试变得极其脆弱——仅仅因为JDK版本升级导致某框架内部多调了一次toString(),你的测试就全挂。
3.2 createMock:平衡模式,宽容但不失控
这是EasyMock的默认行为,也是最常用的选择。它对未声明调用的处理是:返回类型默认值(null、0、false),但不记录也不验证。
PaymentService normalMock = EasyMock.createMock(PaymentService.class); EasyMock.expect(normalMock.process("ORDER-001")).andReturn("SUCCESS"); EasyMock.replay(normalMock); // 这行不会报错,但也不会被verify捕获 normalMock.getStatus(); // 返回null,静默通过 // verify阶段只检查process()是否被调用,getStatus()被完全忽略 EasyMock.verify(normalMock); // 成功这种模式的好处是“不干扰正常测试流”,坏处是可能掩盖真实问题。比如你mock了一个DAO,忘了expectsave()方法,但测试中确实调用了它——EasyMock返回null,而你的业务代码恰好对null做了空处理,测试就“意外”通过了。这违背了测试的“失败即缺陷”原则。
3.3 createNiceMock:消防员模式,救火专用
PaymentService niceMock = EasyMock.createNiceMock(PaymentService.class); EasyMock.expect(niceMock.process("ORDER-001")).andReturn("SUCCESS"); EasyMock.replay(niceMock); // 这行完全OK,返回null,且不会影响verify niceMock.getStatus(); // null niceMock.getBalance(); // 0.0 EasyMock.verify(niceMock); // 依然成功,只验证了process()NiceMock是三者中最“友好”的。它对所有未声明的方法调用,都返回安全的默认值(nullfor object,0for number,falsefor boolean),且完全不纳入verify检查范围。
它唯一的、也是最重要的适用场景:mock那些你只关心少数几个方法,其余方法全是“噪音”的胖接口(Fat Interface)。比如mockHttpServletRequest,你只关心getParameter("id")和getSession(),但Spring MVC内部会疯狂调用getContentType()、getCharacterEncoding()、getLocale()……用StrictMock或NormalMock,你得写十几行expect,而NiceMock一行搞定。
我的经验是:80%的测试用createMock,15%用createNiceMock(针对胖接口),5%用createStrictMock(仅限安全关键路径)。硬要统一用一种,反而降低测试质量。
实战技巧:不要在测试类顶部统一声明mock类型。根据每个test方法的具体依赖特征,动态选择。比如测试支付主流程用
createMock,测试支付回调兼容性时mockHttpServletRequest就用createNiceMock。灵活性比“一致性”更重要。
4. 参数匹配器(Matchers):为什么你的expect("abc")永远不匹配
EasyMock.expect(mock.process("ORDER-001"))看似简单,实则暗藏玄机。EasyMock默认使用引用相等(==)比较参数,而非equals()。这意味着:
String orderId = "ORDER-001"; EasyMock.expect(mock.process(orderId)).andReturn("SUCCESS"); // 在replay阶段,如果被测代码传入的是new String("ORDER-001") mock.process(new String("ORDER-001")); // 匹配失败!因为两个String对象引用不同这不是bug,是设计。EasyMock认为,参数的“身份”(identity)比“值”(value)更重要——它要确保被测代码调用的是你期望的那个确切对象,而不是一个“长得像”的副本。这在mock集合、自定义对象时尤为关键。
但现实是,99%的场景,你关心的是值,不是引用。EasyMock为此提供了丰富的参数匹配器(Matchers),它们必须成对使用:所有参数要么全用matcher,要么全不用。混用会抛IllegalStateException: 2 matchers expected, 1 recorded。
4.1 最常用匹配器:eq(), anyObject(), isA()
EasyMock.eq("ORDER-001"):调用equals()比较,最安全通用;EasyMock.anyObject():匹配任意非null对象,常用于泛型方法;EasyMock.isA(String.class):匹配指定类型的任意实例。
// 正确:全部使用matcher EasyMock.expect(mock.process(EasyMock.eq("ORDER-001"))).andReturn("SUCCESS"); EasyMock.expect(mock.save(EasyMock.anyObject())).andReturn(true); EasyMock.expect(mock.find(EasyMock.isA(Order.class))).andReturn(order); // 错误:混用,编译通过但运行时报错 EasyMock.expect(mock.process("ORDER-001")).andReturn("SUCCESS"); // 字符串字面量 EasyMock.expect(mock.save(EasyMock.anyObject())).andReturn(true); // matcher4.2 复杂匹配:and(), or(), not(), capture()
当需要组合条件时,EasyMock提供逻辑运算匹配器:
// 匹配:orderId以"ORDER-"开头 AND amount > 100 EasyMock.expect(mock.charge( EasyMock.and( EasyMock.startsWith("ORDER-"), EasyMock.gt(100.0) ) )).andReturn("APPROVED");但更强大的是Capture——它能捕获replay阶段实际传入的参数值,供后续断言:
Capture<String> capturedOrderId = new Capture<>(); Capture<Double> capturedAmount = new Capture<>(); EasyMock.expect(mock.charge( EasyMock.capture(capturedOrderId), EasyMock.capture(capturedAmount) )).andReturn("APPROVED"); EasyMock.replay(mock); // 执行被测代码 service.processOrder(order); // 验证:不仅调用发生,而且参数值符合预期 EasyMock.verify(mock); Assert.assertEquals("ORDER-001", capturedOrderId.getValue()); Assert.assertEquals(150.0, capturedAmount.getValue(), 0.01);这是EasyMock超越“简单返回值mock”的核心能力:它让测试能验证“被测代码是否向依赖传递了正确的数据”,而不只是“是否调用了依赖”。
4.3 自定义Matcher:当内置不够用时
内置匹配器总有覆盖不到的场景。比如你需要验证一个OrderRequest对象的status字段是否为"PAID",且items.size()大于0。这时可以写一个匿名内部类:
EasyMock.expect(mock.submit( EasyMock.or( EasyMock.and( EasyMock.isA(OrderRequest.class), new IArgumentMatcher() { @Override public boolean matches(Object argument) { OrderRequest req = (OrderRequest) argument; return "PAID".equals(req.getStatus()) && req.getItems().size() > 0; } @Override public void appendTo(StringBuffer buffer) { buffer.append("OrderRequest with status=PAID and non-empty items"); } } ), EasyMock.isNull() ) )).andReturn("OK");注意appendTo()方法——它会在匹配失败时出现在错误信息里,是调试的关键。没有它,你只会看到“ArgumentMatcher failed”,却不知哪个matcher。
关键提醒:所有matcher必须在
replay()前调用,且capture()的getValue()只能在verify()之后调用。提前调用会返回null,因为capture动作发生在replay阶段。
5. 集成Spring:为什么@MockBean在Spring Boot里比EasyMock更香
在Spring生态中,直接用EasyMock.createMock()创建mock,然后手动注入到被测Bean,是一种“原始人”做法。Spring Test提供了更高阶的抽象:@MockBean。它与EasyMock的关系,不是替代,而是封装与增强。
5.1 @MockBean的底层,很可能就是EasyMock
Spring Boot 2.2+ 默认使用Mockito,但你可以通过spring.test.mockito.inline=false强制回退到EasyMock(需引入org.easymock:easymock)。@MockBean的本质是:
- 在Spring Test Context中,创建一个mock实例;
- 将其注册为Bean,替换掉容器中原有的真实Bean;
- 管理mock的生命周期(每个test方法自动reset);
- 与
@Autowired无缝集成。
@SpringBootTest class PaymentServiceTest { @MockBean // Spring创建mock,自动注入 private PaymentGateway gateway; @Autowired private PaymentService service; @Test void testProcessSuccess() { // 使用EasyMock语法录制 EasyMock.expect(gateway.charge(EasyMock.anyObject())) .andReturn(new ChargeResult("SUCCESS", "TXN-001")); EasyMock.replay(gateway); String result = service.process("ORDER-001"); assertEquals("SUCCESS", result); EasyMock.verify(gateway); } }这里gateway已经是EasyMock创建的mock,你无需createMock(),只需专注expect/replay/verify。Spring帮你解决了mock的创建、注入、销毁。
5.2 与@SpyBean的协同:部分mock的黄金组合
@SpyBean创建的是真实对象的“间谍”(spy),它调用真实方法,但允许你对特定方法进行stub。与EasyMock结合,能实现“大部分走真实逻辑,关键点打桩”的精准测试:
@SpyBean private PaymentService spyService; @Test void testWithRealLogicButStubbedGateway() { // 对spyService的gateway依赖进行EasyMock stub PaymentGateway mockGateway = EasyMock.createMock(PaymentGateway.class); EasyMock.expect(mockGateway.charge(EasyMock.anyObject())) .andReturn(new ChargeResult("FAILED", "ERR-001")); EasyMock.replay(mockGateway); // 将mock注入spy,覆盖其内部gateway字段 ReflectionTestUtils.setField(spyService, "gateway", mockGateway); String result = spyService.process("ORDER-001"); // 走真实process逻辑,但gateway被stub assertEquals("FAILED", result); EasyMock.verify(mockGateway); }这种组合,比纯mock更接近真实场景,比纯集成测试更快更稳定。
5.3 避坑指南:Spring上下文与EasyMock的生命周期冲突
最大的坑在于:Spring Test Context是跨test方法缓存的,而EasyMock mock是单次生命周期的。如果你在一个test方法里replay()了mock,下一个test方法再用同一个mock实例,会因状态机已处于REPLAY态而失败。
解决方案只有两个:
- 推荐:每个test方法内独立
replay()和verify(),不复用mock实例; - 强制:在
@After方法中调用EasyMock.reset(mock),将其状态重置为RECORD。
@After public void tearDown() { EasyMock.reset(gateway); // 重置mock,为下一个test准备 }但要注意,reset()会清除所有recorded的expect,所以它只适用于“每个test方法只mock一个东西”的简单场景。复杂场景,老老实实每个test新建mock。
经验之谈:在Spring Boot项目中,优先用
@MockBean+Mockito,因为Spring官方深度集成;若团队已重度使用EasyMock且不愿迁移,则用@MockBean作为容器管理入口,内部仍用EasyMock语法,这是最平滑的过渡方案。
6. EasyMock 4.x vs 3.x:升级不是点个按钮,而是重构测试哲学
EasyMock 4.x(2017年发布)是一次颠覆性升级,它彻底抛弃了静态方法调用(EasyMock.expect()),转向面向对象的API设计。这不是语法糖,而是测试范式的转变。
6.1 旧世界:静态工厂方法的诅咒
EasyMock 3.x的API是典型的“工具类”风格:
// 3.x PaymentService mock = EasyMock.createMock(PaymentService.class); EasyMock.expect(mock.process("ORDER-001")).andReturn("SUCCESS"); EasyMock.replay(mock); // ... test ... EasyMock.verify(mock);问题在于:
- 静态方法难以Mock(比如你想测试一个类,它内部调用了
EasyMock.createMock()); - 缺乏类型安全,
expect()返回IExpectationSetters,链式调用容易中断; replay()和verify()是全局操作,状态混乱。
6.2 新世界:Mockery上下文的统治
EasyMock 4.x引入Mockery类,作为所有mock操作的“司令部”:
// 4.x Mockery context = new Mockery(); PaymentService mock = context.mock(PaymentService.class); context.checking(new Expectations() {{ oneOf(mock).process("ORDER-001"); will(returnValue("SUCCESS")); }}); // ... test ... context.assertIsSatisfied(); // 替代verify()Mockery是一个实例,它:
- 封装了所有mock对象及其期望;
checking()块内定义期望,语法更接近自然语言(oneOf,allowing,ignoring);isAssertSatisfied()一次性验证所有mock,不再需要为每个mock单独verify();- 支持
@RunWith(JUnit4Mockery.class),自动管理上下文生命周期。
6.3 升级代价:不是重命名,而是重写
从3.x升级到4.x,没有自动迁移工具。你必须:
- 将所有
EasyMock.createMock()改为context.mock(); - 将所有
expect().andReturn()改为checking(new Expectations(){{...}})块; - 将所有
replay()删除(4.x自动管理); - 将所有
verify()改为context.assertIsSatisfied(); - 引入
junit4依赖(4.x原生支持JUnit4,JUnit5需额外适配器)。
我主导过一个500+测试用例的项目升级,耗时两周。最大的收获不是性能提升(4.x其实略慢),而是测试可读性的质变。oneOf(mock).process("ORDER-001")比EasyMock.expect(mock.process("ORDER-001"))更清晰地表达了“期望发生一次调用”,而不是“期望这个方法返回什么”。
现实建议:新项目直接上EasyMock 4.x;老项目升级前,先评估ROI。如果测试覆盖率高、维护频繁,升级值得;如果测试只是摆设、三年没动过,不如趁机换成Mockito——后者生态更广,文档更全,社区更活跃。
7. 当EasyMock不再“Easy”:五个必须迁移到Mockito的信号
EasyMock曾是Java Mock领域的王者,但时代变了。以下五个信号,表明你的项目已站在迁移的十字路口:
7.1 信号一:你在mock final类或静态方法
EasyMock 3.x/4.x均不支持mock final类、final方法、静态方法、私有方法。如果你的代码大量使用Lombok@UtilityClass、GuavaImmutableList、或Spring@Configuration类,你会不断遇到:
java.lang.IllegalArgumentException: Cannot create mock for class XXX because it is finalMockito 3.4.0+ 通过mockito-inline模块,原生支持mock final类和方法。一行配置即可:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>4.11.0</version> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-inline</artifactId> <version>4.11.0</version> </dependency>// Mockito可以轻松mock final类 final class PaymentUtil { static String generateTxnId() { return "TXN-" + System.currentTimeMillis(); } } PaymentUtil mockUtil = Mockito.mock(PaymentUtil.class); Mockito.when(mockUtil.generateTxnId()).thenReturn("MOCK-TXN");7.2 信号二:你的测试里充斥着Capture和自定义Matcher
当超过30%的test方法需要Capture捕获参数,或频繁编写IArgumentMatcher时,说明你正在用EasyMock解决本不属于它的任务——验证复杂业务逻辑。Mockito的ArgumentCaptorAPI更简洁,且ArgumentMatcher支持Lambda,可读性飙升:
// Mockito 4.x ArgumentCaptor<OrderRequest> captor = ArgumentCaptor.forClass(OrderRequest.class); verify(gateway).charge(captor.capture()); OrderRequest actual = captor.getValue(); assertThat(actual.getStatus()).isEqualTo("PAID"); assertThat(actual.getItems()).isNotEmpty();7.3 信号三:团队新人平均上手时间超过一天
EasyMock的学习曲线陡峭,源于其“录制-回放”模型与直觉相悖。Mockito采用“when-then”声明式模型,更符合开发者心智:
// EasyMock(难记顺序) mock = createMock(Service.class); expect(mock.doWork()).andReturn("done"); replay(mock); // ... use ... verify(mock); // Mockito(直觉流) mock = mock(Service.class); when(mock.doWork()).thenReturn("done"); // ... use ... verify(mock).doWork();新人看Mockito代码,基本能猜出80%逻辑;看EasyMock,得先查文档确认replay()在哪。
7.4 信号四:你开始为EasyMock写自己的Wrapper工具类
我见过最典型的反模式:一个叫EasyMockHelper的工具类,封装了createNiceMockForRequest()、replayAll()、verifyAll()等方法,试图“简化”EasyMock。这恰恰证明:框架本身的设计已无法满足你的需求,你正在用胶水粘合裂缝。Mockito的MockitoSession和MockitoExtension(JUnit5)提供了更优雅的生命周期管理。
7.5 信号五:CI构建中EasyMock相关测试失败率高于10%
EasyMock对JDK版本、字节码增强库(CGLIB/Byte Buddy)版本极其敏感。JDK 17+的强封装(Strong Encapsulation)会让EasyMock 3.x的反射调用大面积失败。而Mockito 4.x已全面拥抱模块化,对JDK 17/21支持完善。
我的迁移路线图:先用
mockito-inline替换EasyMock 3.x,保持测试结构不变;再逐步将when().thenReturn()替换expect().andReturn();最后引入@ExtendWith(MockitoExtension.class),告别@Before中的reset()。整个过程,测试通过率始终保持100%,因为Mockito的API是向后兼容的。
EasyMock不是过时,而是完成了它的历史使命——它教会了Java世界“Mock是什么”,并为后来者铺平了道路。当你不再需要纠结replay()的时机,不再为final关键字抓狂,不再在Stack Overflow上搜索“EasyMock unexpected method call”,你就知道,是时候说再见了。