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

Room数据库协程内执行多操作时单元测试失败问题排查

问题分析与解决方向

问题背景

在Android应用中使用Room数据库,通过CoinPickerRepoImpl仓库类操作CoinDao实现代币选中状态更新:在updateSelectedCoin方法的协程上下文内,先调用unselectAllCoins将所有代币设为未选中,再调用selectCoin将指定ID代币设为选中。该逻辑在应用中运行正常,但执行单元测试时,assertNotNull(selectedCoinId)断言报错(selectedCoinId为null),移除unselectAllCoins后测试即可通过。

问题原因

  • UnconfinedTestDispatcher调度特性:测试中使用的UnconfinedTestDispatcher会让协程在当前线程立即执行,但Room的suspend DAO方法内部存在线程切换逻辑。当unselectAllCoins和selectCoin连续调用时,Unconfined调度器可能导致两个更新操作的执行顺序出现预期外的交错,或者测试线程在第二个更新完成前就发起查询,读取到所有代币都未选中的中间状态。
  • 事务原子性缺失:当前两个更新操作是独立执行的,未包裹在同一个事务中。在测试环境下,Room可能将这两个操作拆分为独立事务,测试线程若在两个事务之间查询,就会得到selectedCoinId为null的结果。而实际应用中线程调度间隔极短,很难触发这种中间状态。

解决方向

1. 合并操作到单个事务

修改DAO,新增一个包含两个更新逻辑的事务方法,确保操作原子执行,避免中间状态暴露:

@Dao
interface CoinDao {
    @Transaction
    suspend fun updateSelectedCoin(id: String) {
        unselectAllCoins()
        selectCoin(id)
    }

    @Query("UPDATE coinentity SET selected = 0")
    suspend fun unselectAllCoins()

    @Query("UPDATE coinentity SET selected = 1 WHERE id = :id")
    suspend fun selectCoin(id: String)
}

仓库类直接调用该事务方法:

override suspend fun updateSelectedCoin(id: String) {
    withContext(coroutineDispatcher) {
        coinDao.updateSelectedCoin(id)
    }
}

2. 更换测试调度器

替换UnconfinedTestDispatcher为StandardTestDispatcher,它会严格按协程调度顺序执行,避免操作交错:

@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = StandardTestDispatcher()

@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
    Dispatchers.setMain(testDispatcher)
    db = Room.inMemoryDatabaseBuilder(
        ApplicationProvider.getApplicationContext(), AppDatabase::class.java
    ).allowMainThreadQueries().build()
    coinDao = db.coinDao()
    coinpickerRepository =
        CoinPickerRepoImpl(coinDao = coinDao, coroutineDispatcher = testDispatcher)
    runBlocking {
        initDb(coinDao)
    }
}

@After
fun tearDown() {
    Dispatchers.resetMain()
    db.close()
}

测试方法中确保所有调度任务执行完成后再查询:

@Test
fun update_selected_coin_test() = runTest {
    coinpickerRepository.updateSelectedCoin("BTC")
    testDispatcher.scheduler.advanceUntilIdle() // 等待所有后台任务执行完毕
    val selectedCoinId = coinpickerRepository.getSelectedCoinId()
    assertNotNull(selectedCoinId)
    assertEquals(selectedCoinId, coinDao.getSelectedCoin()?.id)
    assertTrue(coinDao.selectedCoinExists())
}

3. 验证初始化数据状态

确认initDb方法中初始化的cryptoCoins集合里没有默认选中的代币,避免初始状态干扰测试结果。


相关原代码

仓库类原代码

class CoinPickerRepoImpl @Inject constructor(
    private val coinDao: CoinDao,
    @IoDispatcher private val coroutineDispatcher: CoroutineDispatcher
): CoinPickerRepository {
  
 override suspend fun updateSelectedCoin(id: String) {
        withContext(coroutineDispatcher) {
            coinDao.unselectAllCoins()
            coinDao.selectCoin(id)
        }
    }
}

DAO原代码

@Dao
interface CoinDao {

 @Query("UPDATE coinentity SET selected = 0")
    suspend fun unselectAllCoins()

    @Query("UPDATE coinentity SET selected = 1 WHERE id = :id")
    suspend fun selectCoin(id: String)
}

测试原代码

@RunWith(AndroidJUnit4::class)
@SmallTest
class CoinPickerRepoImplTest {

    private lateinit var coinDao: CoinDao
    private lateinit var coinpickerRepository: CoinPickerRepository
    private lateinit var db: AppDatabase

    @OptIn(ExperimentalCoroutinesApi::class)
    private val testDispatcher = UnconfinedTestDispatcher()

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(), AppDatabase::class.java
        ).allowMainThreadQueries().build()
        coinDao = db.coinDao()
        coinpickerRepository =
            CoinPickerRepoImpl(coinDao = coinDao, coroutineDispatcher = testDispatcher)
        runBlocking {
            initDb(coinDao)
        }
    }

    @After
    @Throws(IOException::class)
    fun closeDb() {
        db.close()
    }

    @Test
    fun update_selected_coin_test() = runTest {
        coinpickerRepository.updateSelectedCoin("BTC")
        val selectedCoinId = coinpickerRepository.getSelectedCoinId()
        assertNotNull(selectedCoinId)
        assertEquals(selectedCoinId, coinDao.getSelectedCoin()?.id)
        assertTrue(coinDao.selectedCoinExists())
    }
}

private suspend fun initDb(coinDao: CoinDao) {
    coinDao.insertAll(cryptoCoins.map { cryptoCoin ->
        CoinEntity(
            id = cryptoCoin.id,
            name = cryptoCoin.name,
            symbol = cryptoCoin.symbol,
            image = cryptoCoin.image,
            selected = cryptoCoin.selected
        )
    })
}

内容的提问来源于stack exchange,提问作者Gastón Saillán

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.18 11:37:01