Testing aiodrf applications¶
Test response contracts, authorization, database effects and cleanup, not only the coroutine status of a handler. Use ordinary Django/DRF assertions for synchronous endpoints and the async client for async application paths.
Regression-driven changes¶
For a feature or bug fix, start with a test of the observable contract and confirm its failure before changing the implementation. After the fix, run that test and the affected integration cases. Compare with ordinary DRF when preserving its validation, response or override behavior. Test default and opted-in execution separately; a fast path must not bypass authorization or cleanup.
The project TDD policy specifies the evidence expected in a PR, including checks that could not be run. For an application, retain the same regression discipline without copying the framework's entire version matrix. Documentation-only changes use documentation contracts.
pytest configuration¶
Install pytest, pytest-django and pytest-asyncio in the application's development dependency group. A minimal pyproject.toml configuration is:
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "project.settings"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = ["error"]
Use a dedicated test database. pytest-django refuses database access without the appropriate fixture or marker. Never select a production database for test runs, migrations or example applications.
HTTP contracts¶
import pytest
from aiodrf.test import AsyncAPIClient
@pytest.mark.django_db(transaction=True)
async def test_article_creation():
client = AsyncAPIClient()
response = await client.post("/articles/", {"title": "Example"}, format="json")
assert response.status_code == 201
assert response.data["title"] == "Example"
transaction=True is appropriate when worker threads or independent requests
must observe committed data. Django TestCase and pytest's normal db fixture
provide useful rollback isolation but are not substitutes for transaction,
locking or cross-connection tests. Async ORM entry points still need database
permission from pytest-django.
force_authenticate() isolates permissions and view behavior. Add separate
tests with actual credentials to exercise authentication, expiry, malformed
headers and the 401 challenge. Use
AsyncAPIClient(enforce_csrf_checks=True) for session-authenticated mutations.
Verify object permissions on list and detail endpoints and ensure denied
writes leave the database unchanged.
AsyncAPIClient and AsyncAPIRequestFactory accept DRF's forms for
headers: HTTP_* keywords and headers=, in the constructor, per request
and in credentials(). They differ from DRF's APIClient in two ways:
- A request's own header takes precedence over
credentials(); in DRF's client the credentials win. The constructor's headers come last. force_authenticate(None)stops forcing authentication but does not log out a session, as DRF's does; callawait client.alogout()for that.
Serializer and hook tests¶
from rest_framework import serializers
from aiodrf import aio
class Input(serializers.Serializer):
quantity = serializers.IntegerField(min_value=1)
async def test_invalid_quantity():
serializer = Input(data={"quantity": 0})
assert not await aio.is_valid(serializer)
assert serializer.errors["quantity"][0].code == "min_value"
For each custom sync/async hook, test the inherited implementation, an override
and a super() call. Test both Python and rendered JSON values for compiled
serializers, including invalid input, PATCH, nulls, decimals and dates. An
equivalent ordinary DRF serializer is a useful reference. Do not assert only
that a backend was selected: unsupported cases may correctly fall back to DRF.
ASGI application and lifespan¶
import httpx
from asgi_lifespan import LifespanManager
from project.asgi import application
async def test_ready():
async with LifespanManager(application) as manager:
transport = httpx.ASGITransport(app=manager.app)
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/ready/")
assert response.status_code == 200
Use manager.app, which supplies lifespan state. HTTPX's ASGI transport does
not run lifespan automatically. Its in-process transport does not reproduce
socket backpressure, proxy buffering or the timing of client disconnects; test
those with a real server (see deployment behaviour).
Concurrency and resources¶
Create independent clients, serializer instances and request context per concurrent operation. Use barriers/events to reproduce races; do not rely on arbitrary sleeps. Test cancellation while work is active, verify owned tasks are joined, and assert clients/generators are closed. Cancellation does not terminate a synchronous database worker or undo its completed transaction.
Use aiodrf.test.count_hops() only for targeted aiodrf scheduling regressions.
It does not count all Django middleware adaptation or database-driver threads.
Use query assertions and a profiler alongside it. Separate latency benchmarks
from functional tests; never make shared CI runners enforce microsecond limits.
Coverage and warnings¶
An application can run pytest --cov=your_app --cov-branch --cov-report=term-missing.
Choose a threshold based on its own behavior and risk. This repository's
combined library gate is 85%; it includes optional runtime modules rather than
excluding untested integrations. The contribution guide
explains unit/integration separation and combining reports.
Keep deprecations as errors. Assert intentional compatibility warnings with
pytest.warns(..., match=...); do not disable a warning category globally.
Third-party deprecations must have a bounded version-specific record and a
removal condition. Fix deprecated calls in application code instead of hiding
them with filterwarnings.