Skip to content

FastAPI Testing ​

Fastapi Startkit ships a purpose-built HttpTestCase that spins up your actual FastAPI application and sends real HTTP requests through it — no mocking, no monkeypatching. Every test talks to the same app you deploy.

Installation ​

The testing helpers live in the fastapi extra. Install it alongside the standard test dependencies:

bash
uv add fastapi-startkit --extra fastapi
uv add --dev pytest pytest-asyncio httpx

The HttpTestCase Class ​

HttpTestCase is an abstract base class that sets up an httpx.AsyncClient backed by your FastAPI instance before each test, and tears it down after.

python
from fastapi_startkit.fastapi.testing import HttpTestCase

You must subclass it and implement get_application(), which should return your booted Application instance:

python
class TestRegister(HttpTestCase):
    def get_application(self):
        from bootstrap.application import app
        return app

The get_application() method is called once per test via a pytest fixture — return the same singleton app every time so providers don't re-register on every test run.

HTTP Helpers ​

HttpTestCase exposes async wrappers around the most common HTTP methods. All keyword arguments are forwarded directly to the underlying httpx.AsyncClient method.

MethodSignature
getawait self.get(url, **kwargs)
postawait self.post(url, **kwargs)
putawait self.put(url, **kwargs)
patchawait self.patch(url, **kwargs)
deleteawait self.delete(url, **kwargs)

Pass a JSON body with json=, form data with data=, headers with headers=, and so on — anything httpx accepts.

Each helper returns a TestResponse — a thin wrapper around httpx.Response that adds fluent assertion helpers (assert_status, assert_ok, assert_json, and more) while forwarding everything else to the underlying response. See Fluent JSON Assertions for the full API.

Writing Tests ​

Tests are async methods prefixed with test_. pytest-asyncio handles the event loop automatically when asyncio_mode = "auto" is set in pyproject.toml.

toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
asyncio_default_test_loop_scope = "function"
pythonpath = ["."]

asyncio_default_fixture_loop_scope and asyncio_default_test_loop_scope suppress deprecation warnings introduced in pytest-asyncio 0.23. pythonpath = ["."] adds the project root to sys.path so imports like from bootstrap.application import app resolve without extra configuration.

Example — Registration Endpoint ​

python
from fastapi_startkit.fastapi.testing import HttpTestCase


class TestRegister(HttpTestCase):
    def get_application(self):
        from bootstrap.application import app
        return app

    async def test_user_can_register(self):
        response = await self.post("/students/register", json={
            "name": "John Doe",
            "email": "john@example.com",
            "password": "password123",
        })

        assert response.status_code == 200
        assert response.json()["message"] == "Student registered successfully"
        assert "user_id" in response.json()

    async def test_user_cannot_register_with_invalid_data(self):
        # missing required fields
        response = await self.post("/students/register", json={})
        assert response.status_code == 422

        # password too short
        response = await self.post("/students/register", json={
            "name": "John Doe",
            "email": "john@example.com",
            "password": "short",
        })
        assert response.status_code == 422

        # invalid email
        response = await self.post("/students/register", json={
            "name": "John Doe",
            "email": "not-an-email",
            "password": "password123",
        })
        assert response.status_code == 422

        # name too short
        response = await self.post("/students/register", json={
            "name": "J",
            "email": "john@example.com",
            "password": "password123",
        })
        assert response.status_code == 422

    async def test_user_cannot_register_with_duplicate_email(self):
        payload = {
            "name": "Jane Doe",
            "email": "jane@example.com",
            "password": "password123",
        }

        await self.post("/students/register", json=payload)

        response = await self.post("/students/register", json=payload)
        assert response.status_code == 400
        assert response.json()["detail"] == "Email already registered"

Accessing the Raw Client ​

If you need features not covered by the convenience helpers (e.g. OPTIONS, HEAD, custom auth headers on every request, streaming), access self.client directly. It is a fully configured httpx.AsyncClient. Note that calling the client directly returns a raw httpx.Response, not a TestResponse, so the fluent assertion helpers are not available.

