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 import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
@@ -100,6 +101,13 @@ tasks.withType<Test>().configureEach {
integrationTestClasses.forEach { excludeTestsMatching(it) } integrationTestClasses.forEach { excludeTestsMatching(it) }
} }
} }
testLogging {
exceptionFormat = TestExceptionFormat.FULL
events("failed")
showStackTraces = true
showExceptions = true
showCauses = true
}
} }
tasks.register<Test>("integrationTest") { tasks.register<Test>("integrationTest") {
@@ -13,9 +13,11 @@ import com.subtracks.R
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/** /**
* Our own aggregated progress notification for the downloads the platform engine runs. The engine's * Our own aggregated progress notification for the downloads the platform engine runs. The engine's
@@ -44,7 +46,9 @@ class DownloadNotifier(
} }
internal fun close() { internal fun close() {
val job = scope.coroutineContext[Job]
scope.cancel() scope.cancel()
runBlocking { job?.join() }
} }
private fun build(content: DownloadNotification): Notification = private fun build(content: DownloadNotification): Notification =
@@ -16,6 +16,7 @@ import com.subtracks.data.source.streamLengthSuffix
import com.subtracks.data.source.subsonic.SubsonicClient import com.subtracks.data.source.subsonic.SubsonicClient
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -100,7 +102,9 @@ class SourceRepository(
} }
fun close() { fun close() {
val job = scope.coroutineContext[Job]
scope.cancel() scope.cancel()
runBlocking { job?.join() }
} }
fun sources(): Flow<List<Source>> = db.sourcesDao().sources() 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
sealed interface SyncStatus { sealed interface SyncStatus {
data object Idle : SyncStatus data object Idle : SyncStatus
@@ -46,6 +49,12 @@ class SyncManager(
requests.trySend(Unit) requests.trySend(Unit)
} }
fun close() {
val job = scope.coroutineContext[Job]
scope.cancel()
runBlocking { job?.join() }
}
private suspend fun runSync() { private suspend fun runSync() {
if (sourceRepository.offline.value) { if (sourceRepository.offline.value) {
showMessage("Offline mode is on") 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.model.Source
import com.subtracks.data.repo.ArtworkSeedRepository import com.subtracks.data.repo.ArtworkSeedRepository
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
@@ -30,11 +29,6 @@ class ArtworkSeedDaoTest {
.build() .build()
} }
@After
fun tearDown() {
db.close()
}
@Test @Test
fun deletingASourceCascadesToItsSeeds() = fun deletingASourceCascadesToItsSeeds() =
runTest { runTest {
@@ -17,7 +17,6 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -39,11 +38,6 @@ class LibraryDaoTest {
.build() .build()
} }
@After
fun tearDown() {
db.close()
}
@Test @Test
fun albumSortsAndStarredFilter() = fun albumSortsAndStarredFilter() =
runTest { runTest {
@@ -93,7 +93,6 @@ class DownloadRepositoryTest {
fun tearDown() { fun tearDown() {
repository.close() repository.close()
sources.close() sources.close()
db.close()
dir.deleteRecursively() dir.deleteRecursively()
} }
@@ -51,7 +51,6 @@ class LibraryRepositoryTest {
@After @After
fun tearDown() { fun tearDown() {
sourceRepository.close() sourceRepository.close()
db.close()
} }
@Test @Test
@@ -12,7 +12,6 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@@ -35,11 +34,6 @@ class QueueKeysetTest {
repository = QueueRepository(db) repository = QueueRepository(db)
} }
@After
fun tearDown() {
db.close()
}
@Test @Test
fun albumKeysetFromMatchesOffsetWindowAtADeepPosition() = fun albumKeysetFromMatchesOffsetWindowAtADeepPosition() =
runTest { runTest {
@@ -15,7 +15,6 @@ import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -39,11 +38,6 @@ class QueueRepositoryTest {
repository = QueueRepository(db) repository = QueueRepository(db)
} }
@After
fun tearDown() {
db.close()
}
@Test @Test
fun wholeAlbumEntryResolvesEveryTrackInOrder() = fun wholeAlbumEntryResolvesEveryTrackInOrder() =
runTest { runTest {
@@ -61,7 +61,6 @@ class SourceRepositoryTest {
@After @After
fun tearDown() { fun tearDown() {
repository.close() repository.close()
db.close()
} }
@Test @Test
@@ -268,7 +267,13 @@ class SourceRepositoryTest {
repository.setOfflineMode(true) repository.setOfflineMode(true)
withTimeout(5_000) { repository.offline.first { it } } 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) assertTrue("expected the stored file, was ${ref?.url}", ref?.url?.startsWith("file:") == true)
} }
@@ -57,8 +57,8 @@ class SyncManagerTest {
@After @After
fun tearDown() { fun tearDown() {
manager.close()
sourceRepository.close() sourceRepository.close()
db.close()
server.shutdown() server.shutdown()
} }
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -41,11 +40,6 @@ class SyncServiceTest {
.build() .build()
} }
@After
fun tearDown() {
db.close()
}
@Test @Test
fun syncInsertsAndPrunesRemovedRows() = fun syncInsertsAndPrunesRemovedRows() =
runTest { runTest {
@@ -103,7 +103,6 @@ class PlaybackControllerTest {
controller.close() controller.close()
sources.close() sources.close()
downloads.close() downloads.close()
db.close()
dispatcher.close() dispatcher.close()
} }
@@ -24,8 +24,9 @@ import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
@@ -90,11 +91,10 @@ class DownloadsViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
viewModel.viewModelScope.cancel() runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close() controller.close()
downloadRepository.close() downloadRepository.close()
sourceRepository.close() sourceRepository.close()
db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
} }
@@ -24,8 +24,9 @@ import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@@ -84,11 +85,10 @@ class QueueViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
viewModel.viewModelScope.cancel() runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
controller.close() controller.close()
downloads.close() downloads.close()
sources.close() sources.close()
db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
} }
@@ -16,8 +16,9 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
@@ -72,11 +73,10 @@ class AddSourceViewModelTest {
@After @After
fun tearDown() { fun tearDown() {
createdViewModels.forEach { it.viewModelScope.cancel() } createdViewModels.forEach { runBlocking { it.viewModelScope.coroutineContext[Job]?.cancelAndJoin() } }
server.shutdown() server.shutdown()
downloadRepository.close() downloadRepository.close()
sourceRepository.close() sourceRepository.close()
db.close()
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.close() dispatcher.close()
} }