Write good tests (with FactoryBoy and Faker) in 6 Minutes πŸ½οΈπŸ‘¨β€πŸ³

Photo of Tom Dekan
by Tom Dekan
Updated: Fri 03 May 2024

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 in sim/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 at sim/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 at sim/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, open urls.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.

Let's get visual.

Do you want to create beautiful frontends effortlessly?
Click below to book your spot on our early access mailing list (as well as early adopter prices).
Copied link to clipboard πŸ“‹

Made with care by Tom Dekan

Β© 2024 Photon Designer