Lesson 13 — write your first test¶
What you will build¶
A test file for the application you have been building, and the habit that makes it worth having: a test double must satisfy the contract.
There is no Xyberos test runner and no plugin to install. A test is an ordinary pytest test; what the framework ships is the doubles that make it fast.
What you need¶
pytest, and the http extra, because two of these tests make a request:
The code¶
This lesson is the test file. Copy it into your project as tests/test_app.py:
"""Lesson 13 — writing your first test, as a test.
This file *is* the lesson: copy it into your own project as `tests/test_app.py` and run `pytest`.
There is no Xyberos test runner and no plugin to install. A test is an ordinary pytest test, and
what the framework ships is the doubles that make it fast — plus one rule worth learning early: **a
test double has to satisfy the contract**, which is why the application is written against a
`Protocol` rather than against the class that happens to implement it today.
"""
from __future__ import annotations
import asyncio
from typing import Annotated, Protocol, cast
from xyberos import App, Inject
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client
class ItemReader(Protocol):
"""The contract: what a caller needs, not what an implementation is."""
def all(self) -> list[str]:
"""Return every stored item."""
...
class ItemStore:
"""The real implementation: no HTTP types, so it is testable on its own."""
def __init__(self) -> None:
self._items: list[str] = []
def add(self, name: str) -> None:
"""Store one item."""
self._items.append(name)
def all(self) -> list[str]:
"""Return every stored item."""
return list(self._items)
class FakeItemStore:
"""A test double that satisfies the same contract, and answers instantly."""
def all(self) -> list[str]:
"""Return a fixed list."""
return ["from the fake"]
def create_app() -> App:
"""Build the application the tests exercise."""
app = App()
app.provide(ItemReader, instance=ItemStore())
@app.get("/items")
async def list_items(items: Annotated[ItemReader, Inject()]) -> list[str]:
return items.all()
return app
def test_a_service_can_be_used_without_http() -> None:
"""The cheapest test: no application, no request, no server."""
def scenario() -> None:
store = ItemStore()
store.add("notebook")
assert store.all() == ["notebook"]
scenario()
def test_the_route_answers() -> None:
"""Use the test client as a context manager, so the application's lifespan runs."""
with create_test_client(HttpAdapter(create_app())) as client:
response = client.get("/items")
assert response.status_code == 200
assert response.json() == []
def test_a_dependency_can_be_replaced() -> None:
"""Override the *contract*.
`with_overrides` returns an independent application, so an override cannot leak into another
test, and the double is checked against the contract when it is first resolved.
"""
app = create_app().with_overrides({ItemReader: FakeItemStore()})
with create_test_client(HttpAdapter(app)) as client:
assert client.get("/items").json() == ["from the fake"]
def test_the_application_validates_and_starts() -> None:
"""Composition errors are startup errors, so a test can catch them early."""
async def scenario() -> None:
app = create_app()
app.validate()
async with app.lifespan():
# `resolve` is typed for a contract, and a `Protocol` contract has to be named here for
# a type checker to see what comes back.
reader = cast(ItemReader, await app.resolve(ItemReader))
assert reader.all() == []
asyncio.run(scenario())
How to run it¶
Four tests, and they get faster as they get further from HTTP: a plain unit test, a request through the test client, a request with a dependency replaced, and a check that the application validates and starts.
The habits worth copying¶
| Habit | Why |
|---|---|
Write the application against a Protocol, not a class |
the contract is what a caller needs, so a double can satisfy it |
Use create_test_client(...) as a context manager |
entering it runs the lifespan, which validates and starts the application |
Override the contract with app.with_overrides({...}) |
each test gets its own application, so nothing leaks between tests |
Write asyncio.run(scenario()) inside a sync test |
no pytest-asyncio marker and no plugin: each test gets its own event loop, which is what an App requires anyway |
| Never sleep | inject a clock and move it, rather than waiting for time to pass |
The double must satisfy the contract. Override a class contract with something that is not an instance of it, and the failure arrives when the service is first resolved — lesson 8's second line of output is the same class of mistake caught at composition time.
Common mistakes¶
- A request outside the client's context manager. It is refused with a
500, because the application has not started. If every request fails in a test, check thewithblock first. - A double that does not satisfy the contract. The provider is checked, and you get
DependencyResolutionErrornaming what the provider returned instead of what the contract wanted. - Testing through the HTTP layer only. The cheapest test in the file touches no application at all, and it is the one that runs in a millisecond.
Exercise¶
Write a test that fails because the application is wired incorrectly, then fix the wiring.
Show the answer
The answer is one test: compose an `App`, register a service whose own dependencies are missing, and expect `validate()` to raise `MissingProviderError`. You have already seen that failure produced by a module you ran — it is the second line of [lesson 8](08-dependency-injection.md)'s output, and the page there documents the message: `MissingProviderError` names both the missing piece and the service that needed it, which is usually enough to fix the wiring without debugging.Next¶
Lesson 14 covers the errors you will meet, and how to read them. Until it is published, the roadmap shows where each lesson stands.