A test that sleeps for a second to verify a one-second delay is slow by design. A test that sleeps a little longer to avoid failing on CI is also difficult to trust.
For shared coroutine logic, virtual time gives you a better question: what should have happened before this deadline, and what should happen at it?
Make the delayed behavior small
Here is a deliberately tiny piece of shared code:
import kotlinx.coroutines.delay
class DelayedRefresh(
private val refresh: suspend () -> Unit,
) {
suspend fun run() {
delay(1_000)
refresh()
}
}
The delay is only an example. In a real feature, a product rule should justify it. The important design choice is that the class suspends in its caller's coroutine instead of creating an unrelated scope.
Assert the boundary explicitly
Put this test in commonTest with kotlin.test and kotlinx-coroutines-test dependencies:
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class DelayedRefreshTest {
@Test
fun refreshRunsAtTheDeadline() = runTest {
var refreshes = 0
val action = DelayedRefresh { refreshes++ }
val job = launch { action.run() }
runCurrent()
advanceTimeBy(999)
assertEquals(0, refreshes)
advanceTimeBy(1)
runCurrent()
assertEquals(1, refreshes)
job.join()
}
}
The first runCurrent() starts the launched coroutine and reaches its delay. Advancing by 999 ms proves the work has not fired early. Advancing to 1,000 ms moves the clock to the deadline; runCurrent() executes work scheduled at that instant.
This distinction matters because advanceTimeBy does not itself run tasks scheduled exactly at the destination time. Treating clock movement and task execution as separate steps makes boundary assertions easier to read.
Test cancellation as a feature
Add a second test to the same class:
@Test
fun cancellationPreventsRefresh() = runTest {
var refreshes = 0
val action = DelayedRefresh { refreshes++ }
val job = launch { action.run() }
runCurrent()
advanceTimeBy(500)
job.cancel()
job.join()
advanceTimeBy(1_000)
runCurrent()
assertEquals(0, refreshes)
}
A completed success test cannot prove that obsolete work stops. This cancellation test expresses a separate requirement: cancelling the owner before the deadline must prevent the refresh.
Virtual time has a boundary too
The test scheduler controls work using its test dispatchers. It does not accelerate a real server, blocking sleep, or arbitrary work moved to a production dispatcher.
When a class must switch dispatcher, inject that dispatcher and use a StandardTestDispatcher(testScheduler) in the test. Keep related test dispatchers on the same scheduler. Otherwise, advancing one clock may not advance the work you intended to observe.
Keep runTest as the returned result of the test function, as shown above, for cross-platform compatibility. Avoid placing unrelated work after it.
What this still does not prove
A passing common test validates the shared behavior on the targets where you actually run it. Merely placing a test in commonTest is not evidence that CI executed every target.
Discover the test tasks exposed by your module, run the relevant Android/JVM and native targets, and respect host requirements for Apple targets. Native integration and UI behavior deserve their own tests.
For timing-sensitive code, a useful test tells a short story: start work, advance to just before the boundary, assert, cross the boundary, assert again. That is more informative than waiting and hoping the final state appears.
Top comments (0)