GroovyMock模拟final类方法在库代码调用返回null的解决方法
问题背景
- 多模块Java项目设置独立测试模块存放全项目单元测试、集成测试,测试模块
src/main/java目录为空,所有测试代码均存放在test目录下。 - 测试异常现象:使用
GroovyMock模拟final类MyClass1的getText()方法时,Groovy测试文件内部直接调用可正常返回预设模拟值;但该方法被其他提前编译完成的依赖模块中MyClass2类调用时,会返回null/空字符串默认值,无法命中预设模拟逻辑。 - 初步怀疑原因:Groovy通过编译期改写实现方法拦截,依赖库中的
MyClass1、MyClass2均为已编译Java字节码,无法被该拦截逻辑处理。
问题复现代码
业务模块(Module1)代码
public final class MyClass1 { private String text; public MyClass1() { text = "Hello there!"; } public String getText() { return text; } } public class MyClass2 { private MyClass1 myObject1; public MyClass2(MyClass1 myObject1) { this.myObject1 = myObject1; } public String toLowerCase() { return myObject1.getText().toLowerCase(); } }
测试代码
def assertTextNotNull(MyClass1 myObject1) { assert myObject1.getText() != null; } def "successful test"() { given: MyClass1 myMock1 = GroovyMock(MyClass1); myMock1.getText() >> "ABC"; assertTextNotNull(myMock1); } def "unsuccessful test"() { given: MyClass1 myMock1 = GroovyMock(MyClass1); // 因待模拟的实际类是final类型,必须使用GroovyMock MyClass2 myObject2 = new MyClass2(myMock1); myMock1.getText() >> "ABC"; when: assertTextNotNull(myMock1); then: myObject2.toLowerCase() == "abc"; // 此处执行失败,原因是myObject1.getText()返回空字符串"" }
测试模块pom配置
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <groupId>myGroupId</groupId> <artifactId>myParentArtifactId</artifactId> <version>1.0-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>myArtifactId-tests</artifactId> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <release>17</release> </configuration> <dependencies> </dependencies> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.0.0-M7</version> <configuration> <excludes> <exclude>**/*IT.*</exclude> <exclude>**/*IntegrationTest.*</exclude> </excludes> <includes> <include>**/*UT.*</include> <include>**/*UnitTest.*</include> <include>**/*UT.groovy</include> </includes> </configuration> </plugin> <plugin> <artifactId>maven-failsafe-plugin</artifactId> <version>3.0.0-M7</version> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> <configuration> <excludes> <exclude>**/*UT.*</exclude> <exclude>**/*UnitTest.*</exclude> </excludes> <includes> <include>**/*IT.*</include> <include>**/*IntegrationTest.*</include> </includes> </configuration> </execution> </executions> </plugin> <plugin> <!-- gmavenplus插件用于编译Groovy代码 --> <groupId>org.codehaus.gmavenplus</groupId> <artifactId>gmavenplus-plugin</artifactId> <version>1.13.1</version> <executions> <execution> <goals> <goal>compile</goal> <goal>compileTests</goal> <goal>addSources</goal> <goal>addTestSources</goal> </goals> </execution> </executions> </plugin> </plugins> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> </resource> </resources> </build> <repositories> <repository> <id>papermc</id> <url>https://repo.papermc.io/repository/maven-public/</url> </repository> </repositories> <dependencies> <!-- Paper/Waterfall依赖 --> <dependency> <groupId>io.papermc.paper</groupId> <artifactId>paper-api</artifactId> <version>1.18.2-R0.1-SNAPSHOT</version> <scope>test</scope> </dependency> <dependency> <groupId>io.github.waterfallmc</groupId> <artifactId>waterfall-api</artifactId> <version>1.18-R0.1-SNAPSHOT</version> <scope>provided</scope> </dependency> <!-- 其他业务模块依赖 --> <dependency> <groupId>myGroupId</groupId> <artifactId>myArtifactId-module1</artifactId> <version>1.0-SNAPSHOT</version> <scope>test</scope> </dependency> <!-- 测试相关依赖 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.8.2</version> <scope>test</scope> </dependency> <!-- 配合CGLIB实现无默认构造函数类的Mock --> <dependency> <groupId>org.objenesis</groupId> <artifactId>objenesis</artifactId> <version>3.1</version> <scope>test</scope> </dependency> <dependency> <groupId>org.spockframework</groupId> <artifactId>spock-core</artifactId> <version>2.2-M1-groovy-4.0</version> <scope>test</scope> </dependency> <dependency> <groupId>cglib</groupId> <artifactId>cglib-nodep</artifactId> <version>3.3.0</version> <scope>test</scope> </dependency> </dependencies> </project>
排查结论
- 复现验证:将示例代码复制到新模块,保留
MyClass1的final修饰符可稳定复现异常;移除final修饰符后测试可正常通过。 - 根因确认:
GroovyMock对final类的方法模拟仅在Groovy代码调用链路中生效,当方法被已编译的第三方库/其他模块Java代码调用时,会直接返回对应类型默认值(引用类型返回null,示例中字符串返回空字符串)。
可行解决方案
方案1:替换Mock框架为支持字节码增强的Mock工具(推荐)
放弃GroovyMock,改用支持类加载期字节码插桩的Mock框架,可无视final修饰符实现全链路Mock:
- 优先选
Mockito-inline(高版本Mockito已将该能力合并入核心包):原生支持Mock final类、静态方法,对Java/Groovy调用链路完全生效,和Spock 2.x版本兼容度很高。当前pom里已经引入了objenesis、cglib依赖,替换时只需要添加mockito-inline依赖,把测试代码里的GroovyMock(MyClass1)替换为Mock(MyClass1)即可,原有桩逻辑不需要改动。 - 如果需要Mock私有方法、构造函数等更特殊的场景,可以用
PowerMock配合Mockito使用,但配置复杂度更高,JDK17下需要额外添加JVM启动参数打开模块访问权限。
方案2:测试阶段剥离final修饰符
使用字节码处理工具在测试执行前修改依赖Jar包中目标类的字节码,移除final修饰符:
- 可通过maven插件在测试编译阶段调用ByteBuddy等字节码工具,修改对应类的访问标志,去掉
ACC_FINAL标记,让CGLIB可以正常生成动态代理类,此时Spock普通的Mock()方法即可正常工作。 - 该方案不需要调整测试代码逻辑,但需要额外配置构建插件,对构建流程有侵入,适合不想更换现有Mock技术栈的场景。
方案3:针对目标类编写测试专用包装类
如果被Mock的final类方法数量不多,可在测试模块内定义对应接口和包装类,业务代码依赖接口而非具体final类,测试时直接Mock接口即可。
- 该方案符合依赖倒置设计原则,但需要调整业务代码的依赖关系,改造成本较高,适合长期迭代的项目使用。
注意:JDK17环境下使用字节码增强类Mock框架时,需要在surefire/failsafe插件配置中添加JVM参数,打开对应JDK模块的访问权限,否则会报字节码操作失败的错误。
内容的提问来源于stack exchange,提问作者BlackKnight625
相关产品推荐
相关产品推荐

