Files
amethyst/.claude/skills/kotlin-coroutines/references/testing-coroutines.md
T
2025-12-31 07:13:27 +02:00

10 KiB

Testing Coroutines

Comprehensive guide for testing async code with runTest, Turbine, and best practices.

runTest - Standard Testing

Basic Pattern

@Test
fun `test suspend function`() = runTest {
    val result = repository.fetchData()
    assertEquals(expected, result)
}

What runTest does:

  • Skips delays automatically
  • Provides TestScope
  • Advances virtual time
  • Waits for all coroutines to complete

Testing StateFlow

@Test
fun `stateflow updates correctly`() = runTest {
    val viewModel = MyViewModel()

    // Initial state
    assertEquals(UiState.Loading, viewModel.state.value)

    // Trigger action
    viewModel.loadData()
    advanceUntilIdle()  // Run all pending coroutines

    // Verify final state
    assertEquals(UiState.Success(data), viewModel.state.value)
}

Testing with Time Control

@Test
fun `debounce works correctly`() = runTest {
    val viewModel = SearchViewModel()

    viewModel.search("a")
    advanceTimeBy(100)  // 100ms passed

    viewModel.search("ab")
    advanceTimeBy(100)

    viewModel.search("abc")
    advanceTimeBy(300)  // Debounce completes

    // Only "abc" should have triggered search
    assertEquals(listOf("abc"), viewModel.searchQueries)
}

Time control functions:

  • advanceTimeBy(millis) - Move virtual time forward
  • advanceUntilIdle() - Run all pending work
  • runCurrent() - Run currently scheduled tasks only

Turbine - Flow Testing Library

Basic Collection Testing

@Test
fun `flow emits expected values`() = runTest {
    repository.observeData().test {
        assertEquals(Item1, awaitItem())
        assertEquals(Item2, awaitItem())
        assertEquals(Item3, awaitItem())
        awaitComplete()
    }
}

Testing Flow Transformations

@Test
fun `map transforms correctly`() = runTest {
    val source = flowOf(1, 2, 3)

    source
        .map { it * 2 }
        .test {
            assertEquals(2, awaitItem())
            assertEquals(4, awaitItem())
            assertEquals(6, awaitItem())
            awaitComplete()
        }
}

Testing Relay Subscriptions

@Test
fun `relay subscription receives events`() = runTest {
    val fakeClient = FakeNostrClient()

    fakeClient.reqAsFlow(relay, filters).test {
        // Initially empty
        assertEquals(emptyList(), awaitItem())

        // Send event
        fakeClient.sendEvent(event1)
        assertEquals(listOf(event1), awaitItem())

        // Send another
        fakeClient.sendEvent(event2)
        assertEquals(listOf(event1, event2), awaitItem())

        cancelAndIgnoreRemainingEvents()
    }
}

Testing Error Handling

@Test
fun `catch handles errors gracefully`() = runTest {
    val errorFlow = flow {
        emit(1)
        throw IOException("Network error")
    }.catch { emit(-1) }  // Fallback value

    errorFlow.test {
        assertEquals(1, awaitItem())
        assertEquals(-1, awaitItem())
        awaitComplete()
    }
}

Testing StateFlow with Turbine

@Test
fun `stateflow emits updates`() = runTest {
    val viewModel = MyViewModel()

    viewModel.state.test {
        // Skip initial value
        assertEquals(UiState.Loading, awaitItem())

        // Trigger update
        viewModel.loadData()
        assertEquals(UiState.Success(data), awaitItem())

        cancelAndIgnoreRemainingEvents()
    }
}

Turbine assertions:

  • awaitItem() - Get next emission or fail
  • awaitComplete() - Verify flow completed
  • awaitError() - Verify flow threw exception
  • expectNoEvents() - Assert no emissions in timeframe
  • cancelAndIgnoreRemainingEvents() - Stop test

Testing Patterns for Amethyst

Pattern: Test Relay Connection Flow

@Test
fun `reconnects on connectivity change`() = runTest {
    val connectivityFlow = MutableStateFlow(ConnectivityStatus.Off)
    val relayPool = FakeRelayPool()

    connectivityFlow
        .flatMapLatest { status ->
            when (status) {
                is ConnectivityStatus.Active -> relayPool.connectAll()
                else -> emptyFlow()
            }
        }
        .test {
            // Initially offline
            expectNoEvents()

            // Go online
            connectivityFlow.value = ConnectivityStatus.Active(1L, false)
            assertTrue(relayPool.connected)

            cancelAndIgnoreRemainingEvents()
        }
}

Pattern: Test Event Deduplication

@Test
fun `deduplicates events across relays`() = runTest {
    val relay1 = FakeRelay()
    val relay2 = FakeRelay()

    merge(relay1.events, relay2.events)
        .distinctBy { it.id }
        .test {
            // Both relays send same event
            relay1.send(event1)
            relay2.send(event1)

            // Only one emission
            assertEquals(event1, awaitItem())
            expectNoEvents()

            cancelAndIgnoreRemainingEvents()
        }
}

Pattern: Test Backpressure Handling

