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 typing import TYPE_CHECKING, Any, Final
5
6import pytest
7import schemathesis as st
8from django.conf import LazySettings
9from django.contrib.auth.models import User
10from django.urls import reverse
11from hypothesis import settings as h_settings
12from hypothesis import strategies
13from schemathesis.specs.openapi.schemas import OpenApiSchema
14
15from django_test_app.server.wsgi import application
16from dmr.validation import ResponseValidator
17
18_LOCAL_MAX_EXAMPLES: Final = 15
19_MAX_EXAMPLES: Final = (
20 50 # noqa: WPS432
21 if os.environ.get('CI')
22 else _LOCAL_MAX_EXAMPLES
23)
24
25if TYPE_CHECKING:
26 import tracecov
27
28
29@pytest.fixture(autouse=True)
30def _patch_response_validation(monkeypatch: pytest.MonkeyPatch) -> None:
31 # Patches the response validator class to never validate the responses,
32 # despite their settings. This is needed to test schematesis's
33 # response schema validation and compatibility between two. Not ours schema.
34 # https://github.com/wemake-services/django-modern-rest/issues/776
35 monkeypatch.setattr(
36 ResponseValidator,
37 '_should_validate_responses',
38 lambda *args, **kwargs: False,
39 )
40
41
42@pytest.fixture(autouse=True)
43def _disable_logging() -> Iterator[None]:
44 # Logging has too much output with schemathesis:
45 logging.disable(logging.CRITICAL)
46 yield
47 logging.disable(logging.NOTSET)
48
49
50@pytest.fixture(autouse=True)
51def _modify_integration_settings(settings: LazySettings) -> None:
52 # Schemathesis tests only run meaningfully with DEBUG=False (the test
53 # explicitly skips when DEBUG=True), so there is no value in running
54 # the full suite twice via the parent conftest parametrisation.
55 settings.DEBUG = False
56
57
58# The `transactional_db` fixture is required to enable database access.
59# When `st.openapi.from_wsgi()` makes a WSGI request, Django's request
60# lifecycle triggers database operations.
61# The `admin_user` fixture is required here so that auth can use
62# its credentials (username and password) for authentication.
63# This follows the `pytest-django` pattern for creating user fixtures:
64# https://github.com/pytest-dev/pytest-django/blob/main/pytest_django/fixtures.py#L483
65@pytest.fixture
66def api_schema(transactional_db: None, admin_user: User) -> OpenApiSchema:
67 """Load OpenAPI schema as a pytest fixture."""
68 return st.openapi.from_wsgi(reverse('openapi_json'), application)
69
70
71schema = st.pytest.from_fixture('api_schema')
72
73# Register custom strategies:
74st.openapi.format(
75 'phone',
76 strategies.from_regex(r'^\+7-495-[0-9]{3}-[0-9]{2}-[0-9]{2}$'),
77)
78
79# TODO: provide `Token` auth as well
80# TODO: restore `django_session` auth, removed in #1550
81
82
83@schema.parametrize()
84@h_settings(max_examples=_MAX_EXAMPLES)
85@pytest.mark.timeout(0)
86def test_schemathesis(
87 tracecov_map: 'tracecov.CoverageMap | None',
88 *,
89 case: st.Case[Any],
90) -> None:
91 """Ensure that API implementation matches the OpenAPI schema."""
92 if tracecov_map is None: # pragma: no cover
93 pytest.skip(reason='missing `tracecov`')
94
95 from tracecov.schemathesis import helpers # noqa: PLC0415
96
97 # `werkzeug` leaves `REMOTE_ADDR` out of the WSGI environ entirely,
98 # while every real server sets it. Code that looks up the client IP
99 # then behaves differently under test than in production: for example
100 # `django-allauth` rate limiting answers `403` when it cannot find one.
101 response = case.call_and_validate(
102 environ_base={'REMOTE_ADDR': '127.0.0.1'},
103 )
104 # Record interaction for `tracecov` report:
105 tracecov_map.record_schemathesis_interactions(
106 case.method,
107 case.operation.full_path,
108 [helpers.from_response(case.method, response)],
109 )
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
21
22[generation]
23# Don't generate `\x00` (NULL) bytes in strings:
24# this thing fails tests against real databases,
25# because \x00 is not accepted by databases by default.
26allow-x00 = false
27
28# Cursor-paginated endpoints answer `400`, when
29[[operations]]
30include-tag = 'model_cursor'
31
32[operations.checks.positive_data_acceptance ]
33expected-statuses = ['2xx', '400']
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())