python
async def test_preflight(self):
    response = await self.client.options("/profile")
    assert response.status_code == 204

Fluent JSON Assertions ​

Every HTTP helper (get, post, put, patch, delete) returns a TestResponse instead of a raw httpx.Response. TestResponse wraps the real response and adds a small set of expressive assertion helpers, including a fluent AssertableJson API for inspecting JSON bodies.

python
response = await self.post("/teams", json=payload)

response.assert_created().assert_json(lambda json: (
    json.where("id", 1)
        .where("name", "Phoenix Suns")
        .etc()
))

TestResponse transparently forwards every attribute it doesn't define to the underlying httpx.Response, so response.status_code, response.json(), response.headers and response.text continue to work exactly as before. Existing tests that used plain assertions keep passing.

Status Assertions ​

TestResponse provides shortcuts for the most common status checks. Each one returns the same TestResponse, so calls chain.

MethodAsserts
assert_status(code)the response status equals code
assert_ok()status is 200
assert_created()status is 201
assert_no_content()status is 204
python
response.assert_status(422)
response.assert_ok()
response.assert_created().assert_json(...)

When the status does not match, the failure message includes the expected and actual codes and the response body, so you can see why the request failed:

AssertionError: Expected status code [200] but received [422]. Body: {"detail":[...]}

JSON Body Assertions ​

assert_json(callback=None, *, exact=None) ​

Run fluent assertions against the decoded JSON body. The callback receives an AssertableJson instance scoped to the root of the body.

python
response.assert_json(lambda json: (
    json.where("id", 1)
        .where("name", "Phoenix Suns")
        .etc()
))

To assert the body matches a dictionary exactly, pass exact=:

python
response.assert_json(exact={"id": 1, "name": "Phoenix Suns"})

assert_json_structure(structure) ​

Assert that the body contains a given set of keys without caring about values. Use a list of key names for an object, nest dictionaries for deeper structures, and use the "*" wildcard to apply a structure to every element of a list.

python
response.assert_json_structure({
    "teams": {
        "*": ["name", "sport"],
    },
})

A leaf may map to None in dict form to assert presence only:

python
response.assert_json_structure({"meta": None, "data": ["id", "name"]})

The AssertableJson API ​

AssertableJson is the object passed to your assert_json callback. Every method returns the same instance, so assertions chain fluently. It is built around four ideas:

  • Dot-path keys — address nested values with "profile.email", and even index into lists with "teams.0.name".
  • Predicate matchers — pass a callable anywhere a value is expected to run a custom check.
  • Nested scoping — descend into a sub-object or list element with has(key, callback).
  • The strict interaction model — every key of an object must be asserted, or the test fails. Call etc() to acknowledge the rest.

Matching values ​

MethodDescription
where(key, expected)assert key equals expected, or passes the expected predicate if it is callable
where_not(key, expected)assert key does not equal expected (or fails the predicate)
where_all(bindings)assert each {key: expected} pair via where
where_type(key, type)assert key is one of the given JSON type name(s)
where_all_type(bindings)assert each {key: type} pair via where_type
python
json.where("name", "Phoenix Suns")          # literal value
json.where("score", lambda v: v > 100)       # predicate matcher
json.where_not("status", "banned")
json.where_all({"id": 1, "active": True})
Predicate matchers ​

Any value argument can be a callable. It receives the actual value and must return something truthy for the assertion to pass:

python
json.where("email", lambda v: v.endswith("@example.com"))
json.where("tags", lambda v: "featured" in v)
Types with where_type ​

Supported type names are string, integer, double, number, boolean, array, object, and null. Pass a pipe-separated string or a list to allow a union of types:

python
json.where_type("name", "string")
json.where_type("deleted_at", "string|null")
json.where_type("tags", ["array", "null"])

Booleans are treated as a distinct JSON type: True/False satisfy boolean but not integer, even though Python's bool is a subclass of int.

Asserting presence ​

