A friend asked me about this recently, so I thought I’d write up what I do.

First, use Pytest. It is good. It is your friend. It makes running tests a dee-lite. The builtin Django test runner is fine, but I much prefer writing and configuring Pytest.

Next: where do I put my tests? I’ve finally come around to some conventional wisdom on this. I like to put all my tests in a tests directory in my project root. If I have JS tests too (and I do, don’t you?) then they’re in tests/js and the Python tests are in tests/py. Inside that directory, I have directories mirroring the module structure of my app, but with the leaf nodes prefixed with test_, per Pytest defaults. So like this:

- my_repo/
  +- my_project/
  |  +- my_app/
  |     +- models.py
  +- tests/
     +- py/
        +- my_project/
           +- my_app/
              +- test_models.py

Why do we put our tests in a mirrored directory structure, and why do we prefix test modules with test_? First, we put tests under the tests root so we can, in principle, exclude them from builds. This is usually nonissue for me. We also put them here so that in our GitHub PRs, the tests are all in a solid block and not interleaved with code changes. We mirror the directory structure so we know what test files relate to what source files. And finally, we prepend test_ because we may well have modules in our test directories that provide helpful utilities to our tests that would otherwise have name collisions with similarly named modules in our source. Don’t name your modules utils, but if you do, this will make your life easier.

Next, what Pytest packages do I use? In addition to installing Pytest itself in my dev dependencies, I also use pytest-django, pytest-factoryboy, pytest-cov, and pytest-sugar. The first two provide useful tools for integrating with Django, the latter two provide better and more meaningful test output. Sometimes, I will also use pytest-asyncio, if I have something like an async websocket consumer.

Next, how do I configure Pytest? I use pytest.ini in my repo root, and it looks somewhat like this:

[pytest]
DJANGO_SETTINGS_MODULE = my_project.settings
testpaths = tests/py
addopts =
  --exitfirst
  --assert=plain
  --tb short
  --cov=my_project
  --cov-report html
  --cov-report term:skip-covered
filterwarnings =
  ignore::django.utils.deprecation.RemovedInDjango40Warning
  ignore:.*importing the ABCs.*:DeprecationWarning
  ignore::RuntimeWarning

I also use a .coveragerc in my repo root, like this:

[run]
omit =
  my_project/asgi.py
  my_project/wsgi.py
[report]
show_missing = True
fail_under = 100

Finally, how do I run my tests? Well, just pytest. All the rest is in configs. If the coverage is at the desired 100%, then the output will be succinct, and if it’s not, we’ll see missing lines in the terminal, and can always open htmlcov/index.html to see it in-browser in detail.

I hope this helps! Let me know if there are things you like, things you’d change, or questions you still have!