Testing Async Code
Testing async code uses the same pytest you know, with one extra decorator: @pytest.mark.asyncio. Put it above your test function, make the function async def, and use await inside. Everything else stays the same.
pytest-asyncio, @pytest.mark.asyncio, async fixtures. Auto mode sets asyncio_mode = "auto" in pyproject.toml to avoid decorating every test. Async fixtures use async def + yield for setup/teardown.
In Lesson 5, James built the complete async pipeline: read notes with aiofiles, export concurrently with gather. It works when he runs it manually. But there are no tests.
"Every function I have built since Chapter 57 has tests," James says. "The async functions do not. Can pytest even run async code?"
"It can," Emma says. "You need one plugin: pytest-asyncio. After that, you mark your async tests with a decorator and pytest handles the event loop for you."
Installing pytest-asyncio
Add the plugin as a dev dependency:
uv add --dev pytest-asyncio
Then configure it in pyproject.toml. Add this section (or add to your existing [tool.pytest.ini_options]):
[tool.pytest.ini_options]
asyncio_mode = "auto"
The asyncio_mode = "auto" setting tells pytest-asyncio to automatically handle any async def test function. Without it, you would need to add @pytest.mark.asyncio above every async test. Auto mode saves repetition.
If you prefer explicit control, skip the asyncio_mode setting and mark each test individually:
import pytest
@pytest.mark.asyncio
async def test_something() -> None:
result = await my_async_function()
assert result == expected
Both approaches work. This chapter uses auto mode.
Writing Async Tests
An async test function looks like a regular test, but with async def and await:
import asyncio
from pathlib import Path
from smartnotes.async_io import async_export_all, Note
async def test_export_creates_files(tmp_path: Path) -> None:
"""Verify that async_export_all creates one file per note."""
notes = [
Note(title="Test One", body="Body one", tags=["python"]),
Note(title="Test Two", body="Body two", tags=["async"]),
]
count = await async_export_all(notes, tmp_path)
assert count == 2
assert (tmp_path / "test-one.md").exists()
assert (tmp_path / "test-two.md").exists()
Run it:
uv run pytest tests/test_async_io.py -v
Output:
tests/test_async_io.py::test_export_creates_files PASSED
The test uses tmp_path, the same pytest fixture you have used since Chapter 57. pytest-asyncio runs the async test function on an event loop automatically. You do not call asyncio.run() yourself.
A few more tests to verify behavior:
async def test_export_empty_list(tmp_path: Path) -> None:
"""Verify that exporting an empty list returns 0."""
count = await async_export_all([], tmp_path)
assert count == 0
async def test_export_file_content(tmp_path: Path) -> None:
"""Verify that exported files contain the correct content."""
notes = [
Note(title="Content Check", body="The body text", tags=["test"]),
]
await async_export_all(notes, tmp_path)
content = (tmp_path / "content-check.md").read_text()
assert "# Content Check" in content
assert "The body text" in content
assert "Tags: test" in content
Output:
tests/test_async_io.py::test_export_creates_files PASSED
tests/test_async_io.py::test_export_empty_list PASSED
tests/test_async_io.py::test_export_file_content PASSED
Each test follows the same pattern you learned in Chapter 57: arrange (create notes), act (await the function), assert (check results). The only difference is async def and await.
Async Fixtures
Fixtures can be async too. This is useful when setup involves I/O, like creating files with aiofiles:
import pytest
import aiofiles
from pathlib import Path
@pytest.fixture
async def notes_directory(tmp_path: Path) -> Path:
"""Create a temp directory with sample note files."""
notes_dir = tmp_path / "notes"
notes_dir.mkdir()
sample_notes = [
("python-basics.md", "# Python Basics\n\nVariables and types.\n\nTags: python\n"),
("async-intro.md", "# Async Intro\n\nCoroutines and await.\n\nTags: python, async\n"),
("cli-tools.md", "# CLI Tools\n\nBuilding with argparse.\n\nTags: python, cli\n"),
]
for filename, content in sample_notes:
async with aiofiles.open(notes_dir / filename, mode="w") as f:
await f.write(content)
return notes_dir
Use it in a test:
from smartnotes.async_io import read_notes_from_directory
async def test_read_notes_from_directory(notes_directory: Path) -> None:
"""Verify that async reader finds all notes."""
notes = await read_notes_from_directory(notes_directory)
assert len(notes) == 3
titles = [n.title for n in notes]
assert "Async Intro" in titles
assert "CLI Tools" in titles
assert "Python Basics" in titles
Output:
tests/test_async_io.py::test_read_notes_from_directory PASSED
The async fixture creates three note files using aiofiles. The test receives the populated directory and verifies the reader finds all three notes. The fixture tears down automatically because tmp_path is cleaned up by pytest.
For fixtures that need explicit cleanup, use yield:
@pytest.fixture
async def database_connection():
"""Open a connection, yield it, then close."""
conn = await connect_to_database()
yield conn
await conn.close()
Everything before yield is setup. Everything after yield is teardown. The same pattern as sync fixtures, but with await.
The TDG Cycle for Async Code
The TDG cycle does not change for async code. You specify, write tests, generate, and verify. The tests are async, the implementation is async, but the workflow is the same:
| TDG Step | Sync Version | Async Version |
|---|---|---|
| Specify | Write stub with types | Write stub with async def and return types |
| Test | def test_... | async def test_... with await |
| Generate | Prompt AI | Same prompt, AI generates async code |
| Verify | ruff, pyright, pytest | Same tools, same commands |
The full flow for testing the async pipeline:
# Step 1: Specify the stub
async def async_pipeline(
source_dir: Path, export_dir: Path
) -> int:
"""Read notes from source, export to destination concurrently."""
...
# Step 2: Write the test
async def test_pipeline_reads_and_exports(
notes_directory: Path, tmp_path: Path
) -> None:
"""Verify pipeline reads from source and exports to destination."""
export_dir = tmp_path / "exports"
count = await async_pipeline(notes_directory, export_dir)
assert count == 3
assert export_dir.exists()
exported_files = list(export_dir.glob("*.md"))
assert len(exported_files) == 3
# Step 3: Prompt AI
# "Implement async_pipeline so that test_pipeline_reads_and_exports passes.
# Use aiofiles for file I/O and asyncio.gather for concurrent exports."
# Step 4: Verify
# uv run ruff check smartnotes/async_io.py
# uv run pyright smartnotes/async_io.py
# uv run pytest tests/test_async_io.py -v
The test drives the implementation. The AI sees the test expectations and generates code that satisfies them. This works for async code exactly as it does for sync code.
PRIMM-AI+ Practice: Predict the Async Test
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
Given this test file with asyncio_mode = "auto" configured:
import asyncio
async def test_gather_results() -> None:
async def double(x: int) -> int:
await asyncio.sleep(0.1)
return x * 2
results = await asyncio.gather(double(1), double(2), double(3))
assert results == [2, 4, 6]
- Will this test pass or fail?
- Does this test need
@pytest.mark.asyncio? Why or why not? - How long will this test take to run? (Hint: gather runs concurrently.)
Write your answers before running.
Check your prediction
- Pass.
gatherruns all threedoublecalls concurrently and returns[2, 4, 6]. - No. With
asyncio_mode = "auto", pytest-asyncio detectsasync deftest functions automatically. - About 0.1 seconds. All three
doublecalls sleep concurrently, so total time equals the longest single sleep (0.1s), not the sum (0.3s).
Run
Press Shift+Tab to exit Plan Mode.
Create the test file and run it with uv run pytest -v. Verify it passes and check the timing in pytest output.
Investigate
Ask Claude Code:
What happens if I write an async test function but forget to
configure asyncio_mode and forget @pytest.mark.asyncio?
Show me the error message pytest produces.
The AI shows that pytest runs the function but the await inside is never executed. The test may pass silently without actually testing anything, or raise a confusing coroutine warning. This is a common trap.
Modify
Add a timing assertion to test_export_creates_files. Measure the time with time.perf_counter(), export 10 notes concurrently, and assert the total time is under 1 second (proving they ran concurrently, not sequentially).
Make [Mastery Gate]
Write the complete test suite for SmartNotes async I/O. In Claude Code, type /tdg:
- Write stubs for
async_export_allandread_notes_from_directory - Write tests: export creates files, export with empty list, read from directory, read from empty directory, full pipeline round-trip
- Prompt AI to implement both functions
- Run
uv run ruff check,uv run pyright,uv run pytest -v
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Diagnose a Silent Test Failure
I wrote this async test but it seems to pass even when
the implementation is broken:
async def test_export_count(tmp_path):
notes = [Note("A", "body", [])]
count = await async_export_all(notes, tmp_path)
assert count == 1
When I deliberately break async_export_all to return 0,
the test still passes. What is going wrong? Check my
pyproject.toml configuration for pytest-asyncio issues.
What you're learning: Async tests can silently pass if pytest-asyncio is not configured correctly. The AI diagnoses whether the test is actually running as async or being skipped as a coroutine. This debugging skill prevents false confidence in your test suite.
Prompt 2: Generate a Comprehensive Test Suite
I have two async functions to test:
async def async_export_all(notes: list[Note], directory: Path) -> int
async def read_notes_from_directory(directory: Path) -> list[Note]
Generate a comprehensive pytest test suite with:
- Happy path tests for both functions
- Edge cases (empty inputs, missing directories)
- A round-trip test (export then read back)
- An async fixture that creates sample note files
Use aiofiles in fixtures and tmp_path for isolation.
Include type annotations on all test functions.
What you're learning: You are using AI as a co-worker to generate test cases. The AI suggests edge cases you may not have considered (missing directories, permission errors, Unicode filenames). You review and refine, keeping the tests that add value.
Prompt 3: Compare Sync vs Async Test Patterns
Show me the same test written three ways:
1. Sync test for a sync function
2. Async test for an async function (auto mode)
3. Async test for an async function (explicit marker)
Use the SmartNotes export function as the example.
Explain when I would choose each approach.
What you're learning: Seeing all three patterns side by side cements the mechanical difference: sync uses def and direct calls; async uses async def and await. The choice between auto mode and explicit markers depends on project conventions and how many async tests you have.
James runs the full test suite:
uv run pytest tests/test_async_io.py -v
Five tests, all green. He grins.
"It is the same cycle," he says. "Specify, test, generate, verify. The tests have async def instead of def and await instead of direct calls. But the pattern is identical."
"That is the point," Emma says. "Testing async code should not feel like learning a new framework. It is pytest with a plugin." She pauses. "Though I will admit, the first time I forgot to configure asyncio_mode, I spent twenty minutes wondering why my tests passed when the implementation was clearly broken. The test function was a coroutine that pytest never awaited. It returned a coroutine object, which is truthy, so the assertion passed."
"That sounds painful."
"It was."
If you suspect a test is not running as async, add assert False at the end. If the test still passes, pytest is not awaiting your coroutine. Check that asyncio_mode = "auto" is in your pyproject.toml or that your test has the @pytest.mark.asyncio decorator.
James nods and adds that trick to his notes. He looks at the SmartNotes project. Async exports, async reads, async tests. "We have all the pieces. What is left?"
"Putting them together. The capstone combines everything into a complete async pipeline with timing proof. You have twenty-five minutes."