Testing¶
Built-in testing tools¶
Django has really good testing tools:
https://docs.djangoproject.com/en/latest/topics/testing/tools
https://docs.djangoproject.com/en/latest/topics/testing/advanced
Just like Django itself, we provide several built-in utilities for testing.
These includes subclasses of django.test.RequestFactory
for sync and async requests. Use them for faster and simpler unit-tests:
DMRRequestFactoryfor sync casesDMRAsyncRequestFactoryfor async ones
We also have two subclasses of django.test.Client
DMRClientfor sync casesDMRAsyncClientfor async ones
What is the difference between the default ones? Not much:
Default
Content-Typeheader is set toapplication/jsonIt is now easier to change
Content-Typeheader as simple as specifyingheaders={'Content-Type': 'application/xml'}to change the content type for XML (or any other) requests and responsesOur test clients are faster, because they use
msgspecif it is available to dump and parse json, instead of a regularjson
Testing styles support¶
We support both:
django.test.TestCasestyled testsAnd pytest-django styled tests
For pytest we also have a bundled plugin with several different fixtures:
1from collections.abc import Iterator
2from typing import TYPE_CHECKING
3
4try:
5 import pytest
6except ImportError: # pragma: no cover
7 print( # noqa: WPS421
8 'Looks like `pytest` is not installed, please install it separately',
9 )
10 raise
11
12if TYPE_CHECKING:
13 # We can't import it directly, because it will ruin our coverage measures.
14 from django.conf import LazySettings
15
16 from dmr.test import (
17 DMRAsyncClient,
18 DMRAsyncRequestFactory,
19 DMRClient,
20 DMRRequestFactory,
21 )
22
23
24@pytest.fixture
25def dmr_client(request: pytest.FixtureRequest) -> 'DMRClient':
26 """Customized version of :class:`django.test.Client`."""
27 from dmr.internal.test import maybe_track_client
28 from dmr.test import DMRClient
29
30 client = DMRClient()
31 maybe_track_client(request, client)
32 return client
33
34
35@pytest.fixture
36def dmr_async_client(request: pytest.FixtureRequest) -> 'DMRAsyncClient':
37 """Customized version of :class:`django.test.AsyncClient`."""
38 from dmr.internal.test import maybe_track_client
39 from dmr.test import DMRAsyncClient
40
41 client = DMRAsyncClient()
42 maybe_track_client(request, client)
43 return client
44
45
46@pytest.fixture
47def dmr_rf() -> 'DMRRequestFactory':
48 """Customized version of :class:`django.test.RequestFactory`."""
49 from dmr.test import DMRRequestFactory
50
51 return DMRRequestFactory()
52
53
54@pytest.fixture
55def dmr_async_rf() -> 'DMRAsyncRequestFactory':
56 """Customized version of :class:`django.test.AsyncRequestFactory`."""
57 from dmr.test import DMRAsyncRequestFactory
58
59 return DMRAsyncRequestFactory()
60
61
62@pytest.fixture
63def dmr_clean_settings() -> Iterator[None]:
64 """Cleans settings caches before and after the test."""
65 from dmr.settings import clear_settings_cache
66
67 clear_settings_cache()
68 yield
69 clear_settings_cache()
70
71
72@pytest.fixture
73def settings(
74 settings: 'LazySettings',
75 dmr_clean_settings: None,
76) -> 'LazySettings':
77 """Customized version of :func:`pytest_django.fixtures.settings`."""
78 return settings
No need to configure anything, just use these fixtures by names in your tests.
You can use plain Django test primitives:
1import json
2from http import HTTPStatus
3
4from django.test import TestCase
5from django.test.utils import override_settings
6from django.urls import path
7
8from examples.testing.pydantic_controller import UserController
9
10urlpatterns = [
11 path('users/', UserController.as_view(), name='users'),
12]
13
14
15@override_settings(ROOT_URLCONF=__name__)
16class TestDjangoBuiltinClient(TestCase):
17 def test_post_user(self) -> None:
18 payload = {'email': 'user@example.com', 'age': 20}
19
20 response = self.client.post(
21 '/users/',
22 data=json.dumps(payload),
23 content_type='application/json',
24 )
25
26 assert response.status_code == HTTPStatus.CREATED
27 response_data = json.loads(response.content)
28 assert response_data['email'] == payload['email']
29 assert response_data['age'] == payload['age']
30 assert isinstance(response_data['uid'], str)
Or use dmr.test helpers when you want JSON defaults and controller-level
testing with request factories:
1import json
2from http import HTTPStatus
3
4from dirty_equals import IsUUID
5from django.http import HttpResponse
6from django.test import TestCase
7from django.test.utils import override_settings
8from django.urls import path
9from typing_extensions import override
10
11from dmr.test import DMRClient, DMRRequestFactory
12from examples.testing.pydantic_controller import UserController
13
14urlpatterns = [
15 path('users/', UserController.as_view(), name='users'),
16]
17
18
19@override_settings(ROOT_URLCONF=__name__)
20class TestDMRHelpers(TestCase):
21 @override
22 def setUp(self) -> None:
23 self.client = DMRClient()
24
25 def test_dmr_client_post_json_by_default(self) -> None:
26 payload = {'email': 'user@example.com', 'age': 20}
27
28 response = self.client.post('/users/', data=payload)
29
30 assert isinstance(response, HttpResponse)
31 assert response.status_code == HTTPStatus.CREATED
32 assert json.loads(response.content) == {
33 'uid': IsUUID,
34 **payload,
35 }
36
37 def test_dmr_request_factory_with_controller(self) -> None:
38 dmr_rf = DMRRequestFactory()
39 payload = {'email': 'user@example.com', 'age': 20}
40
41 request = dmr_rf.post('/users/', data=payload)
42 response = UserController.as_view()(request)
43
44 assert isinstance(response, HttpResponse)
45 assert request.content_type == 'application/json'
46 assert response.status_code == HTTPStatus.CREATED
47 assert json.loads(response.content) == {
48 'uid': IsUUID,
49 **payload,
50 }
Structured data generation¶
Since django-modern-rest is already built around an idea
that we use models for everything, it is quite natural to reuse
these models for tests as well.
For example, one can use
Polyfactory
to build test data from pydantic, msgspec,
@dataclass, or even TypedDict models.
Let’s say you have this code for your controller, using pydantic models:
1import uuid
2
3import pydantic
4
5from dmr import Body, Controller
6from dmr.plugins.pydantic import PydanticSerializer
7
8
9class UserCreateModel(pydantic.BaseModel):
10 email: str
11 age: int
12
13
14class UserModel(UserCreateModel):
15 uid: uuid.UUID
16
17
18class UserController(Controller[PydanticSerializer]):
19 def post(self, parsed_body: Body[UserCreateModel]) -> UserModel:
20 return UserModel(
21 uid=uuid.uuid4(),
22 age=parsed_body.age,
23 email=parsed_body.email,
24 )
Let’s reuse the models for data generation in tests!
1import json
2from http import HTTPStatus
3
4from dirty_equals import IsUUID
5from django.http import HttpResponse
6from polyfactory.factories.pydantic_factory import ModelFactory
7
8from dmr.test import DMRRequestFactory
9from examples.testing.pydantic_controller import UserController, UserCreateModel
10
11
12class UserCreateModelFactory(ModelFactory[UserCreateModel]):
13 """Will create structured random request data for you."""
14
15 __check_model__ = True
16
17
18def test_create_user(dmr_rf: DMRRequestFactory) -> None:
19 # This will return random `UserCreatedModel` instances:
20 request_data = UserCreateModelFactory.build().model_dump(mode='json')
21
22 request = dmr_rf.post('/url/', data=request_data)
23
24 response = UserController.as_view()(request)
25
26 assert isinstance(response, HttpResponse)
27 assert response.status_code == HTTPStatus.CREATED
28 assert response.headers == {'Content-Type': 'application/json'}
29 assert json.loads(response.content) == {
30 'uid': IsUUID,
31 **request_data,
32 }
Which will make your tests simple, fast, and will help you find unexpected corner cases.
Property-based API testing¶
There’s a great tool called schemathesis that can be used to test your API to match your OpenAPI spec.
Official docs: https://schemathesis.readthedocs.io
schemathesis is not bundled together with the django-modern-rest.
You have to install it with:
uv add --group dev schemathesis
poetry add --group dev schemathesis
pip install schemathesis
Now, let’s see how you can generate thousands of tests for your API with just several lines of python code:
1import logging
2import os
3from collections.abc import Iterator
4from http import HTTPStatus
5from http.cookies import SimpleCookie
6from typing import TYPE_CHECKING, Final
7
8import pytest
9import schemathesis as st
10from django.conf import LazySettings
11from django.contrib.auth.models import User
12from django.urls import reverse
13from hypothesis import settings as h_settings
14from hypothesis import strategies
15from schemathesis.specs.openapi.schemas import OpenApiSchema
16
17from django_test_app.server.wsgi import application
18from dmr.test import DMRClient
19from dmr.validation import ResponseValidator
20
21_LOCAL_MAX_EXAMPLES: Final = 25
22_MAX_EXAMPLES: Final = 100 if os.environ.get('CI') else _LOCAL_MAX_EXAMPLES
23
24if TYPE_CHECKING:
25 import tracecov
26
27
28@pytest.fixture(autouse=True)
29def _patch_response_validation(monkeypatch: pytest.MonkeyPatch) -> None:
30 # Patches the response validator class to never validate the responses,
31 # despite their settings. This is needed to test schematesis's
32 # response schema validation and compatibility between two. Not ours schema.
33 # https://github.com/wemake-services/django-modern-rest/issues/776
34 monkeypatch.setattr(
35 ResponseValidator,
36 '_should_validate_responses',
37 lambda *args, **kwargs: False,
38 )
39
40
41@pytest.fixture(autouse=True)
42def _disable_logging(settings: LazySettings) -> Iterator[None]:
43 # Logging has too much output with schemathesis:
44 logging.disable(logging.CRITICAL)
45 yield
46 logging.disable(logging.NOTSET)
47
48
49@pytest.fixture(autouse=True)
50def _modify_integration_settings(settings: LazySettings) -> None:
51 # Schemathesis tests only run meaningfully with DEBUG=False (the test
52 # explicitly skips when DEBUG=True), so there is no value in running
53 # the full suite twice via the parent conftest parametrisation.
54 settings.DEBUG = False
55
56
57# The `transactional_db` fixture is required to enable database access.
58# When `st.openapi.from_wsgi()` makes a WSGI request, Django's request
59# lifecycle triggers database operations.
60# The `admin_user` fixture is required here so that auth can use
61# its credentials (username and password) for authentication.
62# This follows the `pytest-django` pattern for creating user fixtures:
63# https://github.com/pytest-dev/pytest-django/blob/main/pytest_django/fixtures.py#L483
64@pytest.fixture
65def api_schema(transactional_db: None, admin_user: User) -> OpenApiSchema:
66 """Load OpenAPI schema as a pytest fixture."""
67 return st.openapi.from_wsgi(reverse('openapi_json'), application)
68
69
70schema = st.pytest.from_fixture('api_schema')
71
72# Register custom strategies:
73st.openapi.format(
74 'phone',
75 strategies.from_regex(r'^\+7-495-[0-9]{3}-[0-9]{2}-[0-9]{2}$'),
76)
77
78# Register custom auth:
79
80
81@st.auth().apply_to(st.openapi.require_security_scheme('django_session'))
82class _DjangoSessionAuth:
83 def get(
84 self,
85 case: st.Case,
86 ctx: st.AuthContext,
87 ) -> SimpleCookie:
88 dmr_client = DMRClient()
89 response = dmr_client.post(
90 reverse('api:django_session_auth:django_session_sync'),
91 # Username and password are taken from `admin_user` fixture,
92 # which is defined in `pytest-django`:
93 data={'username': 'admin', 'password': 'password'},
94 )
95 assert response.status_code == HTTPStatus.OK, response.content
96 return response.cookies
97
98 def set(
99 self,
100 case: st.Case,
101 data: SimpleCookie,
102 ctx: st.AuthContext,
103 ) -> None:
104 # Set to the case itself:
105 case.cookies.update({
106 cookie_name: cookie.coded_value
107 for cookie_name, cookie in data.items()
108 })
109 assert case.cookies, ctx
110
111
112@schema.parametrize()
113@h_settings(max_examples=_MAX_EXAMPLES)
114def test_schemathesis(
115 tracecov_map: 'tracecov.CoverageMap | None',
116 *,
117 case: st.Case,
118) -> None:
119 """Ensure that API implementation matches the OpenAPI schema."""
120 if tracecov_map is None: # pragma: no cover
121 pytest.skip(reason='missing `tracecov`')
122
123 from tracecov.schemathesis import helpers # noqa: PLC0415
124
125 response = case.call_and_validate()
126 # Record interaction for `tracecov` report:
127 tracecov_map.record_schemathesis_interactions(
128 case.method,
129 case.operation.full_path,
130 [helpers.from_response(case.method, response)],
131 )
What will happen here?
schemathesisloads OpenAPI schema definition from thereverse('openapi')URLThen we will create a top level
schemaobject from theapi_schemapytest fixture. It is needed to create a property-based test caseLastly, we create a generated test case with the help of
@schema.parametrize()
You can also provide settings, like the number of generated tests, enabled rules, auth, etc:
1[warnings]
2# Fail on any warning:
3fail-on = true
4
5[auth.openapi.http_basic]
6# Our own credentials from
7# django_test_app/server/apps/controllers/auth.py
8username = 'test'
9password = 'pass'
10
11[auth.dynamic.openapi.jwt]
12path = '/api/jwt-auth/jwt-obtain-access-refresh-sync/'
13# These are default user credentials from
14# https://github.com/pytest-dev/pytest-django
15payload = { username = 'admin', password = 'password' }
16extract_selector = '/access_token'
17
18[output.sanitization]
19# We don't have any real data to hide, but it is easier to debug:
20enabled = false
When running the test case with
pytest tests/test_integration/test_openapi/test_schema.py
it will cover all your API. In simple cases it might be enough tests.
Yes, you heard right: in simple cases just using schemathesis
can remove the need to write any other integration tests.
Important
Using schemathesis with django-modern-rest is very easy,
because we offer state-of-the-art OpenAPI schema generation.
It will be really hard to satisfy schemathesis with a different framework.
Validating responses¶
schemathesis can also be used in regular
tests to validate the response schema.
See https://schemathesis.readthedocs.io/en/stable/guides/schema-conformance/
Example:
from dmr.test import DMRClient
def test_with_conditional_logic(dmr_client: DMRClient) -> None:
response = dmr_client.post(
'/users',
data={'name': 'Alice'},
)
assert schema['/users']['POST'].is_valid_response(response.json())
API coverage with TraceCov¶
TraceCov can be used as an optional API
coverage layer for django-modern-rest test suites. It complements regular
integration tests and schemathesis runs by showing which OpenAPI operations
and parameters were actually exercised.
Official docs: https://docs.tracecov.sh/
Note
TraceCov is not bundled with the django-modern-rest.
You have to install it.
uv add --group dev tracecov
poetry add --group dev tracecov
pip install tracecov
Why is this better than regular coverage?¶
Most coverage tools measure which lines and branches of your implementation were executed. TraceCov instead measures coverage of your API contract: which OpenAPI operations, parameters, and response variants were exercised.
This matters because “missing contract coverage” often turns into real bugs:
edge cases for parameters, incorrect status codes, or response variants that your
tests never actually reach. With django-modern-rest the OpenAPI schema is
built from the project’s semantic schema (derived from
response validation). That makes TraceCov coverage
tightly aligned with what your implementation claims to support.
Configuration¶
How is that wired with django-modern-rest?
tracecov_mapenables tracking for the whole test run.When
tracecov_mapis active, any test that usesdmr_clientordmr_async_clientis automatically included in the TraceCov report.If you also run
schemathesis, theschemathesistest records which validated requests and responses correspond to which OpenAPI operation, so the report can connect execution back to the spec.
To enable tracking, define a session-scoped tracecov_map fixture.
When tracecov_map is configured and TraceCov is installed, dmr_client
and dmr_async_client automatically register requests in tracecov.
1from typing import TYPE_CHECKING
2
3import pytest
4from django.conf import LazySettings
5
6from dmr.settings import Settings
7
8if TYPE_CHECKING:
9 import tracecov
10
11
12@pytest.fixture(scope='session')
13def tracecov_map() -> 'tracecov.CoverageMap | None':
To enable TraceCov recording for schemathesis runs, make sure your
schemathesis test explicitly records validated interactions into
tracecov_map via record_schemathesis_interactions(...).
1if TYPE_CHECKING:
2 import tracecov
3
4
5@pytest.fixture(autouse=True)
6def _patch_response_validation(monkeypatch: pytest.MonkeyPatch) -> None:
7 # Patches the response validator class to never validate the responses,
8 # despite their settings. This is needed to test schematesis's
9 # response schema validation and compatibility between two. Not ours schema.
10 # https://github.com/wemake-services/django-modern-rest/issues/776
11 monkeypatch.setattr(
12 ResponseValidator,
13 '_should_validate_responses',
14 lambda *args, **kwargs: False,
15 )
16
17
18@pytest.fixture(autouse=True)
19def _disable_logging(settings: LazySettings) -> Iterator[None]:
20 # Logging has too much output with schemathesis:
21 logging.disable(logging.CRITICAL)
What will happen here?
schemathesisexecutes requests generated from your OpenAPI schema.After each
schemathesisrequest is validated, the integration callsrecord_schemathesis_interactions(...)to record which OpenAPI operation and parameters were exercised for that verified response.Independently from
schemathesis, any requests performed throughdmr_clientordmr_async_clientare tracked automatically whentracecov_mapis active.TraceCov aggregates coverage across operations, parameters, keywords, and response coverage.
Tip
If TraceCov is not installed, or when tracecov_map is missing or inactive,
fixtures return regular DMR clients without tracking.
When running your tests:
pytest tests/test_integration/test_openapi/test_schema.py
TraceCov generates a report in various formats. See TraceCov docs for details on the generated coverage report.
In short: run schemathesis and regular integration tests together and get
one unified TraceCov view of what your test suite actually exercised.