Product at Otovo 🏗


At Otovo, one of our core software systems is a Django application written in Python. For a large codebase, unit tests is an integral part. This is especially true for a large Python application, as there is no compiler to help you out (even though type annotations and mypy do help).

Even though we are working on a Django application, and Django has internal test tooling, we have decided to use pytest. Among other things, we like that:

And in addition to this, plugins like pytest-django makes it easy to test a Django application.

Below we will give a short introduction on how to use pytest in a django application.

Pytest settings

First off, we are using setup.cfg to configure how we run pytest. For example, when we run pytest . it is actually the equivalent of running pytest . --allow-hosts=127.0.0.1 --reuse-db --disable-warnings.

 addopts =
    # forbid external i/o in tests
    --allow-hosts=127.0.0.1
    # we explicitly pass --create-db in our test workflows
    --reuse-db
    --disable-warnings

Asserts

When writing asserts in pytest, there is no need to use custom assert functions. This is in contrast to Python’s built-in unit testing framework, where I’ve always had to rely on my IDE’s auto completion feature to find the assert method I want (I’m looking at you, self.assertSequenceEqual). With pytest you only need assert.

assert num_panels == 42
assert kilowattpeak_values == [8, 11, 12]
assert errors is None

Fixtures

Fixtures are a great way of managing the dependencies for your unit tests. You use a fixture by decorating a function with the @pytest.fixture decorator. You can now use the name of that function as arguments to your test functions

class Panel:
    def __init__(self, name, power):
        self.name = name
        self.power = power

    def power_in_kw(self):
        return self.power / 1000


@pytest.fixture
def futura_sun_panels():
    return [Panel("Futura Sun", 360), Panel("Future Sun", 380)]


def average_kw(panels):
    return sum(panel.power_in_kw() for panel in panels) / len(panels)


def test_average_kw(futura_sun_panels):
    avg_power = average_kw(futura_sun_panels)

    assert avg_power == 0.37

Fixtures can be shared by multiple test modules by placing them in files called conftest.py, which are automatically imported when using pytest.

Fixtures are also very useful when working with test databases. If you need to test a function which handles a contract in some way, just invoke the contract_db fixture (here we also make use of the db fixture from pytest-django

from django.db import models


class Contract(models.Model):
    ...


@pytest.fixture
def contract_db(db):
    return Contract.objects.create(...)


def function(contract):
    return isinstance(contract, Contract)


def test_function(contract_db):
    assert function(contract_db) == True

This way we don’t need to manually create a new Contract model instance (with all its nested relations) for each new test.

One thing to warn about however, is to not abuse db-fixtures where they’re not strictly needed. For something like an API test, where you need to create data in the database for the API to return, db-fixtures are perfect, but creating resources in the database for every test quickly gets expensive, and slows down the test suite.

An alternative to running Contract.objects.create(...) is to instantiate a model instance, without invoking the database. This means we’re still able to interact with the model in a test, but we do so in-memory, saving ourselves a lot of compute:

@pytest.fixture
def contract():
    return Contract(...)


def function(contract):
    return isinstance(contract, Contract)


def test_function(contract):
    assert function(contract) == True

Testing with factory boy

Plain fixtures are great for many things, but with a lot of database relations, we can suddenly end up with a net of fixtures that is hard to untangle. We therefore make use of factory boy to simplify model creation.

A factory is just a representation of a model, so where you have models,

class House(models.Model):
    id = models.UUIDField(...)
    name = models.CharField(.., unique=True)  # every house needs a name

class Window(models.Model):
    id = models.UUIDField(...)
    house = models.ForeignKey(House, ..)
    width = models.IntegerField(...)
    height = models.IntegerField(...)
    depth = models.IntegerField(...)

factory boy requires you to configure factory representations of the same models before we can get started. In other words, there’s a bit of overhead at the start, but it’s worth it once you get started writing tests! The factory representations are just abstract models and look like this

import factory  # this is factory-boy

from .models import House, Window


class HouseFactory(factory.django.DjangoModelFactory):
    name = FuzzyText(length=10)

    class Meta:
        model = House
        django_get_or_create = ['name']


class WindowFactory(factory.django.DjangoModelFactory):
    house = factory.SubFactory(HouseFactory)
    width = FuzzyInteger(1, 10)
    height = FuzzyInteger(1, 10)
    depth = FuzzyInteger(1, 10)

    class Meta:
        model = Window

Now, when the factories are set up, and we’ve created the relations as they exist in the original models, we can very easily start using them to generate test-data like this:

def window_count(house):
    return house.windows_set.count()


def test_function(db):
    # our house should be called `test1`
    house = HouseFactory.create(name="test1")

    # and it should have 100 really tall windows
    windows = WindowFactory.create_batch(100, house=house, height=1000)

    assert window_count(house) == 100

Here we’re using the db fixture because we’re using the create build strategy. This means we’re creating our models in the database. If we don’t require the database for the function we’re testing, we can create an in-memory instance using the build strategy, like this:

def get_house_name(house):
    return house.name


def test_get_house_name():
    house = HouseFactory.build(name="test1")
    assert get_house_name(house) == "test1"

It is worth noting that the build strategy is propgated to the subfactories, so we only need one set of factory models to build, create or stub a model and all its relations:


In [1]: w = WindowFactory.build()

In [2]: Window.objects.all().count()
Out[2]: 0

In [3]: House.objects.all().count()
Out[3]: 0

In [4]: w = WindowFactory.create()

In [5]: Window.objects.all().count()
Out[5]: 1

In [6]: House.objects.all().count()
Out[6]: 1

Lastly, if you find that you’re repeating a lot of creation logic using factory boy, you can of course just create a pytest fixture using factories as well! Re-using the example from the above, our fixtures should looks like this:

@pytest.fixture
def contract_db(db):
    return ContractFactory.create(...)

@pytest.fixture
def contract():
    return ContractFactory.build(...)

We hope this can be of use if you want to use pytest for your Django project!

Oh, by the way - Did you know we’re hiring? Check out https://careers.otovo.com :-)