Adding Django unit tests help you by:
- reducing bugs
- making your code clearer
- allowing you to change your code without breaking the entire application.
This guide shows you how to write unit tests in Django quickly and cleanly, using FactoryBoy and Faker to speed you.
Full video walkthrough is here(featuring me π):
Letβs go
0. Setting Up Your Django Project
- Install packages and create your app
sim
pip install django factory-boy faker
django-admin startproject core .
django-admin startapp sim
- Add your app to your project's settings, open the
settings.py
file in your project directory and add your app to the INSTALLED_APPS list.
INSTALLED_APPS = [
...,
'sim',
...,
]
1. Creating our Models
Our sim
app includes three models: Dish, Ingredient, and Restaurant.
- Add the below to
sim/models.py
from decimal import Decimal
from typing import Iterable
from django.db import models
class Restaurant(models.Model):
name = models.CharField(max_length=100)
address_first_line = models.CharField(max_length=100)
zip_code = models.CharField(max_length=100)
phone_number = models.CharField(max_length=100)
def __str__(self):
return self.name
@property
def address(self) -> str:
return f"{self.address_first_line}, {self.zip_code}"
class Ingredient(models.Model):
name = models.CharField(max_length=100)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return self.name
class Dish(models.Model):
name = models.CharField(max_length=100)
price = models.DecimalField(max_digits=10, decimal_places=2)
ingredients = models.ManyToManyField(Ingredient)
restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE)
def __str__(self):
return self.name
def unit_margin(self, prefetched_ingredients: Iterable[Ingredient] = None) -> Decimal:
"""
The profit margin per dish.
We add the option to prefetch ingredients to reduce the number of database queries where we have many ingredients.
"""
ingredients = prefetched_ingredients or self.ingredients.all()
return Decimal(self.price - self.total_ingredient_cost(ingredients))
@staticmethod
def total_ingredient_cost(ingredients: Iterable[Ingredient]) -> Decimal:
return Decimal(sum(ingredient.unit_price for ingredient in ingredients))
- Create and run migrations for our models:
python manage.py makemigrations
python manage.py migrate
2. Write tests for our models
- Create a directory at
sim/tests
. - In sim/tests, create an empty
__init__.py
file (we need this to detect that our folder contains tests). - Create a file called
test_models.py
insim/tests
and add:
from django.test import TestCase
from sim.models import Restaurant, Ingredient, Dish
from decimal import Decimal
class RestaurantTests(TestCase):
def test_address(self):
"""
Test the __str__ method of the restaurant model.
"""
restaurant = Restaurant(name='Pizza Hut', address_first_line='123 Main Street', zip_code='203302', phone_number='123-456-7890')
expected = "123 Main Street, 203302"
self.assertEqual(expected, restaurant.address)
class DishTests(TestCase):
def setUp(self): # Runs before every test.
self.restaurant = Restaurant.objects.create(name='Le Gavroche', address_first_line='123 Main Street', zip_code='203302', phone_number='123-456-7890')
self.saffron = Ingredient.objects.create(name="saffron", unit_price=Decimal("20.30"))
self.ginger = Ingredient.objects.create(name="ginger", unit_price=Decimal("0.90"))
self.carrot = Ingredient.objects.create(name="carrot", unit_price=Decimal("0.20"))
self.pilchard = Ingredient.objects.create(name="pilchard", unit_price=Decimal("1.20"))
self.yeast = Ingredient.objects.create(name="yeast", unit_price=Decimal("0.12"))
self.xantham_gum = Ingredient.objects.create(name="xantham_gum", unit_price=Decimal("0.06"))
def test_total_ingredient_cost(self):
dish = Dish.objects.create(name='Spiced Carrot Soup', price=Decimal("15.00"), restaurant=self.restaurant)
dish.ingredients.add(self.carrot, self.ginger)
expected_cost = self.carrot.unit_price + self.ginger.unit_price
self.assertEqual(dish.total_ingredient_cost(dish.ingredients.all()), expected_cost)
def test_unit_margin(self):
dish = Dish.objects.create(name='Gourmet Pilchard Pizza', price=Decimal("25.00"), restaurant=self.restaurant)
dish.ingredients.add(self.pilchard, self.yeast, self.xantham_gum)
total_cost = self.pilchard.unit_price + self.yeast.unit_price + self.xantham_gum.unit_price
expected_margin = dish.price - total_cost
self.assertEqual(dish.unit_margin(), expected_margin)
def test_unit_margin_with_prefetch(self):
dish = Dish.objects.create(name='Exotic Saffron Dish', price=Decimal("50.00"), restaurant=self.restaurant)
dish.ingredients.add(self.saffron, self.ginger)
prefetched_dishes = Dish.objects.prefetch_related('ingredients').get(id=dish.id)
expected_margin = dish.price - (self.saffron.unit_price + self.ginger.unit_price)
self.assertEqual(prefetched_dishes.unit_margin(prefetched_ingredients=prefetched_dishes.ingredients.all()), expected_margin)
We can now run our first test scenarios for our new models with the command:
python manage.py test sim.tests
You should see something like:
Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s
OK
Make sure that the tests are detected. If you get 0 tests
, you've probably forgotten to create the __init__.py
as mentioned above.
If your tests fail, then debug your code.
Else, congrats π Let's move on.
3. Add FactoryBoy and Faker to our Django tests
FactoryBoy allows you to define factories for your models, which avoids repeating code when creating python objects during your tests. Faker helps generate realistic-looking fake data.
They are useful for a) making your tests easier to read and b) faster to write as your codebase grows. Letβs add them both.
- Create a file called
factories.py
atsim/factories.py
and add
import factory
from faker import Faker
from .models import Restaurant, Ingredient, Dish
from decimal import Decimal
from factory.django import DjangoModelFactory
fake = Faker()
class RestaurantFactory(DjangoModelFactory):
class Meta:
model = Restaurant
name = factory.Sequence(lambda n: f'Restaurant {n}')
address_first_line = fake.address()
phone_number = fake.phone_number()
class DishFactory(DjangoModelFactory):
class Meta:
model = Dish
name = factory.Sequence(lambda n: f'Dish {n}')
price = Decimal(fake.random_number(2))
restaurant = Restaurant
class IngredientFactory(DjangoModelFactory):
class Meta:
model = Ingredient
name = factory.Sequence(lambda n: f'Ingredient {n}')
unit_price = Decimal(fake.random_number(2))
Update our existing unit tests to use FactoryBoy and Faker
Now we can improve our current tests and reduce duplication. Replace our existing model tests (sim/tests/test_models.py
) with the below:
from django.test import TestCase
from sim.factories import RestaurantFactory, DishFactory, IngredientFactory
from sim.models import Restaurant, Ingredient, Dish
from decimal import Decimal
class RestaurantTests(TestCase):
def test_address(self):
"""
Test the address method of the restaurant model.
"""
restaurant = RestaurantFactory()
expected = f"{restaurant.address_first_line}, {restaurant.zip_code}"
self.assertEqual(expected, restaurant.address)
class DishTests(TestCase):
def setUp(self):
self.restaurant = RestaurantFactory()
self.saffron = IngredientFactory(name="saffron", unit_price=Decimal("20.30"))
self.ginger = IngredientFactory(name="ginger", unit_price=Decimal("0.90"))
self.carrot = IngredientFactory(name="carrot", unit_price=Decimal("0.20"))
self.pilchard = IngredientFactory(name="pilchard", unit_price=Decimal("1.20"))
self.yeast = IngredientFactory(name="yeast", unit_price=Decimal("0.12"))
self.xantham_gum = IngredientFactory(name="xantham gum", unit_price=Decimal("0.06"))
def test_total_ingredient_cost(self):
dish = DishFactory(restaurant=self.restaurant)
dish.ingredients.add(self.carrot, self.ginger)
expected_cost = self.carrot.unit_price + self.ginger.unit_price
self.assertEqual(dish.total_ingredient_cost(dish.ingredients.all()), expected_cost)
def test_unit_margin(self):
dish = DishFactory(restaurant=self.restaurant)
dish.ingredients.add(self.pilchard, self.yeast, self.xantham_gum)
total_cost = self.pilchard.unit_price + self.yeast.unit_price + self.xantham_gum.unit_price
expected_margin = dish.price - total_cost
self.assertEqual(dish.unit_margin(), expected_margin)
def test_unit_margin_with_prefetch(self):
dish = DishFactory(restaurant=self.restaurant)
dish.ingredients.add(self.saffron, self.ginger)
prefetched_dishes = Dish.objects.prefetch_related('ingredients').get(id=dish.id)
expected_margin = dish.price - (self.saffron.unit_price + self.ginger.unit_price)
self.assertEqual(prefetched_dishes.unit_margin(prefetched_ingredients=prefetched_dishes.ingredients.all()), expected_margin)
- Rerun the tests. If any fail, debug your code. Else, congrats and move on π
python manage.py test sim.tests
4. Create our views (which weβll then test)
Let's move on to views. We'll create basic views for listing, creating, updating, and deleting instances of the Restaurant, Ingredient, and Dish.
- Create your
views.py
atsim/views.py
and add:
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse_lazy
from .models import Restaurant
from django import forms
class RestaurantForm(forms.ModelForm):
class Meta:
model = Restaurant
fields = ['name', 'address_first_line', 'zip_code', 'phone_number']
def restaurant_list(request):
restaurants = Restaurant.objects.all()
return render(request, 'restaurant_list.html', {'restaurants': restaurants})
def restaurant_create(request):
form = RestaurantForm(request.POST or None)
if form.is_valid():
form.save()
return redirect('restaurant_list')
else:
print(form.errors)
return render(request, 'restaurant_form.html', {'form': form})
def restaurant_update(request, pk):
restaurant = get_object_or_404(Restaurant, pk=pk)
form = RestaurantForm(request.POST or None, instance=restaurant)
if form.is_valid():
form.save()
return redirect('restaurant_list')
return render(request, 'restaurant_form.html', {'form': form})
def restaurant_delete(request, pk):
restaurant = get_object_or_404(Restaurant, pk=pk)
if request.method == 'POST':
restaurant.delete()
return redirect('restaurant_list')
return render(request, 'restaurant_confirm_delete.html', {'restaurant': restaurant})
Connect our urls
- In
core
, openurls.py
and add:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('sim.urls'))
]
In sim
, create a file called urls.py
and add:
from django.urls import path
from . import views
urlpatterns = [
path('restaurants/', views.restaurant_list, name='restaurant_list'),
path('restaurants/new/', views.restaurant_create, name='restaurant_create'),
path('restaurants/<int:pk>/edit/', views.restaurant_update, name='restaurant_edit'),
path('restaurants/<int:pk>/delete/', views.restaurant_delete, name='restaurant_delete'),
]
Add our html templates
- Create a folder called templates in
sim
- Add a file called
restaurant_list.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Restaurant List</title>
</head>
<body>
<h1>Restaurant List</h1>
<ul>
{% for restaurant in restaurants %}
<li>{{ restaurant.name }} - {{ restaurant.address }} - {{ restaurant.phone_number }}</li>
{% endfor %}
</ul>
</body>
</html>
- Add a file called
restaurant_form.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Restaurant</title>
</head>
<body>
<h1>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Restaurant</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
</body>
</html>
- Add a file called
restaurant_confirm_delete.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Delete Restaurant</title>
</head>
<body>
<h1>Delete Restaurant</h1>
<p>Are you sure you want to delete the restaurant "{{ object }}"?</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<button type="submit">Confirm Delete</button>
</form>
</body>
</html>
Add unit tests for our Django views
Now we'll test our views.
In our sim/tests
folder, add a new file called test_views.py
and insert the below:
from django.test import TestCase
from django.urls import reverse
from sim.factories import RestaurantFactory
from sim.models import Restaurant
class RestaurantViewsTest(TestCase):
def test_restaurant_list_view(self):
response = self.client.get(reverse('restaurant_list'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'restaurant_list.html')
def test_restaurant_create_view(self):
response = self.client.get(reverse("restaurant_create"))
with self.subTest("GET request returns 200"):
self.assertEqual(response.status_code, 200)
with self.subTest("GET request uses correct template"):
self.assertTemplateUsed(response, 'restaurant_form.html')
with self.subTest("POST request creates new restaurant"):
data = {
'name': 'New Restaurant', 'address_first_line': '123 New Street',
'phone_number': '987-654-3210', 'zip_code': '12345'
}
self.client.post(reverse('restaurant_create'), data)
last_restaurant = Restaurant.objects.last()
self.assertEqual(last_restaurant.name, 'New Restaurant')
def test_restaurant_update_view(self):
restaurant = RestaurantFactory()
with self.subTest("GET request returns 200"):
response = self.client.get(reverse('restaurant_edit', args=[restaurant.pk]))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'restaurant_form.html')
with self.subTest("POST request updates restaurant"):
data = {'name': 'Updated Restaurant', 'address': '456 Updated Street', 'phone_number': '111-222-3333'}
response = self.client.post(reverse('restaurant_edit', args=[restaurant.pk]), data)
with self.subTest("POST request updates restaurant"):
self.assertEqual(response.status_code, 200)
def test_restaurant_delete_view(self):
restaurant = RestaurantFactory()
with self.subTest("GET request returns 200"):
response = self.client.get(reverse('restaurant_delete', args=[restaurant.pk]))
self.assertEqual(response.status_code, 200)
with self.subTest("GET request uses correct template"):
response = self.client.get(reverse('restaurant_delete', args=[restaurant.pk]))
self.assertTemplateUsed(response, 'restaurant_confirm_delete.html')
with self.subTest("POST request deletes restaurant"):
self.client.post(reverse('restaurant_delete', args=[restaurant.pk]))
self.assertFalse(Restaurant.objects.filter(pk=restaurant.pk).exists())
Run the tests:
python manage.py test sim.tests
Finished (You now know how to write Django unit tests)
Great job. You've learned how to test your Django code. This is awesome because you have automated tests that will help you:
- have clearer code
- change your code without breaking the entire application (e.g., change a model method, and your tests will tell you if you've broken anything)
- build your project faster. Your tests will show you the code you need to update when changing a related part.
Happy coding and congrats on your new ability to write Django unit tests π½οΈπ¨βπ³π
Build your Django frontend even faster
I want to release high-quality products as soon as possible. Probably like you, I want to make my Django product ideas become reality as soon as possible.
That's why I built Photon Designer - an entirely visual editor for building Django frontend at the speed that light hits your eyes. [Photon Designer]https://www.photondesigner.com/?ref=blink-unit-tests-factory-boy-faker) outputs neat, clean Django templates. Build 5x as fast: 1hr instead of 5hrs.