Testing (Pytest)
We use pytest to validate that the functionality of the cms works as expected.
In addition, we use the following plugins:
pytest-cov: This plugin produces coverage reports.
pytest-django: Provide a few helpers for Django
pytest-xdist: Enable distributing tests across multiple CPUs to speed up test execution
Welcome to requests-mock’s documentation!: Mocking requests to external APIs
For more information, see Testing in Django and Writing and running tests.
For reference of our test framework and test cases, see tests.
Running Tests
By default ./tools/test.sh runs the suite inside a Docker container that
mirrors the CircleCI environment (same Python, OS and PostgreSQL versions,
see docker-compose.test.yml), so a local run matches CI by construction.
Pass --local to run directly on the host instead.
Run the full test suite (in Docker):
./tools/test.sh
Run the full test suite on the host:
./tools/test.sh --local
Run only unit tests (no database, completes in seconds):
./tools/test.sh -m unit
Run everything except slow parametrized view tests:
./tools/test.sh -m "not slow"
Run a specific test by keyword:
./tools/test.sh -v -k test_tree_mutex
Run with fewer roles for faster local iteration (4 representative roles instead of 11):
QUICK_ROLES=1 ./tools/test.sh
These options can be combined:
QUICK_ROLES=1 ./tools/test.sh -m "not slow" -v
Test Markers
Tests are categorized with pytest markers:
unitPure logic tests with no database or external service dependencies. These run in about one second.
slowTests that take a long time because they run many parametrized variants. This includes the 16
test_view_status_code_*.pyfiles and region duplication tests.
Mark your tests appropriately when adding new ones:
@pytest.mark.unit
def test_my_pure_function():
assert compute(42) == expected
@pytest.mark.slow
@pytest.mark.django_db
@pytest.mark.parametrize("role", ALL_ROLES)
def test_many_variants(role):
...
Test Categories
Unit Tests
Unit tests verify pure logic with no side effects. They do not touch the database, filesystem, or network.
Mark with
@pytest.mark.unitDo not use
@pytest.mark.django_dbImport constants from
tests.constants, nottests.conftestExamples:
test_rounded_hix_value.py,test_translation_utils.py,test_recurrence_rule.py
Integration Tests (standard)
Most tests fall into this category. They use the database via pytest-django’s savepoint rollback mechanism (each test runs inside a transaction that is rolled back after the test).
Mark with
@pytest.mark.django_dbRequest the
load_test_datafixture to access the shared test dataDatabase changes are automatically rolled back after each test
Example:
@pytest.mark.django_db def test_something(load_test_data): region = Region.objects.get(slug="augsburg") assert region.name == "Stadt Augsburg"
Transactional Tests
Some tests need real transactions (e.g., testing signals, tree operations,
or management commands that call TRUNCATE). These use
transaction=True which flushes the database after each test.
Mark with
@pytest.mark.order("last")and@pytest.mark.django_db(transaction=True)Request
load_test_data_transactionalinstead ofload_test_data(it reloads fixtures per test function)The
order("last")marker is required because transactional tests flush the database — running them before non-transactional tests in the same process would destroy the session-scoped test dataExample:
@pytest.mark.order("last") @pytest.mark.django_db(transaction=True) def test_management_command(load_test_data_transactional): call_command("my_command") assert ...
Fixtures
Factory Fixtures (tests/conftest.py)
The create_* fixtures are function-scoped factory fixtures: each returns a
callable that creates the corresponding model instance with sensible defaults
for all required fields, so tests only need to pass the values they care
about. Prefer them over raw objects.create() calls:
@pytest.mark.django_db
def test_page_creation(create_region, create_page, create_page_translation):
region = create_region()
page = create_page(region)
translation = create_page_translation(page, title="My Page")
assert translation.title == "My Page"
Available factory fixtures:
create_language(slug=None, **overrides)create_region(slug=None, **overrides)— also creates a default languagecreate_page(region, parent=None, **overrides)create_page_translation(page, language=None, **overrides)create_event(region, start=None, end=None, **overrides)create_event_translation(event, language=None, **overrides)create_recurrence_rule(**overrides)create_user(username=None, **overrides)
Role Constants (tests/constants.py)
Import role identifiers from tests.constants:
from tests.constants import ROOT, ANONYMOUS, STAFF_ROLES, PRIV_STAFF_ROLES
Available constants:
Individual roles:
ROOT,ANONYMOUS,MANAGEMENT,EDITOR,AUTHOR,EVENT_MANAGER,OBSERVER,CMS_TEAM,SERVICE_TEAM,MARKETING_TEAMRole groups:
WRITE_ROLES,REGION_ROLES,STAFF_ROLES,PRIV_STAFF_ROLES,HIGH_PRIV_STAFF_ROLES,ROLES,ALL_ROLES
Writing New Tests
Adding a View Test
Status code tests (does every role get the correct HTTP status?): Add entries to
tests/cms/views/view_config.py. Each entry is a tuple of(view_name, allowed_roles)grouped by shared URL kwargs. The 16test_view_status_code_*.pyfiles automatically pick up new entries.Behavior tests (does the view do the right thing?): Create a new test file in the appropriate subdirectory under
tests/cms/views/. Requestload_test_dataandlogin_role_user:@pytest.mark.django_db def test_my_view_creates_thing(load_test_data, login_role_user): client, role = login_role_user response = client.post(reverse("my_view", kwargs={...}), data={...}) if role in PRIV_STAFF_ROLES: assert response.status_code == 302 else: assert response.status_code == 403
Adding an API Test
API tests live in tests/api/ and typically compare JSON output against
expected output files in tests/api/expected-outputs/.
Create the test function using the API client
Create the expected output JSON file
Assert that the response matches the expected output
When API output changes (e.g., new fields, changed formatting), regenerate all snapshot files at once:
./tools/test.sh -v -k test_api_result --update-snapshots
Then review the diff with git diff and commit the updated files.
Adding a Management Command Test
Management command tests live in tests/core/management/commands/.
If the command modifies the database destructively, use a transactional test:
@pytest.mark.django_db(transaction=True)
def test_my_command(load_test_data_transactional):
call_command("my_command", "--flag")
assert MyModel.objects.count() == expected
Common Pitfalls
Always add
@pytest.mark.order("last")to transactional tests — while pytest-django sorts transactional tests after non-transactional ones, other collection hooks can reorder tests afterwards and place a transactional test (which flushes the database) before non-transactional ones.@pytest.mark.order("last")from pytest-order re-enforces the correct ordering after all collection hooks have run.Don’t use
serialized_rollback=True— it has FK ordering issues with PostgreSQL. Useload_test_data_transactionalinstead.Clean up state you attach to session-scoped fixtures — database changes are rolled back after every non-transactional test, but the Python objects returned by session-scoped fixtures are not: they live for the whole worker process. The most common case is the
Clientreturned bylogin_role_user, whose cookies survive the test that set them:@pytest.mark.django_db def test_pagination_cookie(login_role_user): client, role = login_role_user client.cookies["page_size"] = "50" try: ... finally: client.cookies.pop("page_size", None)
The same applies to anything else cached in the fixture’s return value — only the rows it points at are reset between tests, not the object itself.
Import role constants from
tests.constants, nottests.conftest.Tree models (Page, LanguageTreeNode) must be created with
Page.add_root()/parent.add_child(), notPage.objects.create().
Coverage
A full run of ./tools/test.sh collects coverage and enforces the minimum
total coverage configured in pyproject.toml (fail_under). Filtered
runs (-k/-m/a test path) are not instrumented at all — they only ever
see a subset of the suite, and skipping the coverage tracer keeps them fast.
The per-container runs in CI do collect coverage but do not enforce the
threshold, since each container runs only its share of the tests.
After each run, the test coverage is uploaded to CodeClimate (see upload-test-coverage).
Test API with WebApp
To test the API in the web app with a different CMS server, open the JavaScript console of the web app and execute:
window.localStorage.setItem('api-url', 'https://cms-test.integreat-app.de')