You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

升级androidx.datastore至1.1.1后Instrumentation测试报UncompletedCoroutinesError

DataStore升级至1.1.1后Instrumentation测试阻塞失败问题修复

问题背景

将app/build.gradle中DataStore相关依赖从1.0.0升级至1.1.1:

implementation "androidx.datastore:datastore-preferences:$androidXDatastore"
implementation "androidx.datastore:datastore-core:$androidXDatastore"
implementation "androidx.datastore:datastore-preferences-core:$androidXDatastore"

生产代码运行正常,但Instrumentation测试抛出UncompletedCoroutinesError:

kotlinx.coroutines.test.UncompletedCoroutinesError: After waiting for 10s, the test coroutine is not completing, there were active child jobs: ["coroutine#1":StandaloneCoroutine{Active}@284ce4db]
    at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:349)
    at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:333)
    at kotlinx.coroutines.InvokeOnCancelling.invoke(JobSupport.kt:1431)
    at kotlinx.coroutines.JobSupport.notifyCancelling(JobSupport.kt:1477)
    at kotlinx.coroutines.JobSupport.tryMakeCancelling(JobSupport.kt:799)
    at kotlinx.coroutines.JobSupport.makeCancelling(JobSupport.kt:759)
    at kotlinx.coroutines.JobSupport.cancelImpl$kotlinx_coroutines_core(JobSupport.kt:675)
    at kotlinx.coroutines.JobSupport.cancelCoroutine(JobSupport.kt:662)
    at kotlinx.coroutines.TimeoutCoroutine.run(Timeout.kt:159)
    at kotlinx.coroutines.EventLoopImplBase$DelayedRunnableTask.run(EventLoop.common.kt:501)
    at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:280)
    at kotlinx.coroutines.DefaultExecutor.run(DefaultExecutor.kt:109)
    at java.base@18.0.1/java.lang.Thread.run(Thread.java:833)

目标类代码:

class ScrollPositionDataStore(private val dataStore: DataStore<Preferences>) {
    private val _displayedPositionOffsetKey = stringPreferencesKey("displayed_position_offset")

    suspend fun saveScrollPositionStore(topRowIndex: Int, positionOffset: Int) {
        val position = "$topRowIndex,$positionOffset"
        dataStore.edit { preferences ->
            preferences[_displayedPositionOffsetKey] = position
        }
    }

    suspend fun getScrollPosition(): Pair<Int, Int>? = scrollPositionFlow.firstOrNull()

    private val scrollPositionFlow: Flow<Pair<Int, Int>> = dataStore.data
        .map { preferences ->
            // On the first run of the app, we will use LinearLayoutManager by default
            val i = preferences[_displayedPositionOffsetKey]
            val split = i?.split(",")
            if (split == null) Pair(4, 0)
            else Pair(split[0].toInt(), split[1].toInt())
        }
}

测试类及基类代码:

private const val POSITION_PREFERENCES_NAME = "position_preferences"

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class ScrollPositionDataStoreTest : JunitDataStoreTest(POSITION_PREFERENCES_NAME) {
    private var inputDataStore: ScrollPositionDataStore = ScrollPositionDataStore(dataStore)

    @Test
    fun `ScrollPositionDataStore 入力した値をそのまま帰す`() {
        scope.runTest {
            dataStore.edit { it.clear() }
            inputDataStore.saveScrollPositionStore(10, 15)
            assertThat(inputDataStore.getScrollPosition())
                .isEqualTo(Pair(10, 15))
        }
    }

    @Test
    fun `ScrollPositionDataStore 未入力時に400を返す`() {
        scope.runTest {
            dataStore.edit { it.clear() }
            assertThat(inputDataStore.getScrollPosition())
                .isEqualTo(Pair(4, 0))
        }
    }

    @Test
    fun `ScrollPositionDataStore マイナス値をそのまま還す`() {
        scope.runTest {
            dataStore.edit { it.clear() }
            inputDataStore.saveScrollPositionStore(-1, -111)
            assertThat(inputDataStore.getScrollPosition())
                .isEqualTo(Pair(-1, -111))
        }
    }
}

@ExperimentalCoroutinesApi
abstract class JunitDataStoreTest(dataStoreName :String) {

    private val dispatcher = UnconfinedTestDispatcher()
    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    val scope = TestScope(dispatcher + Job())
    val dataStore = PreferenceDataStoreFactory.create(
        scope = scope,
        produceFile = { context.preferencesDataStoreFile(dataStoreName) }
    )

    @CallSuper
    @Before
    open fun setUp() {
        Dispatchers.setMain(dispatcher)
    }

    @CallSuper
    @After
    open fun tearDown() {
        Dispatchers.resetMain()
    }
}

原因分析

DataStore 1.1.0及以上版本优化了协程作用域绑定逻辑,测试基类中TestScope(dispatcher + Job())的写法会导致DataStore内部协程脱离测试Scope管控。TestScope本身已自带根Job,额外添加Job()会创建独立的Job分支,测试结束时无法正确取消DataStore后台协程,进而触发UncompletedCoroutinesError。

修复方案

1. 修正测试Scope创建方式

移除TestScope初始化时额外添加的Job(),直接使用TestScope(dispatcher):

val scope = TestScope(dispatcher)

2. (可选)主动取消测试Scope

在tearDown方法中主动取消测试Scope,确保所有子协程被清理:

@CallSuper
@After
open fun tearDown() {
    Dispatchers.resetMain()
    scope.cancel()
}

修改后的完整基类代码

@ExperimentalCoroutinesApi
abstract class JunitDataStoreTest(dataStoreName :String) {

    private val dispatcher = UnconfinedTestDispatcher()
    private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
    // 移除额外的Job(),使用TestScope自带的根Job
    val scope = TestScope(dispatcher)
    val dataStore = PreferenceDataStoreFactory.create(
        scope = scope,
        produceFile = { context.preferencesDataStoreFile(dataStoreName) }
    )

    @CallSuper
    @Before
    open fun setUp() {
        Dispatchers.setMain(dispatcher)
    }

    @CallSuper
    @After
    open fun tearDown() {
        Dispatchers.resetMain()
        scope.cancel()
    }
}

验证

修改后重新运行Instrumentation测试,所有测试用例应能正常通过,不再出现未完成协程的错误。

内容的提问来源于stack exchange,提问作者kou

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 08:52:02