test(settings): cancel viewModelScope before db.close in SignaturesScreenTest to fix a Room teardown race (flaky on API-37 CI)

SignaturesScreenTest built a real SignaturesViewModel by hand but tore down
with a bare `db.close()` that never cancelled viewModelScope. The ViewModel's
`signatures` StateFlow is a Room InvalidationTracker Flow kept alive by
stateIn(WhileSubscribed(5_000)), so the collector could stay live up to 5s
after the UI detached — a re-query then landed on the just-closed in-memory DB
and threw SQLITE_MISUSE ("connection is closed"). Timing-dependent, hence the
intermittent API-37 CI failure in tappingRadioOnNonDefault_makesItTheDefault.

Fix: hold the ViewModel in an androidx.lifecycle.ViewModelStore and, in @After,
call store.clear() (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE
db.close(), so the collector is gone before the DB closes. Behaviour and
assertions are unchanged; the fix removes the race by construction.

Audited the androidTest tree for the same hazard and fixed two siblings the
same way:
- AccountSettingsScreenTest: had the same live-Room-Flow-vs-close race,
  previously worked around by never closing the in-memory DB at all; now
  clears the ViewModel then closes the DB.
- ComposeScreenTest: ComposeViewModel's init launches a viewModelScope
  coroutine that reads the real accountSettings/signature Room repos; clear
  the store before db.close() to avoid the same in-flight-read-vs-close race.

Verified locally: connectedDebugAndroidTest green for all three classes
(12/12) on a cold-booted emulator, plus the JVM fast gate (assembleDebug,
testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin,
lintDebug, ktlintCheck, detekt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:46:40 -05:00
co-authored by Claude Opus 4.8
parent b8d67557a0
commit ca6d90b602
3 changed files with 49 additions and 6 deletions
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -60,10 +61,20 @@ class ComposeScreenTest {
private var db: AccountDatabase? = null
// Holds the real ComposeViewModel built by hand in setContent() below, so closeDb() can clear()
// it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@After
fun closeDb() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// ComposeViewModel's init block launches a viewModelScope coroutine that reads the real
// accountSettings/signature Room repositories (applySignature()); without this, that read can
// still be in flight when the DB closes, racing a SQLITE_MISUSE ("connection is closed") —
// the same class of teardown race fixed in SignaturesScreenTest/AccountSettingsScreenTest.
viewModelStore.clear()
db?.close()
}
@@ -92,6 +103,7 @@ class ComposeScreenTest {
signatureRepository = SignatureRepository(database.signatureDao()),
settingsRepository = SettingsRepository(context),
)
viewModelStore.put("compose", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ComposeScreen(onBack = onBack, viewModel = viewModel)
@@ -7,11 +7,13 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.WorkManager
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -54,13 +56,27 @@ class AccountSettingsScreenTest {
private var manageSignaturesClicked = false
private lateinit var db: AccountDatabase
// Holds the real AccountSettingsViewModel built by hand in setContent() below, so tearDown() can
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
@After
fun tearDown() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// The ViewModel's `settings`/`signatureCount`/`defaultSignatureName`/`account` Room
// InvalidationTracker Flows are kept alive by stateIn(WhileSubscribed(5_000)): without this,
// a collector can still be live up to 5s after the UI detaches, so a re-query lands on the
// just-closed in-memory DB and throws SQLITE_MISUSE ("connection is closed") — an intermittent
// teardown race, not a real bug. (Previously worked around by never closing the DB at all.)
viewModelStore.clear()
db.close()
}
private fun setContent(): AccountSettingsRepository {
val context = ApplicationProvider.getApplicationContext<Context>()
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
// the test process.
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
val repository = AccountSettingsRepository(db.accountSettingsDao())
runBlocking {
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
@@ -74,6 +90,7 @@ class AccountSettingsScreenTest {
syncScheduler = SyncScheduler(Provider { WorkManager.getInstance(context) }),
settingsRepository = SettingsRepository(context),
)
viewModelStore.put("account-settings", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountSettingsScreen(
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelStore
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -50,6 +51,10 @@ class SignaturesScreenTest {
private lateinit var db: AccountDatabase
private lateinit var repository: SignatureRepository
// Holds the real SignaturesViewModel built by hand in setContent() below, so tearDown() can
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
private val viewModelStore = ViewModelStore()
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
@@ -70,7 +75,15 @@ class SignaturesScreenTest {
}
@After
fun tearDown() = db.close()
fun tearDown() {
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
// SignaturesViewModel.signatures is a Room InvalidationTracker Flow kept alive by
// stateIn(WhileSubscribed(5_000)): without this, the collector can still be live up to 5s
// after the UI detaches, so a re-query lands on the just-closed in-memory DB and throws
// SQLITE_MISUSE ("connection is closed") — an intermittent teardown race, not a real bug.
viewModelStore.clear()
db.close()
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@@ -81,6 +94,7 @@ class SignaturesScreenTest {
SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to accountId)),
repository,
)
viewModelStore.put("signatures", viewModel)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignaturesScreen(onBack = {}, onEdit = {}, onAdd = {}, viewModel = viewModel)