Skip to content

Lesson 9 of 9 · 18m

Testing Async, Time, and Events

The untestable-triple — clocks, randomness, and delays — plus event-driven code and the patterns that make async testable.

Async code is hard to test because it's nondeterministic by nature — timing, ordering, timeouts. The fix isn't a better assertion; it's injecting the nondeterminism so the test controls it.

The untestable triple

def process_order(order):
    if time.time() - order.created_at > 86400:     # untestable — real clock
        order.status = "expired"
    send_email()                                    # untestable — real side effect
    time.sleep(2)                                   # untestable — real delay

Every one of those is a test smell. Inject the clock, the side effect, and the delay:

def process_order(order, clock=time.time, send=send_email):
    if clock() - order.created_at > 86400:
        order.status = "expired"
    send()

The test passes a fake clock (lambda: fixed_time) and a recording fake — you assert on the observable outcome, not the timing.

Testing timeouts and retries

A retry loop that takes 5 real seconds per test is a slow suite. Inject the retry policy or the delay:

def fetch_with_retry(url, max_retries=3, delay=lambda: time.sleep(1)):
    for i in range(max_retries):
        try: return requests.get(url)
        except: delay()

Test with delay=lambda: None and max_retries=0 — the timeout behavior is now testable without waiting.

Event-driven code

An event loop isn't testable as "fire the event and check" — the handler is async, so the test needs to wait for the side effect. Use asyncio.wait_for with a timeout (fails the test if it never arrives) or a synchronous queue consumer for tests:

async def test_handler():
    event.set()
    await asyncio.wait_for(result_future, timeout=2)
    assert result.done()

Field notes

  • freezegun/time-machine patch the clock globally — useful for integration tests but prefer dependency injection for unit tests (the test shouldn't depend on global patching).
  • asyncio.sleep(0) in a test yields control without real time — use it to let the event loop process pending tasks.
  • An async test that doesn't await the assertion races the event loop — always await the side effect you expect.