update claude.md
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
# Testing Coroutines
|
||||
|
||||
Comprehensive guide for testing async code with runTest, Turbine, and best practices.
|
||||
|
||||
## runTest - Standard Testing
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow emits expected values`() = runTest {
|
||||
repository.observeData().test {
|
||||
assertEquals(Item1, awaitItem())
|
||||
assertEquals(Item2, awaitItem())
|
||||
assertEquals(Item3, awaitItem())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Flow Transformations
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
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
|
||||
|
||||
```kotlin
|
||||
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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
|
||||
```kotlin
|
||||
@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
|
||||
Reference in New Issue
Block a user