| name | litestar-testing |
| description | Auto-activate for test_*.py, conftest.py, litestar.testing, TestClient, AsyncTestClient, create_test_client, create_async_test_client, anyio, Guard mocks, DI overrides, or handler tests. Not for generic pytest. |
litestar-testing
Litestar-specific testing patterns built on pytest + anyio. Covers:
TestClient vs AsyncTestClient โ when to use each
@pytest.mark.anyio setup
- 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)
- Autowire discovery and cache isolation (see
../litestar-autowire/references/testing.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, never Optional[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 Litestar tests use
@pytest.mark.anyio by default; do not mix AnyIO and pytest-asyncio auto modes.
- Prefer
AsyncTestClient for new code; TestClient only 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 |
from litestar.testing import AsyncTestClient
async def test_index(async_client: AsyncTestClient):
resp = await async_client.get("/")
assert resp.status_code == 200
from litestar.testing import TestClient
def test_index(client: TestClient):
resp = client.get("/")
assert resp.status_code == 200
anyio Setup
import pytest
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
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
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. Test the real
guard with fake identity or authorization providers. Build a fresh app with
replacement providers; Litestar has no mutable app.dependency_overrides
registry.
from litestar.di import Provide
@pytest.fixture
async def async_client() -> AsyncGenerator[AsyncTestClient, None]:
fake_users_service = FakeUserService()
async def provide_fake_users_service() -> UserService:
return fake_users_service
test_app = create_app(
dependencies={
"users_service": Provide(provide_fake_users_service),
},
)
async with AsyncTestClient(app=test_app) as client:
yield client
Mocking DI Dependencies
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock
import pytest
from litestar.di import Provide
from litestar.testing import AsyncTestClient
@pytest.fixture
async def async_client() -> AsyncGenerator[tuple[AsyncTestClient, AsyncMock], None]:
fake_email = AsyncMock()
async def provide_fake_email() -> AsyncMock:
return fake_email
app = create_app(
dependencies={
"email_service": Provide(provide_fake_email),
},
)
async with AsyncTestClient(app=app) as client:
yield client, fake_email
For isolated handler tests, pass replacements directly to
create_async_test_client(..., dependencies={...}). Do not mutate a
constructed app; rebuilding preserves dependency resolution and prevents
parallel tests from sharing overrides.
Integration with pytest-databases
Combine pytest-databases fixtures with the app fixture. See ../pytest-databases/SKILL.md.
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
resp = await async_client.get("/", headers={"Authorization": "Bearer token"})
async_client.cookies.set("session", "abc123")
resp = await async_client.get("/")
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
assert resp.status_code == 200
assert resp.json() == {"id": 1, "name": "Alice"}
assert resp.headers["content-type"].startswith("application/json")
assert "HX-Trigger" in resp.headers
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
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 by
constructing a fresh app or test client with replacement Provide instances.
Avoid real network calls in tests.
Step 5: Mock Guards When Needed
Build a fresh app with fake identity or authorization providers. Register a
no-op guard only when the test intentionally excludes authentication behavior;
never patch route internals on a shared app.
Step 6: Write Tests
- One assertion concern per test.
- Use
@pytest.mark.parametrize for input variations.
- Use
AsyncTestClient for 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 for new Litestar async tests โ keep pytest-asyncio only when a project already uses it explicitly, and never mix auto modes.
- 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
AsyncTestClient over TestClient for 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.
- Build a fresh app for dependency replacements โ Litestar has no mutable
dependency-override registry, and shared app mutation races under parallel
tests.
- Use
pytest-databases for 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 on request.htmx need both branches covered.
- Mock email via
backend="memory" / InMemoryBackend โ see ../litestar-email/SKILL.md.
Validation Checkpoint
Before delivering Litestar tests, verify:
Example
Task: Test an account creation endpoint that hits Postgres, sends a welcome email via SAQ, and is guarded by an auth check.
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, Mock
import pytest
from litestar import Litestar
from litestar.di import Provide
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) -> tuple[Litestar, AsyncMock]:
from app import create_app
from app.config import Settings
fake_queue = AsyncMock()
fake_task_queues = Mock()
fake_task_queues.get.return_value = fake_queue
async def provide_fake_task_queues() -> Mock:
return fake_task_queues
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,
dependencies={
"task_queues": Provide(provide_fake_task_queues),
},
),
fake_queue,
)
@pytest.fixture
async def async_client(
app: tuple[Litestar, AsyncMock],
) -> AsyncGenerator[tuple[AsyncTestClient, AsyncMock], None]:
test_app, fake_queue = app
async with AsyncTestClient(app=test_app) as client:
yield client, fake_queue
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
References Index
- Async Testing โ anyio setup, async fixtures, context manager testing, and common pitfalls.
Cross-References
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
Shared Styleguide Baseline