多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

TestableMock实战:告别Mockito痛点,轻松Mock静态、私有与构造方法

TestableMock实战:告别Mockito痛点,轻松Mock静态、私有与构造方法 接手一个老项目的单元测试补写工作应该不少朋友都经历过。我上周就遇到一个特别头疼的场景被测的支付Service里构造函数直接new了一个远程配置中心的Client而这个Client在测试环境里如果真去连配置中心要么超时要么抛异常更麻烦的是这个Client的获取配置接口还是final方法用Mockito默认根本mock不了翻出mockito-inline又发现它对这个胖客户端类的代理也经常抽风。就在我准备放弃单测、改用集成测试糊弄过去的时候同事扔给我一个库——TestableMock。我花了两天时间把它用在了三个服务里今天干脆把完整的上手过程、核心原理和踩坑经验整理出来给正被Mockito搞到头疼的人一条更顺的路。这个库适合谁写给那些在Java项目里写单元测试、被静态方法/私有方法/构造函数的mock折腾得不行的人也适合维护老项目、经常要补测试的朋友。它需要的技术基础不高Maven或者Gradle项目、JUnit 4或JUnit 5、Java 8以上就够了。下面从为什么选它开始讲。1. 为什么我最终选择了TestableMock——传统Mock方案的痛点先说个大家都能共鸣的问题Mockito虽然好用但它能mock的范围其实非常有限。Mockito的原理是基于JDK动态代理或者CGLIB生成代理子类然后替换掉被测对象里的依赖实例。这个替换实例的思路决定了它有几个硬伤——final方法mock不了、静态方法mock不了、私有方法mock不了、构造方法mock不了。虽然后面mockito-inline引入了inline mock maker用字节码插桩的方式支持了静态方法但实测下来对复杂类的稳定性和性能都不太理想尤其在一些老项目里经常会出现刚mock完一个静态方法另一个静态方法又炸了的连锁反应。相比之下TestableMock走的是另一条路。它不替换实例而是在JVM启动时挂载一个Java Agent等目标类字节码被加载进JVM时直接用字节码操作工具扫描类里面所有方法调用的指令把调用被Mock方法的字节码指令重定向到Mock方法上。打一个比方Mockito是站在超市门口把你想买的商品换成假货TestableMock则更狠直接进到生产车间把商品生产线上原材料的管道给你改了。所以它对静态方法、私有方法、构造方法都能生效因为这些在字节码层面都只是invokestatic、invokespecial这样的指令没有什么静态/私有/构造的区别。还有一个老牌选手PowerMock。PowerMock也是字节码路线但它的问题是侵入性强、和各种框架的兼容性断裂感明显尤其是配合新版JUnit 5的时候动不动就报一个Mockito cannot mock this class之类的错误排查起来头皮发麻。TestableMock在JUnit 4和JUnit 5下的适配都很顺而且用起来比PowerMock更符合直觉不需要准备一堆全局配置。三种方案的对比我做了个表格能力维度MockitoPowerMockTestableMockmock实例方法支持final除外支持支持mock静态方法需mockito-inline不稳定支持支持稳定mock私有方法不支持支持支持mock构造方法不支持支持支持mock任意类含JDK类不支持部分支持支持JUnit 5兼容性好略麻烦好使用复杂度低中低所以我最终在项目里全面切到TestableMock不是因为它比Mockito更高级而是它正好解决了老代码里最让人抓狂的那几个痛点静态时间函数、私有校验逻辑、new出来的内部依赖。这几个场景在现实中太常见了尤其是老项目没有依赖注入、到处是static util和私有方法Mockito在这些代码面前基本等于摆设。2. 环境准备三分钟跑通第一个Demo2.1 引入依赖与配置Surefire插件TestableMock的使用分两步第一步是添加Maven依赖第二步是给测试运行挂上Java Agent。只配置依赖不配Agent会在运行时直接调用真实方法测试失败而且错误信息不太直观所以这两个必须同时配好。以Maven为例推荐版本统一用0.7.9后面升级就看官方仓库的最新版了properties testable.version0.7.9/testable.version /properties dependency groupIdcom.alibaba.testable/groupId artifactIdtestable-all/artifactId version${testable.version}/version scopetest/scope /dependency然后在maven-surefire-plugin里挂上Agentbuild plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version2.22.2/version configuration argLine-javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar/argLine /configuration /plugin /plugins /build这里有个小细节${settings.localRepository}是Maven本地仓库的路径IDEA里运行时这个参数能被正常解析。但如果你在命令行里单独跑mvn test或者是用Gradle项目处理方式会不太一样。Gradle的配置方式如下test { jvmArgs -javaagent:${configurations.testRuntimeClasspath.find { it.name.contains(testable-agent) }.path} }为什么必须挂Agent因为TestableMock的字节码增强发生在类加载阶段它需要在类被加载进JVM之前就拿到改写机会。Java Agent的premain机制正好干这件事——它在main函数执行前启动在后续每个类被定义时都会收到通知TestableMock在这个钩子里完成对目标方法的调用重定向。不挂Agent就等于手术准备好了但医生没进手术室一切都白搭。2.2 从零写一个最简单的Mock环境配好后跑通一个最小Demo就非常快了。假设我们有这么一个被测类public class DemoService { public String hello(String name) { return new InnerService().greet(name); } static class InnerService { public String greet(String name) { return Hello, name; } } }现在在测试里我不想让hello(test)真的去调用InnerService.greet而是想让它返回我指定的内容。先写测试类import com.alibaba.testable.core.annotation.MockWith; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; MockWith(DemoServiceMock.class) public class DemoServiceTest { Test void shouldMockInnerMethod() { DemoService demoService new DemoService(); assertEquals(mock_test, demoService.hello(test)); } }再写Mock容器类import com.alibaba.testable.core.annotation.MockMethod; public class DemoServiceMock { MockMethod(targetClass DemoService.InnerService.class, targetMethod greet) private String mockGreet(String name) { return mock_ name; } }跑一下测试就过了。这个例子的关键是MockWith(DemoServiceMock.class)把测试类和Mock容器绑定然后DemoServiceMock里的MockMethod告诉框架当DemoService内部调用InnerService.greet时不要真的执行改用mockGreet这个方法。2.3 IDEA运行配置的小坑Maven配置解决了命令行构建的问题但在IDEA里直接右键跑测试时IDEA默认不会读取pom.xml里的argLine配置所以很多人会出现命令行mvn test能过IDEA里跑就挂的诡异现象。这个问题的根源就是IDEA的JUnit运行器没有挂上Java Agent。解决办法有两个。第一个安装官方IDEA插件插件市场搜索TestableMock就行。装完插件后IDEA会在Run Configurations里自动加上Agent参数基本算无感。第二个手动在IDEA的运行配置里的VM options加上-javaagent参数路径写本地仓库jar包的完整路径。我自己的习惯是装插件因为项目里刚好几个人一起维护大家环境不太一样插件能省掉很多解释成本。3. 核心注解的底层逻辑与正确打开方式3.1 MockWith告诉框架把Mock用在哪MockWith是整个TestableMock的入口标注在测试类上括号里传的是Mock容器类的Class对象。框架在读测试类时会先看这个注解然后去指定的Mock容器类里扫描所有MockMethod和MockConstructor标注的方法建立哪个类的哪个方法应该被重定向到哪个方法的映射表。这里有一个容易忽略的细节MockWith生效的范围是整个测试类也就是说Mock容器里有多个Mock方法时它们并不是只对某一个测试方法生效而是对这个测试类下的所有测试方法生效。虽然这看起来有点全局污染但TestableMock本身允许在Mock方法内部对入参做判断所以完全可以通过参数匹配去控制Mock的返回值后面我会专门讲。另外MockWith还有一个mockPrivate属性默认是true表示被测代码里的私有方法也会被扫描。如果把它设置成false私有方法就保持原样。这个属性容易被忽视但它在性能敏感的大项目里有一定作用——因为扫描范围缩小了类加载时的开销也会降低但我个人建议保持默认真到性能瓶颈再优化。3.2 MockMethod同名开箱即用异名靠targetMethodMockMethod是使用频率最高的注解它有两个最核心的属性targetClass指定要Mock的目标类targetMethod指定要Mock的目标方法名。它的匹配逻辑是如果Mock方法自身的方法名和目标方法名一致那么targetMethod可以省略。比如上面例子里的mockGreet与目标方法greet不同名就必须显式写targetMethod greet如果我把Mock方法直接命名为greet那么MockMethod(targetClass DemoService.InnerService.class)就够了。很多刚接触的朋友会在这上面迷惑为什么Mock方法的名字既要当方法名又要当匹配标识我的理解是TestableMock的设计初衷是尽量少写配置通过同名即匹配的约定来减少冗余。但这也带来一个隐患——当Mock容器里有多个同名重载方法时框架是按参数类型来区分的。也就是说如果目标有两个重载的greet一个接收String一个接收int你可以在Mock容器里写两个同名的Mock方法一个参数是String一个参数是int框架会自动匹配。还有一点必须强调Mock方法的方法签名参数列表和返回类型必须与被Mock方法完全一致。签名不匹配时框架不会在编译期报错而是在运行期找不到对应的方法然后抛一个类似No method matches [类名.方法名] in mock class的异常。遇到这种问题第一反应就应该是去核对签名。至于Mock方法本身的作用域修饰符官方推荐写private。因为Mock容器类本身不是对外暴露的组件用private可以避免被其他地方误调用同时也不影响框架通过反射去调用它。实际上它也可以是public或static尤其是Mock静态方法的时候很多人习惯在Mock方法上也加static这个没有硬性规定效果一样。3.3 MockConstructor改造构造函数MockConstructor专门用于Mock构造函数也就是把被测代码里的new Xxx(...)调用拦截下来换成Mock方法里的逻辑。它的核心场景是被测方法内部直接new了一个外部依赖对象而你又没法通过依赖注入把Mock对象塞进去。一个典型例子public class ReportService { public Report createReport(String type) { Report report new Report(type); report.init(); return report; } }这里的new Report(type)如果内部逻辑特别重比如加载模板文件测试时就不想真的执行。用MockConstructor写一个Mockpublic class ReportServiceMock { MockConstructor(targetClass Report.class) private Report mockConstructor(String type) { return new Report(mock_ type); } }然后测试类里MockWith(ReportServiceMock.class)被测代码里所有new Report(type)都会被换成mockConstructor(type)的返回值。这个能力是Mockito和Mockito-inline怎么都做不到的也是TestableMock评价最高的一点。4. 实测静态方法、私有方法与构造方法Mock4.1 静态方法Mock固定时间、绕过外部查询静态方法是单元测试里最常见也最烦人的拦截对象尤其是System.currentTimeMillis()和UUID.randomUUID()这类时间/随机数方法。想验证一个持久化逻辑里保存的时间戳是否准确传统思路只能是给系统类做点文章但在TestableMock里就是一行Mock的事public class TimeMock { MockMethod(targetClass System.class, targetMethod currentTimeMillis) private static long mockCurrentTimeMillis() { return 1000L; } }这就能把所有被测代码里的System.currentTimeMillis()固定成1000。为什么这么简单因为它直接改写了System.currentTimeMillis()调用指令不需要借助任何代理或继承机制。只要是字节码层面发出的invokestatic指令它都能拦。另一个常见场景是调用外部系统的静态查询方法public class AccountService { public static long queryBalance() { // 真实逻辑会走远程RPC return remoteClient.query(); } }在测试时我们不想走RPC就想要一个固定余额。在Mock容器里写MockMethod(targetClass AccountService.class, targetMethod queryBalance) private static long mockQueryBalance() { return 888L; }这样任何地方调用AccountService.queryBalance()都会返回888。注意这里Mock方法也声明成了static因为被Mock的目标是静态方法保持静态更直观但并非强制。4.2 私有方法Mock测公共方法时绕开私有逻辑私有方法Mock的价值在于当你测一个公共方法时它内部会调用若干私有方法其中某个私有方法可能有副作用发短信、调接口、写文件。你真正想测的是公共方法的业务分支而不是私有方法里的外部依赖。传统反射方案只能调用私有方法并不能替换私有方法TestableMock做到了。拿支付场景举例public class PaymentService { public boolean pay(long amount) { if (!checkBalance(amount)) { return false; } return executePay(amount); } private boolean checkBalance(long amount) { // 查询真实账户余额 return AccountService.queryBalance() amount; } }如果checkBalance真的去查账户那测试里支付永远不会成功。现在Mock它MockMethod(targetClass PaymentService.class, targetMethod checkBalance) private boolean mockCheckBalance(long amount) { return amount 1000; }测试里把pay(500)会走正常流程pay(2000)会走余额不足分支。这样我们既没有真正访问账户系统又能把公共方法的两个分支都覆盖到。这里要特别提醒一点私有方法Mock同样要求签名完全一致。尤其是参数类型比如long和Long看着像但不是一回事签名不匹配时框架会静默跳过测试结果还是走真实逻辑非常容易造成Mock了但又没完全Mock的错觉。4.3 构造方法Mock工厂模式下的最强武器构造函数Mock对工厂模式或者服务定位器模式的老代码简直是救命稻草。我遇到过一个场景被测代码里有这么一段public class OrderService { public void createOrder(OrderDTO dto) { Order order new Order(dto.getUserId()); // 后续一堆业务逻辑 } }这个Order构造函数里会自动从数据库加载用户的默认地址测试时一new就报连接失败。用MockConstructor处理public class OrderServiceMock { MockConstructor(targetClass Order.class) private Order mockOrder(Long userId) { Order order new Order(); // 走无参构造函数或直接反射设置属性 order.setUserId(userId); order.setDefaultAddress(mock-address); return order; } }注意这个mockOrder方法内部的new Order()不会和MockConstructor形成无限递归因为Mock是精确到类构造函数签名的。无参构造函数没有被Mock所以走的是真实逻辑。如果被Mock的是无参构造函数Mock方法本身又去new该类的另一个参数构造只要避免自调用就没有问题。5. 进阶玩法参数匹配、调用次数与Spring Boot集成5.1 参数匹配按输入控制Mock输出TestableMock的参数控制分两层。第一层是框架层面的签名匹配即用不同的参数类型组合去匹配不同的重载目标第二层是Mock方法体内的逻辑判断即根据入参的具体值来决定返回什么。签名匹配的示例public class Calculator { public int add(int a, int b) { return a b; } public int add(int a, int b, int c) { return a b c; } }Mock容器里可以定义两个方法MockMethod(targetClass Calculator.class, targetMethod add) private int mockAdd(int a, int b) { return 100; } MockMethod(targetClass Calculator.class, targetMethod add) private int mockAdd(int a, int b, int c) { return 200; }框架会根据参数类型自动匹配到正确的Mock方法。如果要在同一个方法签名下按值区分那就直接写在方法体里MockMethod(targetClass Calculator.class, targetMethod add) private int mockAdd(int a, int b) { if (a 1 b 2) { return 100; } return -1; }这其实是单元测试里最常用的手法用一个可控的Mock方法在多个测试用例里分别验证不同分支。5.2 验证调用次数不用Mockito.verify也能断言Mockito里verify(mock).someMethod()可以验证某个方法有没有被调用、调用了多少次。TestableMock没有直接提供verify API但它的解法更加朴素——在Mock方法里放一个计数器。因为Mock方法本身就是真实被调用到的代码调用次数一目了然public class DemoMock { public static int helloInvokeCount 0; MockMethod(targetClass Demo.class, targetMethod hello) private String mockHello(String name) { helloInvokeCount; return mock; } }测试里断言assertEquals(1, DemoMock.helloInvokeCount);这里有几个注意事项。第一测试类可能会被JUnit复用尤其是JUnit 5默认每个测试方法一个实例但Mock容器类中的静态字段仍然跨测试方法共享所以建议在BeforeEach里重置计数器否则多个测试方法的计数会叠加。第二计数器字段建议用public static否则测试类访问不到。第三如果想区分不同参数路径的调用次数可以在Mock方法里对入参做判断后再累加。这个方案虽然比Mockito的verify啰嗦一点但它能cover 99%的调用验证场景而且完全可控、零依赖。5.3 在Spring Boot测试中的集成姿势TestableMock官方定位是不依赖Spring容器也能跑测试所以在纯JUnit环境下表现最好。但现实中不少同事还是习惯用SpringBootTest带容器跑TestableMock在Spring Boot项目里也完全可用只是要注意方式。我的推荐是能不用容器就不启动容器。因为TestableMock最强的地方就是它可以绕过Spring的Bean装配直接测业务类只要被测类能new出来内部依赖全部用Mock解决。比如MockWith(OrderServiceMock.class) public class OrderServiceTest { Test void shouldCreateOrder() { OrderService orderService new OrderService(new UserService()); // 这里UserService内部调用的外部依赖已经被Mock掉了 orderService.createOrder(dto); // 断言 } }如果确实需要Spring容器比如要验证Transactional、MyBatis Mapper等那就用SpringBootTest TestableMock的组合。这种场景下容器正常启动注册所有BeanTestableMock只负责Mock其中某些方法。有个地方要特别小心如果被测对象是经过Spring AOP代理的比如被Transactional或Async修饰外部方法调用走的是代理对象但代理对象内部的方法体仍然来自原始类所以TestableMock对原始类的内部调用仍然有效。简而言之Spring代理不影响TestableMock对方法体内调用的mock但如果你试图mock代理类本身的方法比如service的公共方法targetClass应该写原始类而不是代理类。写代理类CGLIB生成的子类是常见的坑后面会专门讲。6. 踩坑实录Mock同名的意外、代理对象问题与调试技巧6.1 同名方法被误Mock的全过程遇到过这样一个问题被测类OrderService里有两个方法都叫query一个是query(String orderNo)一个是query(String orderNo, String userId)它们分别加载订单和加载用户订单。我在Mock容器里写了一个方法名也叫query的Mock本意只想Mock那个单参数版本结果运行时发现两个query的行为都变了。排查过程是这样的我先打开MockDiagnose诊断日志看到框架输出里显示两个重载方法都被重定向到我写的Mock方法。原因立刻清晰了——在MockMethod没指定targetMethod的情况下框架默认按Mock方法名去匹配而重载的方法名相同所以两个版本都被认为是目标方法。解决办法有两个要么把Mock方法拆成两个不同名的分别用targetMethod显式指定目标重载要么给Mock方法定义出完全相同参数列表的两个重载让签名区分它们。我在这个案例里用的是第一种因为两个重载需要返回不同的Mock数据拆开命名更清晰。这个坑说明一个道理同名约定虽然方便但前提是明确你的目标方法在类里是否唯一。如果目标类里存在重载方法最好老老实实写targetMethod别依赖同名匹配。6.2 继承和接口场景下的targetClass选择TestableMock的Mock映射是基于调用指令所在的地方来计算的。在继承场景下如果基类定义了一个方法子类继承后并没有重写那么子类实例调用这个方法时实际调用的是基类里的方法体。这种情况下targetClass要写基类而不是子类。比如public class BaseService { public String getName() { return base; } } public class ChildService extends BaseService { public String format() { return [ getName() ]; } }如果我们想让ChildService.format()里调用的getName()返回mocktargetClass必须写BaseService.class因为实际的方法体定义在BaseService里。写了ChildService.class不会生效因为JVM在解析getName()调用时最终绑定的是BaseService.getName()。接口场景更需要注意。JDK动态代理生成的类叫类似$Proxy0它和原始接口不是同一个类。如果被测代码接收的是接口引用但实际运行用的是实现类那targetClass应写实现类而不是接口。比如UserService userService new UserServiceImpl();后来调用userService.getUser()时要Mock的目标是UserServiceImpl里的方法targetClass写UserServiceImpl.class。6.3 开启诊断日志定位问题TestableMock给了个小而美的调试开关MockDiagnose加在测试类上即可MockWith(DemoMock.class) MockDiagnose(enableLog true) public class DemoTest { // ... }打开后测试运行时会输出所有被成功Mock的方法列表包括目标类、目标方法、Mock方法对应关系。当遇到Mock为什么没生效的时候第一步就是看日志里有没有输出这条映射关系。如果日志里没有说明匹配条件没满足按前面说的排查签名、targetClass、targetMethod即可如果日志里有但测试还是走了真实逻辑那多半是方法调用发生在另一个没被扫描到的类里。我在一次排查中遇到过更隐蔽的问题被测代码内部通过反射调用了目标方法测试中Mock日志显示一切正常但实际执行时反射调用还是执行了真实逻辑。原因也好理解——TestableMock改写的是字节码层面的直接调用指令而反射调用绕过了这个指令层。所以如果你用的是反射或者MethodHandleTestableMock是有边界的这种情况下建议换用真实对象逻辑或者Mock外层调用。另外诊断日志在排查Agent没有挂载的问题时也很管用。如果打开日志后控制台完全没有输出基本可以确定是Agent没配上回到环境准备那一步去查IDEA或Maven的配置就行了。最后再说点实际体会。TestableMock真正让我觉得靠谱的地方不是某个单独的Mock能力而是它把手术级的字节码改写封装成了几个语义明确的注解使用时不需要懂字节码也不需要在测试类里铺一大把when().thenReturn()。我建议刚上手的同事都从一个最简单的场景开始比如先Mock一个静态时间方法感受一下改指令和换对象的差异再慢慢扩展到构造方法Mock和Spring Boot集成。一个小技巧Mock容器类可以共用。如果多个测试类都依赖同一个外部静态方法我可以写一个公共的BaseMock然后多个测试类通过MockWith(BaseMock.class)复用。这样公共的Mock逻辑只需要维护一份改起来也方便。不过要注意不同测试类如果对同一个目标方法需要不同的Mock返回值那还是各自建独立的Mock容器避免互相影响。这个库后续还能继续扩展的地方也挺多比如在链路追踪、幂等控制、多环境模拟这类场景里它都可以作为测试工具链的一个重要补充。反正自从用了它我的单测代码里Mockito的占比已经大幅下降老的PowerMock更是一个新项目都没再碰过。
返回列表