Skip to content

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:

python -m pip install pytest "xyberos[http]"
python -m pip install pytest 'xyberos[http]'

The code

This lesson is the test file. Copy it into your project as tests/test_app.py:

examples/learn_fundamentals/fundamentals13_first_test.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

$env:PYTHONPATH = "src;examples"
python -m pytest examples/learn_fundamentals/fundamentals13_first_test.py -q
PYTHONPATH=src:examples python -m pytest examples/learn_fundamentals/fundamentals13_first_test.py -q

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 the with block first.
  • A double that does not satisfy the contract. The provider is checked, and you get DependencyResolutionError naming 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:
no provider is registered for Repository; required by UserService
`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.