stop poisoning the unit suite from Room teardown races

The ThemeTest failure was cross-test contamination, not a theme bug. A Room invalidation-tracker query keeps running on a cancelled flow collector after the in-memory DB is closed or finalised, throws "no such table: sources", and kotlinx-coroutines-test records it and fails the next Compose test with UncaughtExceptionsBeforeTest. Which test it lands on is just ordering/load luck.

Close repositories the way DownloadRepository already did: cancel and join the scope, so Room work cannot outlive close. Stop closing the in-memory test DBs; the DB is dropped and collected once the test releases it, instead of being torn down under a pending query. Wait for the stored ref in offlineModeStillServesDownloadedArtwork instead of asserting once, since it was waiting on a different flow than the state coverArt reads. Print full stack traces for failed tests in CI.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent 2106d786cc
commit 84df71771e
17 changed files with 42 additions and 45 deletions
+8
View File
@@ -1,3 +1,4 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -100,6 +101,13 @@ tasks.withType<Test>().configureEach {
integrationTestClasses.forEach { excludeTestsMatching(it) }
}
}
testLogging {
exceptionFormat = TestExceptionFormat.FULL
events("failed")
showStackTraces = true
showExceptions = true
showCauses = true
}
}
tasks.register<Test>("integrationTest") {
@@ -13,9 +13,11 @@ import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Our own aggregated progress notification for the downloads the platform engine runs. The engine's
@@ -44,7 +46,9 @@ class DownloadNotifier(
}
internal fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
private fun build(content: DownloadNotification): Notification =
@@ -16,6 +16,7 @@ import com.subtracks.data.source.streamLengthSuffix
import com.subtracks.data.source.subsonic.SubsonicClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
@@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
@@ -100,7 +102,9 @@ class SourceRepository(
}
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
fun sources(): Flow<List<Source>> = db.sourcesDao().sources()
@@ -7,11 +7,14 @@ import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
sealed interface SyncStatus {
data object Idle : SyncStatus
@@ -46,6 +49,12 @@ class SyncManager(
requests.trySend(Unit)
}
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
private suspend fun runSync() {
if (sourceRepository.offline.value) {
showMessage("Offline mode is on")
@@ -9,7 +9,6 @@ import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Source
import com.subtracks.data.repo.ArtworkSeedRepository
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
@@ -30,11 +29,6 @@ class ArtworkSeedDaoTest {
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun deletingASourceCascadesToItsSeeds() =
runTest {
@@ -17,7 +17,6 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -39,11 +38,6 @@ class LibraryDaoTest {
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun albumSortsAndStarredFilter() =
runTest {
@@ -93,7 +93,6 @@ class DownloadRepositoryTest {
fun tearDown() {
repository.close()
sources.close()
db.close()
dir.deleteRecursively()
}
@@ -51,7 +51,6 @@ class LibraryRepositoryTest {
@After
fun tearDown() {
sourceRepository.close()
db.close()
}
@Test
@@ -12,7 +12,6 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -35,11 +34,6 @@ class QueueKeysetTest {
repository = QueueRepository(db)
}
@After
fun tearDown() {
db.close()
}
@Test
fun albumKeysetFromMatchesOffsetWindowAtADeepPosition() =
runTest {
@@ -15,7 +15,6 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -39,11 +38,6 @@ class QueueRepositoryTest {
repository = QueueRepository(db)
}
@After
fun tearDown() {
db.close()
}
@Test
fun wholeAlbumEntryResolvesEveryTrackInOrder() =
runTest {
@@ -61,7 +61,6 @@ class SourceRepositoryTest {
@After
fun tearDown() {
repository.close()
db.close()
}
@Test
@@ -268,7 +267,13 @@ class SourceRepositoryTest {
repository.setOfflineMode(true)
withTimeout(5_000) { repository.offline.first { it } }
val ref = repository.coverArt("art-1")
var ref: CoverArtRef? = repository.coverArt("art-1")
withTimeout(5_000) {
while (ref?.url?.startsWith("file:") != true) {
delay(10)
ref = repository.coverArt("art-1")
}
}
assertTrue("expected the stored file, was ${ref?.url}", ref?.url?.startsWith("file:") == true)
}
@@ -57,8 +57,8 @@ class SyncManagerTest {
@After
fun tearDown() {
manager.close()
sourceRepository.close()
db.close()
server.shutdown()
}
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -41,11 +40,6 @@ class SyncServiceTest {
.build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun syncInsertsAndPrunesRemovedRows() =
runTest {
@@ -103,7 +103,6 @@ class PlaybackControllerTest {
controller.close()
sources.close()
downloads.close()
db.close()
dispatcher.close()
}
@@ -24,8 +24,9 @@ import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@@ -90,11 +91,10 @@ class DownloadsViewModelTest {
@After
fun tearDown() {
viewModel.viewModelScope.cancel()
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close()
downloadRepository.close()
sourceRepository.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@@ -24,8 +24,9 @@ import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
@@ -84,11 +85,10 @@ class QueueViewModelTest {
@After
fun tearDown() {
viewModel.viewModelScope.cancel()
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close()
downloads.close()
sources.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
@@ -16,8 +16,9 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@@ -72,11 +73,10 @@ class AddSourceViewModelTest {
@After
fun tearDown() {
createdViewModels.forEach { it.viewModelScope.cancel() }
createdViewModels.forEach { runBlocking { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } }
server.shutdown()
downloadRepository.close()
sourceRepository.close()
db.close()
Dispatchers.resetMain()
dispatcher.close()
}