Room数据库协程内执行多操作时单元测试失败问题排查
问题分析与解决方向
问题背景
在Android应用中使用Room数据库,通过CoinPickerRepoImpl仓库类操作CoinDao实现代币选中状态更新:在updateSelectedCoin方法的协程上下文内,先调用unselectAllCoins将所有代币设为未选中,再调用selectCoin将指定ID代币设为选中。该逻辑在应用中运行正常,但执行单元测试时,assertNotNull(selectedCoinId)断言报错(selectedCoinId为null),移除unselectAllCoins后测试即可通过。
问题原因
- UnconfinedTestDispatcher调度特性:测试中使用的
UnconfinedTestDispatcher会让协程在当前线程立即执行,但Room的suspendDAO方法内部存在线程切换逻辑。当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
相关产品推荐
相关产品推荐

