1. 从Mock谈起为什么单元测试越写越“假”1.1 Mock到底是干什么的常用工具有哪些先说结论Mock不是造假不是把测试数据写死而是把“当前测试不关心的外部依赖”用可控的替身替换掉让被测代码只围绕自身逻辑运行。比如你测一个订单服务它内部会调用户服务拿用户信息调支付客户端发起扣款调缓存服务读写数据。这些依赖要么没准备好、要么有网络和环境问题真正测试时不可能每次都连真实环境。这时候就需要Mock把用户服务、支付客户端、缓存服务全部替换成“我说了算”的替身想让它返回什么就返回什么想让它抛异常就抛异常。我见过不少团队把Mock工具用错场景有人用Fiddler抓包改响应来做前端Mock发现响应数据不生效多半是请求没走代理、缓存没清或HTTPS证书没装那个层面的Mock属于“代理层劫持”和开发自测的边界其实很模糊。前端现在也流行用MSW这类库在浏览器和Node环境里拦截请求做MockVue3TS项目里也有不少配套方案。但这些都是“接口层”或者“HTTP层”的Mock管不着Java代码里方法之间的调用关系。而在Java单元测试里我们更常说的是Mockito、PowerMock以及今天要聊的TestableMock。它们工作在“字节码层”或者“对象层”直接控制某个方法被调用时的行为。1.2 Mockito和PowerMock解决不了的三个痛点Mockito是很成熟的方案但它有个天然边界它通过创建子类或动态代理来替换对象行为所以它能Mock普通实例方法却搞不定静态方法、构造方法、私有方法也搞不定那种“在方法内部直接new出来的对象”。你可能会说不是有mockito-inline和PowerMock吗对但用起来确实让人头大。先看静态方法。Mockito本身不支持必须加mockito-inline扩展用了inline之后对JDK版本还很敏感PowerMock能Mock静态方法但你必须写PrepareForTest这个注解会污染每一个测试类而且PowerMock和Jacoco覆盖率插件有时候会互相打架挂两个Java Agent的顺序错了直接报各种诡异异常。再看构造方法。如果你要Mock的是被测类内部new RedisClient()出来的对象用Mockito几乎要重构代码要么把RedisClient改成从工厂拿要么改成构造器注入要么引入PowerMock的whenNew。为了测试去改生产代码结构成本不低还容易引发同事的“代码洁癖”争议。最后是私有方法。Mockito倒是能通过ReflectionTestUtils之类的反射工具间接访问私有成员但代码写起来很丑只能“绕”不能“改”。PowerMock能改但每次都要写PrepareForTest和PowerMockRunner测试类瞬间变得又重又慢。1.3 TestableMock是什么解决什么问题我第一次接触TestableMock是在一个老项目里做单元测试覆盖率冲刺那个项目里有大量静态工具类、内部new出来的第三方客户端、还有一堆私有方法。用Mockito写测试写到怀疑人生后来团队里有人扔过来一个TestableMock的空依赖和两段示例代码我试完之后的第一反应是这东西怎么不早点拿出来。TestableMock的核心思路很粗暴也很优雅既然Java编译器不让你改字节码那就用Java Agent在类加载的时候直接改。它会在JVM加载类的阶段对字节码做增强把特定方法调用替换成测试类里定义的Mock方法调用。所以你不需要Spring容器、不需要PowerMockRunner、不需要PrepareForTest只需要在测试类里写一个普通方法加上一个注解这个方法就会“接管”被Mock方法的调用。它能Mock的东西包括静态方法、构造方法、私有方法、final方法以及被测类内部new出来的对象的方法。这些恰好是Mockito和PowerMock最费劲的“三座大山”。更关键的是它不需要启动Spring上下文测试运行速度比SpringBootTest那一套快一个数量级。对于存量老项目和大量遗留代码TestableMock几乎是改造性价比最高的Mock工具。2. 五分钟完成环境接入依赖、Agent、第一个Mock方法2.1 引入依赖testable-all和Java Agent配置使用TestableMock第一步和用别的测试库不太一样它需要在Maven的surefire插件里挂一个Java Agent这一步很重要别漏。properties testable.version0.7.9/testable.version /properties dependency groupIdcom.alibaba.testable/groupId artifactIdtestable-all/artifactId version${testable.version}/version scopetest/scope /dependency plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration argLine-javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar/argLine /configuration /plugin配置里的${settings.localRepository}是Maven本地仓库路径不要手动填成绝对路径否则换台机器就崩了。如果你用的IDE集成了Maven本地仓库路径一般都在~/.m2/repository下直接让占位符自动解析最省心。为什么必须挂这个Agent因为TestableMock不是靠继承、不是靠动态代理它是在JVM加载类的时候通过Instrumentation API改写字节码。测试类里那个带MockMethod注解的方法会被Agent“注入”到被Mock类的方法调用点上从而接管调用。Agent没挂上注解类虽然也能编译但运行时完全不会生效这是很多新手踩的第一个坑。另外注意如果你项目里同时用了Jacoco它也是一个Java Agent。surefire的argLine里如果既有Jacoco的agent又有TestableMock的agent顺序不同可能导致Mock不生效或覆盖率采集异常。这个问题我放在后面第五部分详细说这里先记住TestableMock的agent必须存在而且和Jacoco共存时要选对顺序。2.2 第一个Mock方法从实例方法开始先看一个最简单的例子。假设有个用户服务public class UserService { public User findUser(Long userId) { // 真实实现里可能查数据库、调远程接口这里省略 return new User(userId, real-name); } }你的被测类OrderService里调用了它public class OrderService { private final UserService userService; public OrderService(UserService userService) { this.userService userService; } public User getOrderOwner(Long orderId, Long userId) { User user userService.findUser(userId); user.setRemark(order: orderId); return user; } }用TestableMock写测试核心就是三步在测试类里写一个与目标方法同名的方法、方法参数第一位增加一个目标对象类型的参数、方法体里使用expectRun返回想要的Mock结果。import static com.alibaba.testable.core.tool.TestableTool.expectRun; public class OrderServiceTest { private OrderService orderService; BeforeEach void setUp() { orderService new OrderService(new UserService()); } MockMethod(targetClass UserService.class) private User findUser(UserService self, Long userId) { return expectRun(() - { User user new User(userId, mock-name); return user; }); } Test void should_get_mock_user_and_set_remark() { User owner orderService.getOrderOwner(1001L, 88L); assertEquals(mock-name, owner.getName()); assertEquals(order:1001, owner.getRemark()); } }有几个细节要掰开讲。Mock方法名默认和目标方法名一致所以叫findUser如果不一致需要在MockMethod里加targetMethod findUser来指定。Mock方法参数比目标方法多一个参数这个参数类型是目标类用来接收“这次调用是被哪个对象发起的”我习惯把它命名为self官方推荐放在参数列表最前面。被测代码里userService.findUser(userId)被Mock后真正执行的是测试类里这个findUser方法self就是userService实例userId就是88。顺手看一下expectRun它是TestableMock里最常用的返回控制工具作用是在Mock方法体内声明“本次Mock方法的返回逻辑”。它接收一个ReturnChecker在里面可以写断言校验参数也可以返回一个值作为Mock调用的结果。上面的写法就是每次调用返回一个new出来的User对象。2.3 不同参数返回不同结果用TestableTool做更细的控制简单场景一个expectRun就够了但有时你希望根据入参不同返回不同结果。比如findUser方法userId为88时返回VIP用户userId为99时返回普通用户其余情况返回null。这种可以写MockMethod(targetClass UserService.class) private User findUser(UserService self, Long userId) { if (userId 88L) { return expectRun(() - new User(88L, vip-user)); } else if (userId 99L) { return expectRun(() - new User(99L, normal-user)); } return null; }expectRun里的代码就是Mock方法的执行体所以你完全可以写if分支来做参数路由。比较两次调用分别返回不同值也很自然在Mock方法外面放一个计数器第一次返回A第二次返回B。private int callCount 0; MockMethod(targetClass UserService.class) private User findUser(UserService self, Long userId) { callCount; if (callCount 1) { return expectRun(() - new User(userId, first)); } return expectRun(() - new User(userId, second)); }这种写法比PowerMock那种静态声明式的mock更直观因为它本质就是在写普通Java方法。2.4 方法被Mock后原方法真的不执行吗会被执行吗不会。只要Agent成功改写了字节码目标方法的方法体对当前测试来说就等于被替换成了Mock方法。这一点和Mockito的when(...).thenReturn(...)是一样的但TestableMock不需要你先创建mock实例也不需要对被测对象做依赖注入。被测代码里哪怕写死了new UserService()再调findUser只要UserService类的findUser被Mock了依然会走Mock方法。对这就是TestableMock和Mockito最大的不同之一Mockito只能替换“对象引用指向的实例”TestableMock是直接替换“类的某个方法”所以即使对象是被测代码内部new出来的也能被拦截。这个能力在存量代码里太有用了。3. 静态方法、构造方法、私有方法核心玩法逐个拆解3.1 Mock静态方法工具类不再是阻碍老项目里最不缺的就是各种静态工具类尤其是那种StringUtils、DateUtils、ConfigHolder、SecurityUtil测一个方法能牵扯出一堆静态调用。Mockito遇到这种情况基本就废了而TestableMock处理起来和Mock实例方法几乎一样。public class TradeNoGenerator { public static String generate(Long userId) { // 真实实现可能依赖雪花算法、Redis自增、数据库序列 return real-trade-no; } }被测类OrderService里这样用public String createTradeNo(Long userId) { return TradeNoGenerator.generate(userId); }测试类里直接MockMockMethod(targetClass TradeNoGenerator.class) private String generate(ClassTradeNoGenerator self, Long userId) { return expectRun(() - mock-trade-no- userId); }这里Mock方法的第一位参数类型是ClassTradeNoGenerator因为静态方法没有实例对象接收到的是目标类本身。这个细节容易记错我第一次写的时候用了TradeNoGenerator self编译报错之后才想起来静态方法应该接收Class类型。Mock静态方法最大的价值在于不需要把工具类改成接口、不需要引入ServiceLocator、不需要做依赖注入生产代码一行都不用动。对于那种几百个类都依赖同一个静态配置类的老项目这几乎是唯一能低成本把单元测试跑起来的方案。3.2 Mock构造方法绕开new出来的黑盒被测代码内部直接new一个第三方客户端这种结构在代码评审里经常被诟病但在存量项目里就是存在而且你还不能为了测试去大改。TestableMock对这种情况支持得很好它可以直接Mock构造函数。public class PaymentClient { public PaymentClient(String endpoint) { // 真实构造可能建立连接、加载密钥、初始化线程池 } public PayResult pay(Long userId, Long amount) { // 真实发起支付 return new PayResult(false, real); } }被测类public class OrderService { public PayResult pay(Long userId, Long amount) { PaymentClient client new PaymentClient(http://pay.internal); return client.pay(userId, amount); } }想Mock掉PaymentClient的构造函数在测试类里定义一个与构造器参数列表一致的方法加上MockConstructor注解返回类型是目标类MockConstructor(targetClass PaymentClient.class) private PaymentClient newPaymentClient(String endpoint) { return expectRun(() - new PaymentClient(mock-endpoint)); }这样被测代码里执行new PaymentClient(http://pay.internal)时实际返回的是Mock方法里new出来的那个PaymentClient实例。注意Mock方法里虽然写了new PaymentClient(mock-endpoint)但这是“Mock的Mock”不会再进入真实构造器而是在字节码层面直接返回了一个对象所以不用担心真连上什么远程地址。构造方法被Mock后这个对象上后续所有方法调用依然可以被单独Mock比如再Mock掉pay方法。这样一来一个内部new出来的第三方对象就完全在测试掌控之中了。3.3 Mock私有方法与访问私有成员私有方法Mock是PowerMock的招牌能力TestableMock同样支持。在测试类里写一个与私有方法同名的方法用MockPrivate标注就可以替换被测类内部对私有方法的调用。public class OrderService { private boolean isVip(Long userId) { // 可能查VIP表这里省略 return false; } public String getVipGift(Long userId) { return isVip(userId) ? gift-box : no-gift; } }Mock私有方法MockPrivate(targetClass OrderService.class) private boolean isVip(OrderService self, Long userId) { return expectRun(() - true); }这里重点说一句MockPrivate的targetClass写的是被测类自身因为你要替换的是被测类内部的一个私有方法所以Mock方法实际上接收的目标对象也是被测类实例。这个和外部依赖的Mock略有不同别搞反了。TestableMock还支持免反射访问被测类的私有字段和私有方法官方文档有专门说明适合改造那种私有状态特别多、又不想引入反射的类。不过这块我用得不多遇到私有字段处理我还是更倾向暴露必要的包级私有构造器或者用构造器注入来重构毕竟测试代码也是要长期维护的太魔法的东西会影响可读性。3.4 不启动Spring容器直接测Controller与ServiceSpring Boot项目里最常见的坏习惯是不管测什么都上SpringBootTest一个测试方法启动完整上下文启动一次十几秒跑一个类几十秒。而TestableMock的思路是被测类能new就new依赖能用Mock就用Mock完全不需要Spring容器。看一个Controller的例子RestController public class OrderController { Autowired private OrderService orderService; GetMapping(/order/{orderId}/owner/{userId}) public User owner(PathVariable Long orderId, PathVariable Long userId) { return orderService.getOrderOwner(orderId, userId); } }测试类里可以不启动Spring直接newpublic class OrderControllerTest { private OrderController controller; BeforeEach void setUp() { controller new OrderController(); controller.setOrderService(new OrderService(new UserService())); } MockMethod(targetClass UserService.class) private User findUser(UserService self, Long userId) { return expectRun(() - new User(userId, mock-user)); } Test void should_return_mock_owner() { User owner controller.owner(1001L, 88L); assertNotNull(owner); } }这里前提是Controller里用的是setter注入能手动set进去。如果是字段注入且没有setter就稍微麻烦一点可以靠反射或者给测试类加个可见的同包构造器。我在实际项目中会优先把字段注入改成构造器注入这不是为了配合Mock工具而是这种代码本身更好测试、更容易看清依赖关系。TestableMock的价值在于即使你还没重构完也能先通过Mock和Java Agent能力把测试跑起来不会因为“依赖长在字段上”就彻底卡死。4. 实战给订单服务写一份不启动Spring的测试4.1 被测代码结构我把前面提到的点串成一个综合案例。假设有一个订单服务它依赖用户服务查用户、依赖缓存服务读写缓存、还依赖一个内部new出来的支付客户端发起支付。这个结构非常像我在改造的老项目真实状态。public class OrderService { private final UserService userService; private final CacheService cacheService; public OrderService(UserService userService, CacheService cacheService) { this.userService userService; this.cacheService cacheService; } public User getOrderOwner(Long orderId, Long userId) { String cacheKey order: orderId; Object cached cacheService.get(cacheKey); if (cached ! null) { return (User) cached; } User user userService.findUser(userId); cacheService.put(cacheKey, user); return user; } public PayResult pay(Long orderId, Long userId, Long amount) { PaymentClient client new PaymentClient(http://pay.internal); return client.pay(orderId, userId, amount); } }想完整测这两个方法传统Mockito方案里getOrderOwner要Mock cacheService和userServicepay要Mock PaymentClient的构造函数和pay方法后者基本只能上PowerMock。而用TestableMock一个测试类全部搞定。4.2 编写Mock类集中管理当Mock方法比较多、或者一个测试类里要Mock很多外部依赖时可以用MockWith把Mock方法集中到一个独立的类里保持测试类本身干净。这是官方推荐的做法之一我后来很多项目都这么干。public class OrderServiceMock { MockMethod(targetClass CacheService.class) private Object get(CacheService self, Object key) { return expectRun(() - null); } MockMethod(targetClass CacheService.class) private void put(CacheService self, Object key, Object value) { expectRun(() - null); } MockMethod(targetClass UserService.class) private User findUser(UserService self, Long userId) { return expectRun(() - new User(userId, mock-name)); } MockConstructor(targetClass PaymentClient.class) private PaymentClient newPaymentClient(String endpoint) { return expectRun(() - new PaymentClient(mock)); } MockMethod(targetClass PaymentClient.class) private PayResult pay(PaymentClient self, Long orderId, Long userId, Long amount) { return expectRun(() - new PayResult(true, mock-success)); } }注意UserService和CacheService都是外部依赖它们的Mock方法参数第一位是实例对象PaymentClient的pay方法类似构造函数Mock的返回类型是目标类PaymentClient。4.3 组装完整测试类测试类上加上MockWith指向这个Mock类被测对象还是手动new出来MockWith(OrderServiceMock.class) public class OrderServiceTest { private OrderService orderService; BeforeEach void setUp() { orderService new OrderService(new UserService(), new CacheService()); } Test void should_return_mock_user_when_cache_is_empty() { User owner orderService.getOrderOwner(1001L, 88L); assertEquals(mock-name, owner.getName()); } Test void should_pay_success_with_mock_payment_client() { PayResult result orderService.pay(1001L, 88L, 100L); assertTrue(result.isSuccess()); } }整个过程没有启动Spring上下文测试两个方法加起来跑完也就几十毫秒。而同样的一套逻辑如果用SpringBootTest启动一次上下文至少几秒钟如果项目里再挂上各种自动配置启动一次十几秒都很正常。这种速度差异在本地开发时感受最明显尤其是改动检测频繁触发测试的时候效率完全不在一个量级。4.4 在IDEA里运行和调试的细节IDEA里直接右键运行这个测试类就行前提是Maven的argLine配置已经让surefire带上了Java Agent。如果你是从IDE里直接跑单个测试方法IDE通常也会读取surefire配置但有些IDEA版本对argLine的解析有问题最常见的现象是mvn test跑得通IDEA里跑就Mock不生效。遇到这种情况可以在IDEA的Run Configuration里给VM options手动加上同样的javaagent参数-javaagent:$HOME/.m2/repository/com/alibaba/testable/testable-agent/0.7.9/testable-agent-0.7.9.jar注意把$HOME替换成实际路径Windows下是用户目录。Debug调试时同样要带这个参数否则断点虽然会命中测试代码但Mock逻辑完全没有被应用很容易误导你觉得“代码执行到这里没按预期”实际上只是Agent没挂载。TestableMock还有一个好处是调试体验好因为Mock方法就是普通Java方法你在Mock方法体里打断点可以清楚看到参数值、分支逻辑、返回值不像动态代理那样黑盒。这对排查“Mock为什么返回了意外结果”非常有帮助。5. 常见问题速查Mock不生效的N种原因与解法5.1 Agent没挂载或挂载顺序不对这是最常见的Mock不生效原因九成以上是这个问题。表现是测试能跑但Mock方法从来没被调用原方法真实执行了甚至可能因为真实依赖连不上而报错。先确认surefire的argLine里有没有Java Agent参数。如果你项目里同时配置了Jacoco的agent还需要注意顺序。我的经验是把TestableMock的agent放在Jacoco前面一般不会出问题。如果两个agent都挂上了还是Mock不生效就检查是不是本地仓库路径解析错了。也可以临时在测试代码里加一个断点查看被测类加载时是否真的被Agent增强过但这个比较麻烦。更快的办法是跑一个最简单的Mock方法确认环境通顺后再往上叠加。5.2 目标方法是private/final/static没选对注解TestableMock默认的MockMethod能处理普通实例方法但遇到静态方法要用Class类型接收目标对象遇到构造函数要用MockConstructor遇到被测类内部的私有方法要用MockPrivate。很多人第一次用的时候把静态方法也按实例方法写方法参数第一位写了实例类型编译直接报错。这个不算Bug是API设计差异写之前想清楚目标方法属于哪一类。还有final方法。TestableMock对final方法的Mock和普通实例方法一样用MockMethod就行。这一点比Mockito友好太多Mockito要Mock final方法得额外配置inline mock maker而且不同版本行为不一致TestableMock在字节码层面处理天然支持。5.3 与PowerMock、Jacoco共存的问题如果你在一个老项目里同时已经用了PowerMock要小心。PowerMock和TestableMock都是字节码增强工具它们同时存在时PowerMock的PrepareForTest反而可能把TestableMock的Mock方法覆盖掉导致TestableMock的Mock不生效。我的建议是新代码统一走TestableMock存量PowerMock用例按模块逐步迁移不要混写在一个测试类里。Jacoco本身是个覆盖率Java Agent和TestableMock的Agent共存时一般只需要注意挂载顺序。有的项目里Jacoco的agent是surefire自动加的TestableMock的agent在argLine里手动写这时候两个参数拼接的顺序受plugin配置影响。实际项目中我见过多次“Jacoco在后TestableMock无效Jacoco在前TestableMock正常”的情况所以通用的做法就是把TestableMock的agent写在前面。5.4 QA速查表现象可能原因解决方向测试运行成功但Mock方法没进入Java Agent未挂载检查surefire argLine确认testable-agent路径IDEA能跑mvn不能跑或相反两处argLine配置不一致统一通过surefire配置或在IDEA VM options手动加编译报Mock方法参数类型不对静态方法写成了实例对象参数静态方法第一位参数类型改为Class构造函数没有被Mock用了MockMethod而不是MockConstructor替换注解确认参数列表与构造器一致与Jacoco同时存在时报类加载异常Agent顺序冲突把TestableMock的agent放在Jacoco之前与PowerMock混用时Mock失效字节码增强互相覆盖同一类里避免混用逐步迁移到TestableMock私有方法Mock不生效Mock方法放错位置或注解不对使用MockPrivatetargetClass指向被测类自身5.5 Mock方法里不能再调用原方法最后提一个容易犯的错不要在Mock方法里写“先调用一下原方法再返回增强结果”这种代码。TestableMock的Mock方法一旦接管目标方法原方法体就不会被调用你在Mock方法里写self.findUser(userId)会再次触发Mock逻辑然后无限递归最终栈溢出。这不算TestableMock的缺陷而是所有Mock工具的通用规则Mock就是替换不要试图在替换逻辑里“顺便再调一下真的”。如果确实需要“先走原逻辑再加工”这种场景应该把原逻辑抽成一个独立的公开方法配合构造器重构来做而不是在Mock方法里绕。6. 个人体会与扩展建议这几年我在几个项目里从零推单元测试Mockito用了很久PowerMock也救过急但TestableMock是我目前用下来对存量代码最友好的工具。它的核心优势不是某个单点功能多强而是把最让人头疼的场景一次性解决了静态方法不用改代码、构造方法不用改代码、私有方法不用改代码、Spring容器不用启动。这四点在老项目里意味着什么经历过的人都懂。如果你正准备在团队里推单元测试我建议不要一开始就追求覆盖率数字先挑一个静态工具类密集的模块用TestableMock把测试基础打好让团队成员体验到“不启动Spring、几毫秒跑完一个测试类”的顺畅感后面推进会快得多。我在实际项目中还做了一个小改造把常见的Mock方法统一收进base测试类每个业务测试类只写自己特有的Mock整体维护成本下降很明显。TestableMock不是银弹它不能帮你解决代码设计本身的问题一个几千行的God类不管用什么Mock工具测起来都痛苦。但如果你的目标是“在不伤筋动骨的前提下让老项目先把测试跑起来”它确实是我目前见过的最顺手的工具。