Testing Asynchronous & Concurrent Code

Reliable async tests control time, wait for an explicit completion or quiescent state, and separate logical ordering from data-race detection. Sleeping and polling merely hope that a schedule occurred; virtual clocks, deterministic schedulers and bounded synchronization make the schedule part of the experiment.

builds onUnit Testing: Deterministic Fixtures, Actions & OraclesAsync I/O & Event LoopsMemory Models & Ordering

An asynchronous operation returns before its work is complete; a concurrent program admits more than one valid execution order. Tests become flaky when they observe these systems using real elapsed time and an assumed schedule. The cure is to make completion, time and scheduling explicit parts of the harness, then use separate tools for safety properties the example test cannot exhaust.

Wait for a condition, not a duration #

This is not synchronization:

typescript
startWork();
await sleep(100);
expect(result()).toBe("done");

It passes only if the environment schedules and completes the work within an arbitrary interval. A faster sleep makes it flaky; a slower sleep wastes time and can still fail under load.

Prefer an operation that returns a task/promise/future and await it. If the API intentionally starts background work, expose a completion signal, join handle, event, channel, or a test-visible definition of quiescence. When only an eventually true external condition is available, poll that condition with a monotonic deadline and preserve the last observed state on timeout.

Virtualize time #

Time is an input. A retry loop, cache expiry or scheduled job should not require seconds or hours of real waiting in a test. A virtual clock advances only when directed, while a test scheduler runs tasks due at that logical instant.

Frameworks expose this in different ways: Go's testing/synctest combines fake time with waiting for a concurrent “bubble” to become durably blocked; Kotlin's coroutine test scheduler can run current tasks, advance time and drain until idle; Node and .NET provide fake timers/time providers. The invariant is the same: production logic reads an injectable or harness-controlled time source, and the assertion runs only after scheduled consequences have executed.

Keep an outer real-time timeout to terminate a broken test harness. The timeout is a safety fuse, not the mechanism that orders the test.

Separate three kinds of correctness #

Question Suitable evidence
Did this async workflow produce the right result? awaited example with controlled collaborators
Are shared-memory accesses synchronized? race detector or memory-safety tooling under representative execution
Are all relevant schedules correct? deterministic scheduler/model checker over a bounded state space

A race detector instruments an execution and reports conflicting accesses that actually occur. A green run does not prove race freedom because unexecuted schedules remain unexplored. Run it across unit, integration and realistic workload tests that exercise concurrent paths.

Tools such as Loom replace synchronization primitives and systematically permute valid schedules within a model. They can find rare interleavings deterministically, but the state space grows combinatorially and unmodelled I/O or randomness escapes the model. Use tiny models around the concurrency protocol, not an entire application.

Testing events and callbacks #

Events create three distinct claims:

  1. a subscriber receives the event with the correct payload;
  2. cancellation or unsubscription prevents later delivery;
  3. the publisher's action occurs before or after another observable effect as the contract requires.

Subscribe a test-owned handler, perform the action, await a bounded completion signal, and assert the payload. To test a consumer, invoke the consumer through the event interface or a controllable publisher. Avoid asserting private subscription mechanics when externally visible delivery is enough; exact add/remove calls matter only if subscription lifetime is the behavior.

Check failure paths too: handler throws, subscriber is slow, cancellation races with delivery, and duplicate events arrive. Declare whether delivery is at-most-once, at-least-once or best-effort; a test cannot infer a guarantee the interface never made.

Global and static state is a scheduling hazard #

A process-global clock, singleton cache or static event bus lets parallel tests interfere even if each passes alone. Prefer explicit ownership and lifecycle at the composition root. When a platform API is static, wrap it at the true boundary only if the application needs to control it; do not generate one wrapper per call merely to satisfy a mocking tool.

If a test changes global state, serialize that test explicitly and restore the exact prior value in a guaranteed teardown. This is a containment measure, not equivalent to isolation.

Worked example: exponential retry #

For delays of 1 s, 2 s and 4 s followed by success:

  1. configure a stub dependency to fail three times and then succeed;
  2. start the operation under a virtual clock;
  3. assert one immediate attempt;
  4. advance to just before 1 s and assert no retry;
  5. advance across each boundary, draining scheduled work after every step;
  6. assert attempt timestamps [0, 1, 3, 7], the final value, and no pending task;
  7. run separate cancellation examples before and during a scheduled retry.

This proves the logical schedule instantly and deterministically. A companion real integration can verify that the production timer and network cancellation wiring work; a stress or race-enabled run can inspect shared-memory safety. No single test style subsumes the others.

Diagnose the schedule #

On failure, retain logical time, task creation sites, event sequence, cancellation state, random seed and the last observed condition. “Expected true” after a timeout is not enough. Concurrent failures are histories; the test must preserve the history needed to replay or model them.

Sources

  1. articleGo Blog — Testing Time (and other asynchronicities)
  2. docsGo — Data Race Detector
  3. docsKotlin — kotlinx-coroutines-test
  4. docsLoom — testing concurrent Rust code

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close