@Test
fun `drops oldest events when buffer full`() = runTest {
    val fastProducer = flow {
        repeat(100) { emit(it) }
    }

    fastProducer
        .buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
        .test {
            // Slow consumer
            delay(100)

            // Should have dropped oldest, kept newest
            val items = mutableListOf<Int>()
            repeat(10) {
                items.add(awaitItem())
            }

            // Newest items present
            assertTrue(90 in items)
            assertTrue(99 in items)

            awaitComplete()
        }
}

Pattern: Test Concurrent Subscriptions

@Test
fun `multiple subscriptions run concurrently`() = runTest {
    val client = FakeNostrClient()

    val feed1 = async { client.reqAsFlow(relay1, filters1).first() }
    val feed2 = async { client.reqAsFlow(relay2, filters2).first() }

    client.sendTo(relay1, event1)
    client.sendTo(relay2, event2)

    assertEquals(listOf(event1), feed1.await())
    assertEquals(listOf(event2), feed2.await())
}

Fakes and Mocks

Fake NostrClient

class FakeNostrClient : INostrClient {
    private val subscriptions = mutableMapOf<String, MutableSharedFlow<Event>>()

    override fun reqAsFlow(
        relay: NormalizedRelayUrl,
        filters: List<Filter>
    ): Flow<List<Event>> = callbackFlow {
        val subId = RandomInstance.randomChars(10)
        val flow = MutableSharedFlow<Event>()
        subscriptions[subId] = flow

        val events = mutableListOf<Event>()
        flow.collect { event ->
            events.add(event)
            send(events.toList())
        }

        awaitClose {
            subscriptions.remove(subId)
        }
    }

    fun sendEvent(event: Event) {
        subscriptions.values.forEach { it.tryEmit(event) }
    }

    fun sendTo(relay: NormalizedRelayUrl, event: Event) {
        subscriptions[relay.url]?.tryEmit(event)
    }
}

Fake Relay Pool

class FakeRelayPool {
    var connected = false
    private val _events = MutableSharedFlow<Event>()
    val events: SharedFlow<Event> = _events.asSharedFlow()

    fun connectAll(): Flow<Unit> = flow {
        connected = true
        emit(Unit)
    }

    fun disconnect() {
        connected = false
    }

    suspend fun sendEvent(event: Event) {
        _events.emit(event)
    }
}

Testing Exception Handling

Test CoroutineExceptionHandler

@Test
fun `exception handler catches errors`() = runTest {
    val errors = mutableListOf<Throwable>()

    val handler = CoroutineExceptionHandler { _, throwable ->
        errors.add(throwable)
    }

    val scope = CoroutineScope(
        Dispatchers.Unconfined + SupervisorJob() + handler
    )

    scope.launch {
        throw IOException("Test error")
    }

    advanceUntilIdle()

    assertEquals(1, errors.size)
    assertTrue(errors[0] is IOException)
}

Test Retry Logic

@Test
fun `retries failed connections`() = runTest {
    var attempts = 0
    val maxRetries = 3

    flow {
        attempts++
        if (attempts < maxRetries) {
            throw IOException("Connection failed")
        }
        emit("Success")
    }
        .retry(maxRetries)
        .test {
            assertEquals("Success", awaitItem())
            awaitComplete()
            assertEquals(3, attempts)
        }
}

Common Testing Patterns

Pattern: Verify No Emissions After Cancellation

@Test
fun `no emissions after cancellation`() = runTest {
    val flow = flow {
        emit(1)
        delay(1000)
        emit(2)  // Should not emit
    }

    flow.test {
        assertEquals(1, awaitItem())
        cancel()

        // Verify no more emissions
        expectNoEvents()
    }
}

Pattern: Test Time-Based Operations

@Test
fun `periodic emission works`() = runTest {
    flow {
        repeat(3) {
            emit(it)
            delay(1000)
        }
    }.test {
        assertEquals(0, awaitItem())

        advanceTimeBy(1000)
        assertEquals(1, awaitItem())

        advanceTimeBy(1000)
        assertEquals(2, awaitItem())

        awaitComplete()
    }
}

Pattern: Test Hot Flow Conversion

@Test
fun `shareIn creates hot flow`() = runTest {
    var emissions = 0
    val source = flow {
        repeat(3) {
            emissions++
            emit(it)
        }
    }

    val shared = source.shareIn(
        scope = this,
        started = SharingStarted.Eagerly,
        replay = 1
    )

    // First collector
    shared.take(2).collect()
    assertEquals(2, emissions)  // Emitted 0, 1

    // Second collector - shares upstream
    shared.take(1).collect()
    assertEquals(3, emissions)  // Only emitted 2, not restarted

    cancel()
}

Best Practices

  1. Use runTest for all coroutine tests

    • Provides virtual time
    • Automatic cleanup
  2. Use Turbine for Flow testing

    • Clearer assertions
    • Better error messages
  3. Test both success and error paths

    • Normal flow
    • Exception handling
    • Edge cases
  4. Control virtual time explicitly

    • Don't rely on real delays
    • Use advanceTimeBy() and advanceUntilIdle()
  5. Create fakes, not mocks

    • Simpler to maintain
    • More realistic behavior
    • Easier to debug
  6. Test cancellation behavior

    • Verify cleanup happens
    • Check no emissions after cancel
  7. Test concurrent operations

    • Use async to spawn concurrent work
    • Verify independence with SupervisorJob