Testing#

We try to keep our quality standards high. So, we use different tools to make this possible.

We use mypy for optional static typing. We run tests with pytest framework.

pytest#

pytest is the main tool for test discovery, collection, and execution. It is configured inside pyproject.toml file.

We use a lot of pytest plugins that enhance our development experience. List of these plugins is available inside pyproject.toml file.

Running:

pytest

We also have some options that are set on each run via --addopts inside the pyproject.toml file.

Plugins#

We use different pytest plugins to make our testing process better. Here’s the full list of things we use:

  • pytest-django - plugin that introduces a lot of django specific helpers, fixtures, and configuration

  • django-test-migrations - plugin to test Django migrations and their order

  • pytest-cov - plugin to measure test coverage

  • covdefaults - plugin for coverage to smartly ignore more meaningless lines.

  • pytest-randomly - plugin to execute tests in random order and also set predictable random seed, so you can easily debug what went wrong for tests that rely on random behavior

  • pytest-timeout - plugin to raise errors for tests that take too long to finish, this way you can control test execution speed

Snapshot testing#

We use inline-snapshot to snapshot test values and inline-snapshot-django to snapshot SQL queries directly in our test code.

This works as an advanced form of Django’s assertNumQueries: it lets you see the actual query structure in your test and catch regressions like N+1 queries or unintended changes in SQL.

To add snapshots to a test:

  1. Wrap the code under test with snapshot_queries()

  2. Assert the result against an empty snapshot()

  3. Run the test — inline-snapshot fills in the captured queries automatically

from inline_snapshot import snapshot
from inline_snapshot_django import snapshot_queries

@pytest.mark.django_db
def test_blog_post_get(dmr_client: DMRClient, blog_post: BlogPost) -> None:
    """Ensures that blog posts can be fetched."""
    with snapshot_queries() as snap:  # <---- Capture queries.
        response = dmr_client.get(
            reverse('api:v1:main:blog_post_get', kwargs={'id': blog_post.pk}),
        )

    assert response.status_code == HTTPStatus.OK
    assert snap == snapshot()  # <---- Write empty, first run fills it.
    msgspec.convert(response.json(), type=BlogPostFullPayload)

After the first pytest run the snapshot will be updated in-place:

assert snap == snapshot([
    'SELECT ... FROM main_blogpost WHERE ... LIMIT ...',
])

When the expected output changes, update all snapshots with:

pytest --inline-snapshot=fix

Tweaking tests performance#

There are several options you can provide or remove to make your tests faster:

  • You can use pytest-xdist together with -n auto to schedule several numbers of workers, sometimes when there are a lot of tests it may increase the testing speed. But on a small project with a small amount of tests it just gives you an overhead, so removing it (together with --boxed) will boost your testing performance

  • If there are a lot of tests with database access it may be wise to add –reuse-db option, so django won’t recreate database on each test

  • If there are a lot of migrations to perform you may also add –nomigrations option, so django won’t run all the migrations and instead will inspect and create models directly

  • Removing coverage. Sometimes that an option. When running tests in TDD style why would you need such a feature? So, coverage will be calculated when you will ask for it. That’s a huge speed up

  • Removing linters. Sometimes you may want to split linting and testing phases. This might be useful when you have a lot of tests, and you want to run linters before, so it won’t fail your complex testing pyramid with a simple whitespace violation

mypy#

Running mypy is required before any commit:

mypy .

This will eliminate a lot of possible TypeError and other issues across the project, matching the command used by continuous integration.

However, this will not make code 100% safe from errors. So, both the testing and review process are still required.

mypy is configured via pyproject.toml. Read the docs for more information.

We also use django-stubs to type django internals. This package is optional and can be removed, if you don’t want to type your django for some reason.