test(settings): cancel viewModelScope before db.close in SignaturesScreenTest to fix a Room teardown race #374

Merged
JMR-dev merged 3 commits from fix-signatures-test-teardown-race into main 2026-07-06 21:21:56 +00:00
JMR-dev commented 2026-07-06 19:47:07 +00:00 (Migrated from github.com)

Summary

Fixes a genuinely-flaky instrumented test root-caused from an API-37 CI failure: SignaturesScreenTest > tappingRadioOnNonDefault_makesItTheDefault intermittently failed with android.database.SQLException: connection is closed (SQLITE_MISUSE 21) due to a Room / viewModelScope teardown race.

Root cause

SignaturesScreenTest builds a real SignaturesViewModel by hand and tore down with a bare @After fun tearDown() = 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 stayed live up to 5s after the UI detached. Sequence: radio-tap write → Room invalidates signatures → waitUntil returns → test ends → db.close() → the still-live collector re-runs observeForAccount().prepare() on the now-closed connection → SQLITE_MISUSE. Timing-dependent, hence intermittent.

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 race is removed by construction.

Siblings audited + fixed (same hazard)

  • AccountSettingsScreenTest — 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.init launches a viewModelScope coroutine that reads the real accountSettings/signature Room repos; clears the store before db.close() to avoid the same in-flight-read-vs-close race.

(The remaining *ScreenTest classes that close a Room DB either use fakes with no real viewModelScope, or observe non-Room stores — none hit this hazard.)

Verification

  • :app:connectedDebugAndroidTest green (12/12) for SignaturesScreenTest, AccountSettingsScreenTest, ComposeScreenTest on a cold-booted local emulator.
  • JVM fast gate green: assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt.

Note: the flake is intermittent, so one green run cannot prove it is gone — but the fix eliminates the race by construction (the collector is cancelled before the connection closes). CI runs the full multi-API E2E matrix.

🤖 Generated with Claude Code

## Summary Fixes a genuinely-flaky instrumented test root-caused from an API-37 CI failure: `SignaturesScreenTest > tappingRadioOnNonDefault_makesItTheDefault` intermittently failed with `android.database.SQLException: connection is closed` (SQLITE_MISUSE 21) due to a Room / `viewModelScope` teardown race. ### Root cause `SignaturesScreenTest` builds a real `SignaturesViewModel` by hand and tore down with a bare `@After fun tearDown() = 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 stayed live up to 5s after the UI detached. Sequence: radio-tap write → Room invalidates `signatures` → `waitUntil` returns → test ends → `db.close()` → the still-live collector re-runs `observeForAccount().prepare()` on the now-closed connection → SQLITE_MISUSE. Timing-dependent, hence intermittent. ### 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 race is removed by construction. ### Siblings audited + fixed (same hazard) - **`AccountSettingsScreenTest`** — 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.init` launches a `viewModelScope` coroutine that reads the real accountSettings/signature Room repos; clears the store before `db.close()` to avoid the same in-flight-read-vs-close race. (The remaining `*ScreenTest` classes that close a Room DB either use fakes with no real `viewModelScope`, or observe non-Room stores — none hit this hazard.) ### Verification - `:app:connectedDebugAndroidTest` **green (12/12)** for `SignaturesScreenTest`, `AccountSettingsScreenTest`, `ComposeScreenTest` on a cold-booted local emulator. - JVM fast gate green: `assembleDebug`, `testDebugUnitTest`, `jacocoTestCoverageVerification`, `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt`. Note: the flake is intermittent, so one green run cannot *prove* it is gone — but the fix eliminates the race by construction (the collector is cancelled before the connection closes). CI runs the full multi-API E2E matrix. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.