Testing¶
Philosophy¶
Our CI gate is 100% test coverage. Of course, tests must not treat coverage percentage as a goal in itself: Every test must verify behaviour (see below: Assertion quality).
Tests are documentation. A developer reading a test should understand what the code under test is supposed to do, including edge cases. As a result, we accept more code repetition in tests, so that reading a test’s setup involves as little hunting for setup methods as possible.
Tests are fast because slow tests don’t get run.
Testing layers¶
We separate our tests into three layers: Unit tests, Integration tests and End-to-End tests.
Unit tests: Every function, method, property, or class has at least one test or one parameterisation per exit condition. This includes particularly models, forms, serializers, and all service and utility methods.
Unit testing for views is more tricky: we include methods and properties on
view classes that do not handle the actual request/response flow, except
trivial @context decorated properties. We exclude the request/response
handlers on view classes as well as plain view functions, as we test these in
integration tests. We use RequestFactory instances to replace requests in
unit tests (as opposed to the pytest client fixture in integration tests).
Integration tests: Every view has at least one integration test covering
the happy path, using the various client fixtures (see
Fixtures). Integration tests make sure that views work in their
full context of middlewares, rendering, routing, etc.
End-to-end tests: End-to-end tests follow critical multi-step paths through pretalx that cross view boundaries. This includes most prominently the full CfP submission process, but also other multi-step processes like sending and accepting invites, building and releasing and viewing schedules, and rendering and following pretalx-provided links like e.g. on dashboard tiles. These tests are expected to be slower than unit or integration tests.
Assertion quality¶
Every test asserts something meaningful about the system’s behaviour.
Checking only response.status_code == 200 does not count; the test has to
clearly show what changed, what is rendered (either in content or in structure,
like in an API response or a HTTP redirect), or what else makes it matter.
We make sure to test not only the happy path, but also known and assumed potential problems and regressions, like faulty user input and common classes of security vulnerabilities. This also means that when we test for a negative — for example, data that is not leaking — we make sure there is data that could leak.
Assertions should be specific, so we prefer equality over membership
checks. Membership checks (in) hide unexpected extras — we use ==
instead.
# Bad – passes even if other users snuck in
assert user in mail.to_users.all()
# Good – verifies the exact set
assert list(mail.to_users.all()) == [user]
assert set(mail.submissions.all()) == {sub1, sub2}
Anti-patterns:
assert response.status_code == 200as the sole assertion (unless explicitly testing permission/routing only, with a separate test for the behaviour)assert obj is not Noneor similar existence checks without checking what the object isTesting implementation details (e.g. asserting a specific SQL query) rather than behaviour
Asserting that another method was called — we test results, not call graphs.
Mocks and monkeypatches — we use real factories and
RequestFactoryinstead.
In the rare case that a line truly cannot be covered, we mark it with
pragma: no cover and a comment explaining why.
Test layout¶
Tests live in src/tests/, split into Django apps. Inside each app, the
tests are further split along the code structure and are named to match the
file they test. For example, the rules in src/pretalx/submission/rules.py
are tested in src/tests/submission/test_rules.py.
Some modules are tested on multiple layers: All views have at least unit and
integration tests. Here, we use subdirectories named after the layer instead of
filename suffixes: src/tests/agenda/views/unit/test_talk.py and
src/tests/agenda/views/integration/test_talk.py rather than
test_talk.py and test_talk_integration.py.
Test files are marked with their testing layer at the top of the file,
directly after imports, e.g. pytestmark = pytest.mark.unit, with possible
values being unit, integration and e2e to aid in selective runs
of the test suite.
We use function-based tests. Test functions are named
test_<thing>_<condition_or_behaviour>, for example
test_slot_overlaps_when_same_room_and_time or
test_schedule_release_sends_speaker_notifications. Apart from making tests
easy to find, it also makes it trivial to run selected tests for a changed
interface with -k.
We use the Arrange/Act/Assert pattern for organising code within a test, and we visually separate the three sections with blank lines when the test is longer than a few lines.
Docstrings and comments explaining why a test exists or why it’s set up a
particular way are welcome only for complex test cases. A test called
test_dictionary_not_empty does not need a docstring that says """Make
sure the dictionary is not empty.""".
Tooling¶
We use pytest as the test runner and coverage.py for coverage tracking. We use Django
testing helpers as well as pytest-django for utilities like
django.test.override_settings to change settings without impacting other
tests, or django.core.mail.outbox to track sent emails.
Factories¶
We use FactoryBoy for model factories
that live in tests/factories/. Every model that appears in tests has a
factory. As factories are technically against our principle of making tests
readable without hunting for setup and build functions, we strive to keep them
free of any surprising behaviour, so that the reader ideally never has to read
a factory file.
Fixtures¶
When simple factories do not suffice to set up complex test data that is needed
in multiple tests (e.g. an event with a CfP, three submissions in different
states, and a partial schedule), we make sparing use of pytest fixtures for
composing test setups. Fixtures live in a conftest.py at the deepest
possible layer, so that only fixtures used across multiple test modules appear
in the root-level conftest.py.
Selected fixtures that build data:
user_with_eventyields a user with a team membership granting organiser access to an event.populated_eventprovides an event with as many related objects as possible.organiser_useryields a user with organiser-level access (all_events) to theeventfixture viamake_orga_user. Use withclient.force_login(organiser_user)when a test needs an authenticated organiser.talk_slotprovides a visible, confirmed talk slot on an event’s WIP schedule (includes a speaker and submission), andpublished_talk_slotits published sibling on the currently released schedule.make_imagereturns a factory function that creates a minimal valid 1×1 PNG as aSimpleUploadedFile. Call it asmake_image()ormake_image("custom_name.png").locmem_cachereplaces the defaultDummyCachewith a realLocMemCacheso that cache operations actually store and retrieve data. The cache is cleared before each test for isolation.
Utilities¶
We also provide some test utility functions for repeated tasks:
tests.utils.make_orga_user(event=None, *, teams=None, **team_kwargs)creates a user with organiser access. Whenteamsis given, the user is added to each existing team and no new team is created. Otherwise a new team is created onevent.organiserwithall_events=Trueby default. Pass keyword arguments to customise the teams.tests.utils.make_request(event, user=None, method="get", path="/", headers=None, **attrs)creates a plain DjangoHttpRequestfor view unit tests. Setseventanduser(defaults toAnonymousUser) as attributes on the request. Any extra keyword arguments are set as request attributes (e.g.resolver_match).tests.utils.make_view(view_class, request, **kwargs)instantiates a Django class-based view withrequestandkwargsalready set, without dispatching.tests.utils.make_api_request(event=None, user=None, path="/", data=None, **attrs)creates a DRFRequestfor serializer and API unit tests. Setsevent,user, and any additional keyword arguments (e.g.organiser) as attributes on the underlying Django request.tests.utils.refresh(instance, **updates)updates a model with the optionally passed keyword arguments and then re-fetches it from the database, clearing any@cached_propertydata.
Parametrisation¶
We use pytest.mark.parametrize to collapse related scenarios into a single
test function rather than writing near-duplicate tests. If two tests share the
same arrange/act structure and only differ in inputs and expected outputs, they
become one parametrized test. If the setup differs significantly between
cases, they stay separate.
N+1 query prevention¶
The most common performance problem in a Django project involves n+1 queries on list views and other views that render collections.
To prevent any n+1 query behaviour from the beginning, all of these views have
at least one test that is parameterised with item_count values of 1 and
3 and wrapped in django_assert_num_queries with a literal integer. This
ensures the query count stays constant regardless of data size. When we make
changes to the view itself or to underlying middleware, signals, or even
plugins that add per-object queries, we catch the problem early. When the test
breaks, the type of breakage also clearly shows if we changed the number of
queries across all requests (which is often fine) or if we introduced an actual
n+1 query.
Running tests¶
Run tests with:
just test
Standard pytest flags all work:
just test -k <pattern>: run only tests matching a name pattern. Working on CfP views?just test -k cfp_submit.just test --lf: re-run only tests that failed last time.just test -x: stop at first failure.just test --no-header -rN: minimal output, useful when running the full suite.just test tests/schedule/: run all tests for one app.just test -m "not e2e": run all tests except for e2e tests.
Note
For faster runs, just test-parallel uses multiple CPU cores (with
an optional NUM parameter to specify the number of threads, or
leave empty for an automatic choice).