Contributing Code to pretalx¶
pretalx is a fairly standard Django project. This page only lists the project-specific conventions and details that are easy to miss or get wrong.
Backend¶
Run
just fmtbefore committing to auto-format and lint your code.Mark all user-facing strings for translation, and avoid unnecessary changes to existing translations, as they require manual re-translation in all languages.
Do not put any CSS or JS inline in HTML templates, always use separate files, to make sure your changes comply with our CSP headers.
All user input is validated and rendered through Django’s form layer. Any new input handling should use forms too.
When building a new feature with visible user impact, add it to
doc/changelog.rst.
Frontend¶
pretalx is written in plain JS (no jQuery, no Bootstrap) and plain CSS, though we have some CSS class conventions that are similar to Bootstrap.
Some libraries are vendored in
src/pretalx/static/vendored, most notably HTMX. Use HTMX for interactive UI, and use Django template partials to reduce code duplication in HTMX rendering.JavaScript code should be modern – arrow functions,
const, template literals, etc.
Testing¶
Philosophy¶
Our CI gate is 100% test coverage. This is a forcing function, not a goal in itself — coverage without meaningful assertions is worse than no coverage because it creates false confidence. Every test verifies behaviour, not just that code runs without exceptions.
Tests are documentation. A developer reading a test should understand what the
code under test is supposed to do, including edge cases. Docstrings explaining
why a test exists and why it’s set up a particular way are welcome,
especially for non-obvious arrangements — but a test called
test_dictionary_not_empty does not need a docstring that says
"""Make sure the dictionary is not empty.""".
Tests are fast. Slow tests don’t get run locally.
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. Integration tests make
sure that views work in their full context of middlewares, rendering, routing,
etc.
End-to-end tests: Multi-step user flows that cross view boundaries, still using pytest and the Django test client (no browser automation). These encode the critical paths through pretalx — like the CfP submission process, event creation, sending and accepting invites, building and releasing schedules, and so on — by calling multiple views in sequence and following the resulting redirects and state changes. These tests are expected to be slower than unit or even integration tests.
Assertion quality¶
Every test asserts something meaningful about the system’s behaviour.
Checking only response.status_code == 200 does not count.
When we test for a negative — for example, data that is not leaking — we make sure there is data that could leak.
View tests assert at least one of:
Response context contains the expected data (
assert response.context['form'].instance == expected_obj)Database state changed as expected (
assert Submission.objects.filter(state='accepted').count() == 1, plus checking that the accepted submission is the one that should be accepted)The correct template was used (
assert 'agenda/schedule.html' in [t.name for t in response.templates]) if the view does complex template handling rather than having a fixed template assignedRedirects go to the right place with the right state (
assert response.url == expected_url)Response body contains expected content for API views (
assert response.json()['results'][0]['title'] == expected)
Model/method tests assert:
Return values match expected output for given input
Side effects occurred (objects created, signals sent, state changed)
Edge cases are handled (empty inputs, boundary values,
Nonewhere applicable)Exceptions are raised for invalid states (
with pytest.raises(SpecificException):)
Form tests assert:
form.is_valid()returns the expected boolean andform.errorscontains the specific field and errorform.save()creates/modifies the correct objects with the correct field valuesIf choice fields have complex setups for the available options, the choices match expectations (e.g. only including objects the user is permitted to see)
We prefer equality over membership checks. Membership checks (in) hide
unexpected extras — we use == on lists or sets 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}
The same applies to dicts and strings — we compare the full value, not just a
fragment. When full equality is unwieldy, we compare the important fields
individually with ==.
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
Exact string matching on error messages — we match on error code or field name instead. If there is not enough structural data to do so, that’s a sign to improve the structure.
Asserting that another method was called — we test results, not call graphs.
Unnecessary database saves — we avoid
save()in unit tests when we can.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 views in
src/pretalx/agenda/views/talk.py are tested in
src/tests/agenda/views/test_talk.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.
When a directory contains tests at multiple layers (most commonly views/),
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. This keeps filenames clean
and makes it easy to run an entire layer at once
(just test src/tests/agenda/views/integration/).
Test functions are named test_<thing>_<condition_or_behaviour>:
test_slot_overlaps_when_same_room_and_timetest_cfp_submit_without_permission_returns_403test_schedule_release_sends_speaker_notifications
Not beautiful, but consistent, predictable, and friendly both for grep and for
running selected tests with -k. We do not use test classes — all tests are
top-level functions.
We use the Arrange/Act/Assert pattern for organising code within a test. Visually separate the three sections with blank lines when the test is longer than a few lines.
Tooling¶
pytest as the test runner and coverage.py for coverage tracking. 100% coverage is required for CI to pass.
FactoryBoy for model factories. All
factories live in tests/factories/ and are importable from
tests.factories. Every model that appears in tests has a factory.
Factories produce minimal valid instances — we don’t set optional fields unless
the factory’s purpose requires it.
pytest-django bridges pytest and
Django, exposing client, rf (RequestFactory), admin_client, db,
transactional_db etc. as fixtures, and importantly
pytest.mark.django_db. We also use Django testing helpers like
django.test.override_settings or django.core.mail.outbox (as
djmail.outbox).
pytest fixtures for composing test setups. Complex arrangements (e.g. an
event with a CfP, three submissions in different states, and a partial
schedule) live as fixtures in conftest.py at the appropriate level. For
example, we use fixtures for a base event object that requires a lot of
other objects to get set up. We do not use fixtures to wrap a single
factory call — those go directly in the test. conftest.py fixtures are
reserved for setups that involve multiple objects, relationships, or teardown
logic, or that are used very frequently across many tests.
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.
Test utilities:
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 team:# Minimal organiser user = make_orga_user(event) # Organiser with specific permissions user = make_orga_user(event, can_change_submissions=True) # Add to existing teams (no new team created) user = make_orga_user(teams=[team1, team2])
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. Useful in unit tests to call individual view methods and properties directly:request = make_request(event, user=speaker) view = make_view(TalkView, request, slug=submission.code) assert view.object == submission
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.We set up sensible test settings, including configuring Celery to run in eager mode.
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¶
List views and other views that render collections are 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 a middleware, signal, or
queryset change adds queries, the test breaks early.
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).