记一次 Mockito.mockStatic 泄漏导致的单元测试偶发报错排查过程

相信用 Java 写过单元测试的读者们对 Mockito 不会陌生。至于 Mockito 是什么,为什么要用 Mockito,本文不再赘述。本文记录了一次在 Apache ShardingSphere 项目中,由 Mockito.mockStatic 使用不当导致的单元测试偶发报错排查过程。

前言

Mockito 自 3.4.0 起新增了一个方法 Mockito.mockStatic,支持对静态方法 mock。

本人也曾在 Stack Overflow 上回答过一个问题,展示了我在 Apache ShardingSphere 的单元测试代码中使用 Mockito.mockStatic mock 单例的案例,对 Mockito.mockStatic 方法不是特别熟悉的同学可以了解一下:
如何使用 Mockito mock 单例 Mocking a singleton with mockito

mockStatic 使用有哪些注意实现?我们查看一下 Mockito 官方文档的说明:48. Mocking static methods (since 3.4.0)

When using the inline mock maker, it is possible to mock static method invocations within the current thread and a user-defined scope. This way, Mockito assures that concurrently and sequentially running tests do not interfere. To make sure a static mock remains temporary, it is recommended to define the scope within a try-with-resources construct.

大致的意思是:mockStatic 方法作用范围是当前线程和用户定义的作用域。为确保 mockStatic 只是临时生效,建议使用 try-with-resources 代码块包裹 mockStatic
解读 Mockito 文档提供的示例:

assertEquals("foo", Foo.method()); // 静态方法 Foo.method() 原本行为
try (MockedStatic mocked = mockStatic(Foo.class)) {
    
     // 对 Foo 类进行 mockStatic 
    mocked.when(Foo::method).thenReturn("bar"); // 通过 mock 改变静态方法 Foo.method() 行为
    assertEquals("bar", Foo.method()); // 进行测试断言
    mocked.verify(Foo::method);
}
assertEquals("foo", Foo.method()); // 离开 mockStatic 作用域,Foo.method() 恢复原本行为

现在我们思考下,如果 mockStatic 方法没有被包裹在 try-with-resources 代码块中,也没有手动关闭 MockedStatic 对象,会发生什么事情?

根据文档的描述,如果没有关闭 mockStatic 的话,是不是被 mock 的静态类在这条线程上的行为会一直被改变?

Apache ShardingSphere 的单元测试曾出现过因 Mockito.mockStatic 使用后没有释放,导致单元测试偶发失败的问题。

排查过程

Apache ShardingSphere 会通过 GitHub Actions 对每个 PR 或合并到 master 的 commit 运行 CI——标准的 Maven clean install 流程,install 过程中就包括运行单元测试。

有段时间,ShardingSphere 的 CI 偶尔会失败一下,问了一下其他也在参与 ShardingSphere 开发的同学,本地 install 或执行单元测试也有可能会失败。

在这里插入图片描述
https://github.com/apache/shardingsphere/actions/workflows/ci.yml?query=branch%3Amaster+created%3A<2022-07-13+is%3Afailure

由于时间久远,GitHub Actions 的日志已经被清理了。

一个项目的单元测试如果不能保证稳定通过,那肯定是 测试代码有问题 或者 生产代码存在隐患

问题复现

来看 ShardingSphere infra-common 模块下的一个单元测试,ShardingSphereMetaDataTest 中有一个用例如下:

@Test
public void assertGetMySQLDefaultSchema() throws SQLException {
    
    
    MySQLDatabaseType databaseType = new MySQLDatabaseType();
    ShardingSphereDatabase actual = ShardingSphereDatabase.create("foo_db", databaseType, Collections.singletonMap("", databaseType), mock(DataSourceProvidedDatabaseConfiguration.class), new ConfigurationProperties(new Properties()), mock(InstanceContext.class));
    assertNotNull(actual.getSchema("foo_db"));
}

单独运行这个测试用例,是通过的。
在这里插入图片描述
但是,如果运行 infra-common 模块下的所有测试,这个用例就会失败。
在这里插入图片描述

其中,ShardingSphereDatabase.create 最终调用的静态方法大致如下,代码中只有正常返回一个 ShardingSphereDatabase 实例或抛出异常两种可能,不存在返回 null 的情况。

private static ShardingSphereDatabase create(final String name, final DatabaseType protocolType, final DatabaseConfiguration databaseConfig, final Collection<ShardingSphereRule> rules, final Map<String, ShardingSphereSchema> schemas) {
    
    
    // 省略中间过程代码
    return new ShardingSphereDatabase(name, protocolType, resourceMetaData, ruleMetaData, schemas);
}

但是,这么简单的一段单元测试确实就报了空指针,而且还是 actual(静态方法 ShardingSphereDatabase.create 的返回结果)为 null

java.lang.NullPointerException: Cannot invoke "org.apache.shardingsphere.infra.metadata.database.ShardingSphereDatabase.getSchema(String)" because "actual" is null
	at org.apache.shardingsphere.infra.metadata.ShardingSphereMetaDataTest.assertGetMySQLDefaultSchema(ShardingSphereMetaDataTest.java:109)

从代码上看,一个没有可能返回 null 的静态方法,却在单元测试返回了 null,不理解!

由于本地环境暂时能够持续必现问题,可以打断点 Debug 一下。

失败是偶发而不是必现的原因是:一个模块下的单元测试的运行顺序不是恒定的。 有些可能污染其他测试用例的测试代码,恰好其运行顺序比较靠后,测试运行表现为正常通过。

曾经我也解决过另一个受单元测试执行顺序影响的偶发问题,具体排查可以见我之前的文章:记一次 ThreadLocal 泄漏导致的 shardingsphere-jdbc-core 单元测试偶发失败的排查与修复

调试代码

打上断点,运行模块全量测试,跑到了断言失败前的代码。
来一个快速表达式计算,确实是 ShardingSphereDatabase.create 方法返回了 null
在这里插入图片描述

那进入方法内部看看:

发现端倪 & 解决

奇怪的现象出现了!可以看下面这个动图:
在这里插入图片描述
进入 ShardingSphereDatabaes.create 方法后,点击 Step Into,正常情况下应该继续进入 create 方法第一行代码的 DatabaseRulesBuilder.build 方法,但是,调试器却直接跳到了 create 方法的 return,并且点击 Step Into 也没有继续进入 create 方法!

这种奇怪的现象,凭经验来看,有可能是实际运行的字节码与源码对不上。代码中全局搜了一下 mockStatic 方法的使用,果然发现了一些单元测试代码使用了 mockStatic 方法,但既没有使用 try-with-resources,又没有手动释放。

于是,我对 mockStatic 使用不当的代码进行了修复,并且在 ShardingSphere 的代码规范里面补充了使用 mockStaticmockConstruction 的要求。

具体可见:

挖坑

在前面的步骤已经发现并解决了单元测试的问题,但这是凭个人经验和运气解决的。

假如我是曾经没有使用过 mockStatic 等方法、没有相关经验的开发者,光凭 IDEA 的 Debug 现象是无法直接得出 mockStatic 泄漏的结论的,如何能够排查出这类泄漏问题?

找时间继续深入探究这个问题。

猜你喜欢

转载自blog.csdn.net/wu_weijie/article/details/125759460