MethodDescription
has(key, length?, callback?)assert key exists; optionally assert its size and/or scope into it
has_all(*keys)assert every key exists
has_any(*keys)assert at least one key exists
missing(key)assert key is absent
missing_all(*keys)assert none of the keys exist
count(key, n)assert the collection at key has exactly n items

has supports several shorthands:

python
json.has("teams")                        # presence only
json.has("teams", 3)                     # presence + length 3
json.has("profile", lambda p: ...)       # presence + nested scope
json.has("teams", 3, lambda t: ...)      # length + nested scope
python
json.has_all("id", "name", "email")
json.has_any("phone", "mobile")
json.missing("password")
json.count("teams", 3)

Nested scoping ​

Passing a callback to has descends into that key. The callback receives a new AssertableJson scoped to the nested value, and that scope is verified independently (its own keys must all be asserted or etc()'d).

python
response.assert_json(lambda json: (
    json.has("profile", lambda profile: (
        profile.where("email", "john@example.com")
               .where("verified", True)
               .etc()
    )).etc()
))

Because dot-paths index into lists, you can scope into a specific element:

python
json.has("teams", 3)
json.has("teams.0", lambda team: team.where("name", "Phoenix Suns").etc())

Iterating collections ​

MethodDescription
first(callback)scope into the first child element and run callback
each(callback)run callback against every child element in its own scope
python
json.has("teams", lambda teams: teams.first(lambda team: (
    team.where("name", "Phoenix Suns").etc()
)))

json.has("teams", lambda teams: teams.each(lambda team: (
    team.where_all_type({"name": "string", "sport": "string"})
)))

The strict interaction model ​

AssertableJson records every key you touch. When a scope closes (at the end of assert_json, or at the end of a has/first/each callback), it asserts that every key of the object was interacted with. This catches unexpected or leaked fields — for example a password hash that should never reach the client.

If you don't intend to assert on the remaining keys, call etc() to acknowledge them:

python
# Body: {"id": 1, "name": "Phoenix Suns", "sport": "basketball"}

# ❌ Fails — "name" and "sport" were never asserted
response.assert_json(lambda json: json.where("id", 1))

# ✅ Passes — etc() acknowledges the untouched keys
response.assert_json(lambda json: json.where("id", 1).etc())

The failure message names exactly what was left:

AssertionError: Unexpected properties were found at [root]: ['name', 'sport'].
Interact with them or call .etc().

Helpful error messages ​

Every assertion reports the full dot-path to the offending key, so failures point straight at the problem even deep inside a nested body:

AssertionError: Property [teams.0.sport] does not exist.
AssertionError: Property [profile.email] does not match. Expected ['a@b.com'], got ['x@y.com'].

Complete Example ​

python
from fastapi_startkit.fastapi.testing import HttpTestCase


class TestTeams(HttpTestCase):
    def get_application(self):
        from bootstrap.application import app
        return app

    async def test_create_team_returns_created_resource(self):
        response = await self.post("/api/teams", json={
            "name": "Phoenix Suns",
            "sport": "basketball",
        })

        response.assert_status(201).assert_json(lambda json: (
            json.where("id", lambda v: isinstance(v, int))
                .where("name", "Phoenix Suns")
                .where_type("sport", "string")
                .has("coach", lambda coach: (
                    coach.where("name", "Mike Budenholzer").etc()
                ))
                .missing("internal_notes")
                .etc()
        ))

    async def test_list_arizona_teams(self):
        response = await self.get("/api/teams/arizona")

        # Shape check: a list of teams, each with name + sport.
        response.assert_ok().assert_json_structure({
            "teams": {"*": ["name", "sport"]},
        })

        # Value checks with the fluent API.
        response.assert_json(lambda json: (
            json.has("teams", 3)
                .has("teams.0", lambda team: (
                    team.where("name", "Phoenix Suns")
                        .where_all_type({"name": "string", "sport": "string"})
                        .etc()
                ))
                .etc()
        ))

Not Yet Available ​

The following methods exist as placeholders and raise NotImplementedError; they are planned for a future release:

  • where_contains(key, expected)
  • count_between(key, min, max)