Skip to content
← Back to explainers

Python explainer

Make Async Tests Repeatable with Handshakes and Fake Clocks

About 4 min to read. Allow extra time to work through examples.

Hold a task at a checkpoint until the test has looked, test time-based code with a fake clock, and find the tasks a test left running.

This article uses Python. You can make it your practice mode to browse related content across the site.

The previous article replaced sleeps with events. This one adds four tools. A handshake holds a task at a checkpoint. A fake clock lets you test code that reads the time. A task snapshot finds the tasks a test left running. A barrier starts several threads at the same moment.

Hold a task at a checkpoint with two events

You want to test what a reader sees while a writer is halfway through. One event tells the reader that the writer has reached the halfway point. A second event holds the writer there until the reader has looked. Two events used this way are a handshake:

import asyncio


def test_consumer_reads_between_writes() -> None:
    async def scenario() -> list[str]:
        half_written = asyncio.Event()
        read_complete = asyncio.Event()
        observed: list[str] = []

        async def producer() -> None:
            observed.append("wrote-half")
            half_written.set()
            await read_complete.wait()
            observed.append("wrote-rest")

        async def consumer() -> None:
            await half_written.wait()
            observed.append("read")
            read_complete.set()

        async with asyncio.timeout(5):
            async with asyncio.TaskGroup() as group:
                group.create_task(producer())
                group.create_task(consumer())
        return observed

    assert asyncio.run(scenario()) == ["wrote-half", "read", "wrote-rest"]

The first event puts the read after the first write. The second keeps the last write until after the read. With only the first event, the writer could finish before the reader looks. As in the previous article, the asyncio.timeout(5) only stops a broken handshake from hanging the suite.

This test checks the handshake itself. In your own tests, put the checkpoints inside the code under test, for example through a hook that the test passes in. Then assert on that code's state.

Test time-based code with a fake clock

A cache entry expires after a minute, and your test should finish in milliseconds. Pass the clock in as an argument, and give the test a clock it can set:

from collections.abc import Callable
from dataclasses import dataclass


@dataclass
class FakeClock:
    now: float = 0.0

    def __call__(self) -> float:
        return self.now


class Deadline:
    def __init__(self, seconds: float, *, clock: Callable[[], float]) -> None:
        self.clock = clock
        self.expires_at = clock() + seconds

    def expired(self) -> bool:
        return self.clock() >= self.expires_at


def test_expiry_boundary() -> None:
    clock = FakeClock()
    deadline = Deadline(10.0, clock=clock)
    clock.now = 9.0
    assert not deadline.expired()
    clock.now = 10.0
    assert deadline.expired()

In production, pass time.monotonic, a clock that never goes backward. Move the fake clock only forward too, so the test matches what production sees.

The fake clock changes only what clock() returns. It doesn't move asyncio's timers, so a task in asyncio.sleep(60) still sleeps a real minute. Use the fake clock for code that reads the time, and an event for code that waits.

Find the tasks a test left running

A handler that starts a task and forgets it can pass every test while the task runs on in the background. To catch it, compare asyncio.all_tasks() before and after the call:

import asyncio


async def handler() -> None:
    asyncio.create_task(asyncio.sleep(60), name="refresh")  # bug: nobody awaits it


async def main() -> None:
    before = asyncio.all_tasks()
    await handler()
    leftover = asyncio.all_tasks() - before
    print(sorted(task.get_name() for task in leftover))  # ['refresh']


asyncio.run(main())

all_tasks() returns the event loop's unfinished tasks, including tasks that have not started yet. Three things affect what the difference shows:

  • A task that has already finished is missing, even if nobody read its exception.
  • A task that another fixture started on purpose shows up too, so decide which tasks your check is responsible for.
  • Take the second snapshot while the loop is still running. asyncio.run cancels the leftover tasks as it returns, so afterwards there is nothing to see.

Once you have the names, cancel the leftover tasks and await them, so the next test starts clean. The exercise builds this check.

Line threads up with a barrier

A stress test often runs one function on several threads at once. threading.Barrier(n) makes each thread wait until all n have arrived, then lets them all go. That makes overlap more likely. After that, the scheduler still decides which thread runs first, as the previous article said, so a passing run proves nothing. Keep the lock in the code either way.

Check your understanding Your leak check takes its second snapshot right after the handler returns. A moment earlier, a task that the handler started raised ValueError. Does the check report it? Show answer

No. all_tasks() lists only unfinished tasks, and this one has finished. Python logs its error only when it deletes the task, as "Task exception was never retrieved". To catch it in a test, make the handler own its tasks and await them, as the background tasks article showed.

Going further

Debug mode, from the article on debugging async services, adds the line that created a coroutine to the "never awaited" warning. Python prints that warning when it deletes the coroutine, which can be after the test that caused it has finished. With debug mode on, look at the creation line the warning shows, not at the test that was running when it appeared.

step 54 of 56

Concurrency with Threads and asyncio

Up next Find Leaked Tasks and Start Threads Together