Using pytest with Django

When it comes to testing in python pytest is my favorite testing tool. pytest is a testing framework that strips out boilerplate and adds a whole bunch of sensible utilities to make your tests more pythonic. In this post we'll cover how to add that awesomeness to a Django project.

Comparing unitest to pytest

unittest

import unittest

class TestExample(unittest.TestCase):
    def test_hello_world(self):
        self.assertEqual("hello world", "hello world")

pytest

def test_hello_world():
    assert "hello_world" == "hello_world"

In our newer Django projects we've been using pytest instead of the default test runner. Django's prefer testing is built on top of unittest. Luckily for any pytest fans, this makes it easy to have pytest be a drop in replacement without having to change a single test.

How?

Let's dive into how you can setup pytest on your Django projects. For this post, I've created an repo with a dummy Django 1.8 project. It's local development environment is managed by Docker + Docker Compose.. All code examples are pulled from there.

We'll need one more library in addition to pytest to get everything working smoothly with Django. pytest-django takes care of replicating Django's existing testing functionality into pytest.

In our requirements-dev.txt we've can add in the two packages to be installed.

pytest==2.7.2
pytest-django==2.8.0

These were the latest versions when this post was written, be sure to check pypi if either pytest or pytest-django have been updated.

We need to bootstrap pytest with our Django project settings.

pytest-django's documentation recommends a few different ways to achieve this. I'd advocate loading them in through a global conftest.py

In pytest any conftest.py files are invoked before any tests are run. They provide a convenient method to setup hooks or configure any settings for our tests. Where the conftest.py lives dictates the scope of where it applies. If present in the root test folder, hooks declared will apply to all tests. If present in a specific module, hooks will only apply to tests in that module.

tests/
├── conftest.py # Applies to all tests
└── example/
    ├── conftest.py # Applies only to tests in this module/folder.
    ├── test_foo.py

In our case, we want the conftest.py to apply to all tests therefore we place it in our tests root directory.

import os
import django
from django.conf import settings

# We manually designate which settings we will be using in an environment variable
# This is similar to what occurs in the `manage.py`
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.config.settings')


# `pytest` automatically calls this function once when tests are run.
def pytest_configure():
    settings.DEBUG = False
    # If you have any test specific settings, you can declare them here,
    # e.g.
    # settings.PASSWORD_HASHERS = (
    #     'django.contrib.auth.hashers.MD5PasswordHasher',
    # )
    django.setup()
    # Note: In Django =< 1.6 you'll need to run this instead
    # settings.configure()

If setup correctly, you should now be able to run the test suite. Instead of running tests through manage.py you run them through the py.test command directly.

$ docker-compose run web py.test
=== test session starts ===
platform linux -- Python 3.3.6 -- py-1.4.30 -- pytest-2.7.2
rootdir: /code, inifile:
plugins: django
collected 3 items

tests/integration_tests/example/test_models.py ..
tests/unit_tests/example/test_helpers.py .

=== 3 passed in 2.95 seconds ===

To run a specific test in a module/file, you just include the path after the command, like so py.test <path>.

docker-compose run web py.test tests/integration_tests/example/test_models.py
=== test session starts ===
platform linux -- Python 3.3.6 -- py-1.4.30 -- pytest-2.7.2
rootdir: /code/tests/integration_tests/example, inifile:
plugins: django
collected 2 items

tests/integration_tests/example/test_models.py ..

=== 2 passed in 2.92 seconds ===

To run a specific test, point py.test to a specific file and test name, like so py.test <path_to_file>::<name_of_test>.

$ docker-compose run web py.test tests/integration_tests/example/test_models.py::test_save
=== test session starts ===
platform linux -- Python 3.3.6 -- py-1.4.30 -- pytest-2.7.2
rootdir: /code/tests/integration_tests/example, inifile:
plugins: django
collected 2 items

tests/integration_tests/example/test_models.py .

=== 1 passed in 2.94 seconds ===

Existing tests.

As mentioned previously, any existing Django unittest style tests will work out of the box. Here is an example.

# Any existing `unittest` style tests still work without any changes needed.
from django.test import TestCase


class ExampleTestCase(TestCase):
    def setUp(self):
        self.maven = Dog(name="Maven", breed="corgi")
        self.maven.save()

    def test_save(self):
        self.assertEquals(self.maven.name, "Maven")
        # You can mix in pytest's `assert` approach!
        assert self.maven.breed == "corgi"

Database testing

One key difference to watch out for is running pytest style tests against the database.

By default, pytest-django takes a conservative approach to enabling database access in tests. Any pytest style tests will fail if they try to access the database. In order to allow database access to a test, you need add a py.test mark decorator like so...

from example.models import Dog
import pytest


# If your tests need to use the database and want to use pytest
# function test approach, you need to `mark` it.
@pytest.mark.django_db
def test_save():
    maven = Dog(name="Maven", breed="corgi")
    maven.save()
    assert maven.name == "Maven"
    assert maven.breed == "corgi"

That's it! You can now take full advantage of pytest in your Django project.

Cameron Maske

I am the Lead Architect Maven at TrackMaven.

Check out my open source efforts on Github. Want to get in touch? Feel free to drop me an email or tweet.

Comments