litestar-testing
Auto-activate for test_*.py, conftest.py, litestar.testing imports, TestClient, AsyncTestClient, create_test_client, @pytest.mark.anyio, Guard mocks, DI overrides, or Litestar handler tests. Use when testing Litestar apps, handlers, lifespan, auth, HTMX, Inertia, or database-back
What it does
litestar-testing
Litestar-specific testing patterns built on pytest + anyio. Covers:
TestClientvsAsyncTestClient— when to use each@pytest.mark.anyiosetup- App + lifespan in tests
- Fixture patterns from canonical litestar-fullstack tests
- Mocking Guards and DI dependencies
- Integration with
pytest-databases(see../pytest-databases/SKILL.md) - Request body / form / multipart / header / cookie testing
- Litestar-specific assertion patterns (Response, headers, cookies)
For JS-side testing (Vitest, Testing Library, Playwright), use the upstream Vitest docs and Litestar's own JS examples. Out of scope here.
Code Style Rules
- PEP 604 unions:
T | None, neverOptional[T] - Test modules MAY use
from __future__ import annotations— they are pure consumer code. - Function-based tests (not class-based)
- One assertion concern per test
- Async tests use
@pytest.mark.anyio(not@pytest.mark.asyncio); Litestar uses anyio internally - Prefer
AsyncTestClientfor new code;TestClientonly for legacy / sync-only flows
Quick Reference
TestClient vs AsyncTestClient
| Client | When to Use | Lifespan | Internals |
|---|---|---|---|
TestClient | Sync test bodies, simple smoke tests | Triggered via context manager | Runs ASGI in a thread pool |
AsyncTestClient | Default for new tests — async test bodies, lifespan-aware fixtures | Native async lifespan | Runs ASGI in the test event loop |
# AsyncTestClient — preferred
from litestar.testing import AsyncTestClient
async def test_index(async_client: AsyncTestClient):
resp = await async_client.get("/")
assert resp.status_code == 200
# TestClient — legacy / sync
from litestar.testing import TestClient
def test_index(client: TestClient):
resp = client.get("/")
assert resp.status_code == 200
anyio Setup
# conftest.py
import pytest
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
# tests/test_x.py
import pytest
@pytest.mark.anyio
async def test_something():
...
Litestar's runtime is anyio-based; do not use pytest-asyncio — it conflicts.
App + Lifespan Fixture
# conftest.py
from collections.abc import AsyncGenerator
import pytest
from litestar import Litestar
from litestar.testing import AsyncTestClient
from app import create_app
@pytest.fixture
async def app() -> Litestar:
return create_app()
@pytest.fixture
async def async_client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]:
async with AsyncTestClient(app=app) as client:
yield client
async with AsyncTestClient(...) runs on_startup / on_shutdown hooks and plugin lifespans (Vite, SAQ, SQLAlchemy session pool, etc.). Without the context manager, lifespan does not fire.
Mocking Guards
Guards are functions of (connection, route_handler) -> None. Mock by overriding dependencies or by registering a no-op guard at the app level for tests:
# conftest.py
from litestar import Litestar
from app import create_app
@pytest.fixture
async def app_with_no_auth() -> Litestar:
"""App with auth Guard replaced by a no-op for tests."""
from app.domain.accounts.guards import requires_active_user
async def allow_all(connection, route_handler) -> None:
return None
app = create_app()
# Swap the guard everywhere it's referenced (depends on app structure)
for route in app.route_handler_method_map.values():
...
return app
Cleaner: use DI override (preferred). If the Guard depends on a service via DI, override the service:
@pytest.fixture
async def async_client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]:
from app.domain.accounts.services import UserService
class FakeUserService(UserService): ...
app.dependencies["users_service"] = lambda: FakeUserService(...)
async with AsyncTestClient(app=app) as client:
yield client
Mocking DI Dependencies
from unittest.mock import AsyncMock
@pytest.fixture
async def async_client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]:
fake_email = AsyncMock()
app.dependencies["email_service"] = lambda: fake_email
async with AsyncTestClient(app=app) as client:
yield client, fake_email
Integration with pytest-databases
Combine pytest-databases fixtures with the app fixture. See ../pytest-databases/SKILL.md.
# conftest.py
pytest_plugins = ["pytest_databases.docker.postgres"]
@pytest.fixture
async def app(postgres_service) -> Litestar:
from app import create_app
from app.config import Settings
settings = Settings(database_url=f"postgresql+asyncpg://{postgres_service.user}:{postgres_service.password}@{postgres_service.host}:{postgres_service.port}/{postgres_service.database}")
return create_app(settings=settings)
The postgres_service fixture starts a Postgres container. Inject its connection details into the app config.
Request Bodies
| Body Type | Pass via |
|---|---|
| JSON | client.post("/", json={...}) |
| Form | client.post("/", data={...}) |
| Multipart (file upload) | client.post("/", files={"file": ("name.txt", b"content", "text/plain")}) |
| Raw bytes | client.post("/", content=b"...") |
| Custom content-type | client.post("/", content=b"...", headers={"Content-Type": "..."}) |
async def test_create_user(async_client):
resp = await async_client.post(
"/api/users",
json={"name": "Alice", "email": "alice@example.com"},
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "Alice"
Headers, Cookies, Auth
# Header
resp = await async_client.get("/", headers={"Authorization": "Bearer token"})
# Cookie
async_client.cookies.set("session", "abc123")
resp = await async_client.get("/")
# Per-request cookies
resp = await async_client.get("/", cookies={"session": "abc123"})
HTMX Requests
async def test_htmx_partial(async_client):
resp = await async_client.get(
"/items/list",
headers={"HX-Request": "true", "HX-Target": "#item-list"},
)
assert resp.status_code == 200
assert "<ul" in resp.text
Response Assertions
# Status
assert resp.status_code == 200
# Body
assert resp.json() == {"id": 1, "name": "Alice"}
# Headers
assert resp.headers["content-type"].startswith("application/json")
assert "HX-Trigger" in resp.headers
# Cookies (set by server)
assert "session" in resp.cookies
Parametrize
import pytest
@pytest.mark.parametrize("payload, expected_status", [
({"name": "valid", "email": "a@b.co"}, 201),
({"name": "", "email": "a@b.co"}, 400),
({"name": "valid", "email": "not-email"}, 400),
])
@pytest.mark.anyio
async def test_create_user_validation(async_client, payload, expected_status):
resp = await async_client.post("/api/users", json=payload)
assert resp.status_code == expected_status
Coverage
pytest --cov=src --cov-report=html
pytest --cov=src --cov-fail-under=90
<workflow>
Workflow
Step 1: Set Up anyio Backend
Add anyio_backend fixture to conftest.py returning "asyncio". Mark async tests with @pytest.mark.anyio.
Step 2: App + Client Fixtures
Build an app fixture that returns a fresh Litestar instance per test (or per session if no shared state). Build an async_client fixture that wraps the app in AsyncTestClient via async with.
Step 3: Add Database Fixtures
If the app talks to a DB, layer in pytest-databases (postgres_service, mysql_service, etc.) and pass connection details into the app config. See ../pytest-databases/SKILL.md.
Step 4: Override DI for Externals
Mock EmailService, HTTP clients, and other side-effect-laden dependencies via app.dependencies[name] = lambda: fake. Avoid real network calls in tests.
Step 5: Mock Guards When Needed
For tests that should bypass auth, override the Guard's underlying service or register a no-op Guard. Prefer DI overrides over patching internals.
Step 6: Write Tests
- One assertion concern per test.
- Use
@pytest.mark.parametrizefor input variations. - Use
AsyncTestClientfor new code. - Include HTMX / Inertia headers when testing those paths.
Step 7: Verify Coverage
pytest --cov=src --cov-fail-under=90. Cover handlers, services, Guards, and at least one happy-path + one error-path per route.
Guardrails
- Use
@pytest.mark.anyio, not@pytest.mark.asyncio— Litestar runs on anyio. Mixing breaks lifespan. - Always
async with AsyncTestClient(app=app)— without the context manager, plugin lifespans (Vite, SAQ, SQLAlchemy) never run, and tests see a half-initialized app. - Prefer
AsyncTestClientoverTestClientfor new tests — the async client matches Litestar's runtime model. - Mock side effects via DI override, not patching — keeps tests isolated from import order and global state.
- Use
pytest-databasesfor real DB testing — never mock SQLAlchemy / sqlspec internals; assertions on mocked queries don't catch real bugs. - Function-based tests — no class-based test containers unless absolutely needed for shared setup.
- One assertion concern per test — failures should pinpoint a single behavior.
- Don't share state between tests — fresh app + fresh DB per test (or per module with explicit cleanup).
- Test the HTMX path with
HX-Request: true— handlers that branch onrequest.htmxneed both branches covered. - Mock email via
InMemoryConfig— see../litestar-email/SKILL.md.
Validation Checkpoint
Before delivering Litestar tests, verify:
-
anyio_backendfixture returns"asyncio" - Async tests use
@pytest.mark.anyio -
AsyncTestClientis wrapped inasync with(lifespan fires) - DI dependencies (email, HTTP clients) are overridden, not patched
- DB-dependent tests use
pytest-databasesfixtures - Guards either pass real auth (with a fixture user) or are bypassed via DI override
- One assertion concern per test; parametrize for input variations
- HTMX-targeted handlers have tests with
HX-Request: true - Coverage gate (
--cov-fail-under) is set in CI
Example
Task: Test an account creation endpoint that hits Postgres, sends a welcome email via SAQ, and is guarded by an auth check.
# conftest.py
from collections.abc import AsyncGenerator
import pytest
from unittest.mock import AsyncMock
from litestar import Litestar
from litestar.testing import AsyncTestClient
pytest_plugins = ["pytest_databases.docker.postgres"]
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture
async def app(postgres_service) -> Litestar:
from app import create_app
from app.config import Settings
settings = Settings(
database_url=(
f"postgresql+asyncpg://{postgres_service.user}:{postgres_service.password}"
f"@{postgres_service.host}:{postgres_service.port}/{postgres_service.database}"
),
)
return create_app(settings=settings)
@pytest.fixture
async def async_client(app: Litestar) -> AsyncGenerator[tuple[AsyncTestClient, AsyncMock], None]:
fake_queue = AsyncMock()
app.dependencies["task_queues"] = lambda: type("Q", (), {"get": lambda self, name: fake_queue})()
async with AsyncTestClient(app=app) as client:
yield client, fake_queue
# tests/test_accounts.py
import pytest
@pytest.mark.anyio
async def test_create_account_persists_and_queues_email(async_client):
client, fake_queue = async_client
resp = await client.post(
"/api/accounts",
json={"email": "alice@example.com", "name": "Alice"},
)
assert resp.status_code == 201
body = resp.json()
assert body["email"] == "alice@example.com"
fake_queue.enqueue.assert_awaited_once()
args, kwargs = fake_queue.enqueue.await_args
assert args[0] == "send_welcome_email"
assert kwargs["email"] == "alice@example.com"
@pytest.mark.anyio
@pytest.mark.parametrize("payload, expected_status", [
({"email": "valid@example.com", "name": "Valid"}, 201),
({"email": "", "name": "Valid"}, 400),
({"email": "valid@example.com", "name": ""}, 400),
])
async def test_create_account_validation(async_client, payload, expected_status):
client, _ = async_client
resp = await client.post("/api/accounts", json=payload)
assert resp.status_code == expected_status
</example>
References Index
- Async Testing — anyio + pytest-anyio setup, async fixtures, context manager testing, and common pitfalls.
Cross-References
- litestar — Litestar fundamentals.
- pytest-databases — Container-based DB fixtures.
- litestar-email —
InMemoryConfigfor email tests. - litestar-saq — Mocking task queues.
JS-side Testing
For Vitest, Testing Library (React/Vue), and component testing, refer to upstream Vitest docs (https://vitest.dev/). This skill covers the Python/Litestar side only.
Official References
- https://docs.litestar.dev/2/usage/testing.html
- https://docs.pytest.org/en/stable/
- https://anyio.readthedocs.io/en/stable/testing.html
Shared Styleguide Baseline
Capabilities
Install
Quality
deterministic score 0.45 from registry signals: · indexed on github topic:agent-skills · 7 github stars · SKILL.md body (13,882 chars)