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:
- there is less boilerplate
- plain
assert
statements are simple and concise - dependencies can be managed with fixtures
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 :-)