Testing¶
A framework is only as testable as the seams it gives you. Test a real feature and you immediately need three things: a way to drive the app like a real client, a way to not actually send the email or hit the payment API, and a way to check the database changed. arvel ships all three — a real HTTP test client that runs your app through the same stack production uses, recording fakes for side effects (mail, queue, events), and database assertions — so a feature test reads like a description of the behaviour, not a pile of mocks.
This page covers the test client, fakes, database assertions, and freezing time.
Needs the [http] and [dev] extras
The helpers in arvel.testing are core. The test client needs uv add 'arvel[http]', and the
test runner + factories (pytest, pytest-asyncio, polyfactory, faker) come with 'arvel[dev]'.
HTTP tests¶
client(asgi) wraps Litestar's TestClient over your app, so requests run the actual routing,
middleware, and handlers — and every verb call (get/post/put/patch/delete) returns a
response with expressive assertions instead of a bare status code to check by hand:
from arvel.testing import client
def test_homepage():
c = client(app.as_asgi())
c.get("/").assert_ok().assert_see("Welcome")
Because it's the real Litestar client, the OpenAPI schema, validation, and middleware all run —
you're testing the app, not a mock of it. .raw is the escape hatch to the underlying httpx
response for anything not covered below.
Response assertions¶
Every assertion returns the response, so they chain:
response = c.post("/users", json={"name": "Ada"})
response.assert_created()
response.assert_json({"data.name": "Ada"}) # subset match — extra keys are tolerated
response.assert_json_path("data.id", 1) # dotted path into the parsed JSON
response.assert_header("content-type", "application/json")
| Assertion | Checks |
|---|---|
assert_status(n) |
the exact status code |
assert_ok() / assert_created() / assert_no_content() |
200 / 201 / 204 |
assert_not_found() / assert_forbidden() / assert_unauthorized() / assert_unprocessable() |
404 / 403 / 401 / 422 |
assert_redirect(to=None) |
a 3xx status, and (with to) the Location header — pass follow_redirects=False to the request so the client doesn't chase the redirect itself |
assert_json(fragment) |
a dotted-key subset of the parsed JSON (extra keys tolerated) |
assert_json_path(path, value) |
one dotted-key value ("items.0.id" indexes into a list) |
assert_json_count(n, path=None) |
len(...) at path (or the root) |
assert_json_missing(fragment) |
none of the dotted keys are present |
assert_see(text) |
the raw body contains text |
assert_header(name, value=None) |
the header is present (and, with value, matches) |
Mocking¶
Swap a recording fake behind a facade and assert what would have happened — no email sent, no job queued, no notification delivered, no real HTTP request made:
from arvel.testing import fake, fake_notifications, fake_http, reset_fakes
from arvel import Mail, Queue, Event
def test_registration_sends_welcome():
mail = fake(Mail)
queue = fake(Queue)
notifications = fake_notifications()
register_user({"email": "[email protected]"})
mail.assert_sent(WelcomeMail)
queue.assert_pushed(ProvisionWorkspace)
notifications.assert_sent_to(user, WelcomeAboard)
reset_fakes() # restore real implementations (do this in teardown)
| Fake | Assertions |
|---|---|
fake(Mail) |
assert_sent(Mailable) · assert_nothing_sent() |
fake(Queue) |
assert_pushed(Job) · assert_nothing_pushed() |
fake(Event) |
assert_dispatched(EventType) |
fake_notifications() |
assert_sent_to(notifiable, cls, cb=None) · assert_not_sent_to(...) · assert_nothing_sent() · assert_count(n) |
fake_bus() |
same object as fake(Queue) — assert_dispatched(Job)/assert_not_dispatched(Job) |
fake_http(mapping=None) |
Http.fake(...) under the hood, returned for assert_sent/assert_not_sent/assert_sent_count/recorded — see HTTP Client for the mapping/Http.response(...) shape |
fake_storage(disk="local") |
see File Storage |
Call reset_fakes() in teardown so a swapped fake doesn't leak into the next test — it restores
every facade swap plus the faked storage disk, HTTP transport, and notification binding.
Isolating process-global state¶
The default api-group rate limiter and the in-process session store are process-global —
correct for one running app, but they persist across the many app instances a suite builds in one
process, so the throttle eventually returns a spurious 429 mid-suite. Reset them between tests:
import pytest
from arvel.http import reset_rate_limiter, reset_sessions
@pytest.fixture(autouse=True)
def _isolate():
reset_rate_limiter() # clear the in-process rate-limit windows
reset_sessions() # clear the in-process session store
yield
(No effect on a cache-backed limiter/session store — clear that backend instead.)
Database¶
Assert rows exist, don't exist, or are soft-deleted — straight against the connection:
from arvel.testing import assert_database_has, assert_database_missing, assert_soft_deleted
async def test_create_and_delete(db):
await create_post(db, title="Hello")
await assert_database_has(db, "posts", title="Hello")
await delete_post(db, title="Hello")
await assert_soft_deleted(db, "posts", title="Hello") # deleted_at is set
await assert_database_missing(db, "audit_log", action="purge")
Wiring a test database¶
Tests bind models to a throwaway connection directly — no app container needed:
from arvel.database import ConnectionResolver
db = ConnectionResolver() # in-memory sqlite by default; pass {"default": {"url": ...}}
Post.set_connection(db) # class-level: every Post query uses it
...
Post.set_connection(None) # clear before dispose — don't leak a dead resolver
await db.dispose()
For a shared database (e.g. the integration tier's real Postgres), wrap each test in
begin_test_transaction() — every query inside runs on one connection whose transaction is
always rolled back, so the database is untouched between tests:
async with db.begin_test_transaction():
await Post.create(title="scratch") # visible inside the block…
# …and gone here: the transaction rolled back on exit
Console tests¶
cli(app, command, input=None) runs an app-registered command (a Command class on
app.command_classes, or a routes/console.py Console.command(...) closure) against a booted
app and captures its exit code + output:
from arvel.testing import cli
def test_greet_command(app):
result = cli(app, "greet Ada --loud", input=["Bob"]) # input pre-seeds ask()/confirm()/...
result.assert_exit_code(0).assert_output_contains("hello Bob")
input answers prompts (Command.ask/secret/confirm/choice/anticipate) in the order
they're asked — an exhausted or empty seeded answer falls back to the prompt's own default, same as
a real Prompter.
Built-in framework commands aren't reachable this way
cli() only dispatches your commands (app/provider Command classes and
Console.command(...) closures) — a built-in (migrate, make:*, db:seed, ...) isn't. Test
those with Typer's own CliRunner directly:
Freezing time¶
Make time-dependent behavior deterministic by pinning "now" — isolated per async test (see Dates & Time):
from arvel.dates import Date
def test_token_expiry():
Date.set_test_now(Date.parse("2026-01-01T00:00:00+00:00"))
try:
token = issue_token()
assert token.expires_at == Date.parse("2026-01-01T01:00:00+00:00")
finally:
Date.set_test_now(None)
Running tests¶
The suite runs under pytest (async mode auto). The project's quality gate — ruff, mypy
and pyright (strict), import-linter, bandit, the tests, and a line-coverage floor — is one
command (make check / ./tools/validate.sh), the same gate CI enforces.
Integration tests (real services)¶
The unit suite uses fakes/SQLite; an opt-in integration tier exercises arvel against real
infrastructure spun up on demand via testcontainers (needs Docker).
Run it with make test-integration (or pytest -m integration). It covers PostgreSQL (+ pgvector),
MySQL (the ORM + real DATETIME round-trips), Redis (cache/session/throttle), object storage
(RustFS/S3 + Azurite), a real AMQP broker (RabbitMQ/LavinMQ — a job dispatched, consumed, and
executed end to end), and OpenTelemetry export to a live OTLP collector. These catch what fakes
can't — e.g. cross-dialect DDL, broker serialization, and real wire protocols.
It also includes a reference app (test_reference_app.py): a small project/task API — token auth,
validated CRUD, pagination, a model relationship, and a queued job — assembled through the production
fluent bootstrap and driven over HTTP against live PostgreSQL + Redis + RabbitMQ at once, proving the
features compose on real infrastructure, not just in isolation.
Common mistakes & gotchas¶
- Leaking a fake or a frozen clock. Always
reset_fakes()andDate.set_test_now(None)in teardown — a leak makes an unrelated later test fail mysteriously. - Asserting on a fake you didn't swap.
assert_pushedonly sees jobs afterfake(Queue); without the swap they run for real and the assertion has nothing recorded. - Mocking the framework. You rarely need to — the test client runs the real stack and the fakes cover the I/O edges. Reach for a mock only at a genuine third-party boundary.
See also¶
- Dates & Time — freezing time. Queues & Jobs · Mail — what the fakes record.