Skip to main content

RESTful APIs with Django and the Django Rest Framework

Updated 25 min readMarkdown
Part of seriesAdvanced Web Development (CM3035)
RESTful APIs with Django and the Django Rest Framework

Part 4 - Testing Your API with APITestCase and factory_boy

Version badges: Django 5.2 LTS, DRF 3.18, factory_boy 3.3, Python 3.14, tested November 2026

Tested with Django 5.2.19 LTS, Django REST framework 3.18.3, factory_boy 3.3.3 and Python 3.14, on SQLite and PostgreSQL 16, in November 2026. Every code block on this page comes from a project that was run, and the test suite passed 20 runs in a row, in random order, on both databases.

In part 1, part 2 and part 3 of this series we built a small API for a Course model, and we checked it by hand: running client.py, clicking around the browsable API, sending requests with cURL. That works while the API is small. But every time you change a serializer or a view, you would have to repeat every check, and sooner or later you won't.

Automated tests do those checks for you, in a second, every time. In this part we will:

  • see what is worth testing in an API, and how a test is laid out;
  • use Django's test runner and DRF's APITestCase;
  • make test data with factory_boy instead of typing it out;
  • write a full set of tests for our courses API: list, create, detail, update, partial update, delete, permissions and the serializer;
  • look at three traps that make tests fail for the wrong reasons;
  • measure what the tests cover with coverage.py, try pytest, and run everything on every push with GitHub Actions.

What should an API test check?

A test for an API endpoint does what you did by hand with client.py: it sends a request and looks at the response. For each endpoint, it's worth checking four things:

  1. The status code: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 403 Forbidden, 404 Not Found.
  2. The response data: the JSON has the fields and values you expect.
  3. Validation errors: bad data is rejected, with an error for the right field.
  4. The database: a create really saved a row, a delete really removed it, and a rejected request saved nothing.

The fourth one is easy to forget. A view can return 201 and still not save anything, so a good test asks the database as well.

Most tests follow the same three steps, often called arrange, act, assert:

Arrange, act, assert: make the test data, send one request, then check the status code, the JSON and the database
  • Arrange: create the data the test needs.
  • Act: do the one thing being tested, usually a single request.
  • Assert: check the result.

Keep each test to one behaviour, and give it a name that says what should happen, such as test_delete_returns_204_and_removes_the_course. When a test fails, its name tells you what broke before you read any code.


First, a small addition: course levels

So far our Course model has no relationships. Real models usually do, and they bring their own testing problems, so let's add one. UoL computer science modules are at Level 4, 5 or 6 (CM1xxx, CM2xxx and CM3xxx). Add a Level model to courses/models.py and give Course a level field:

from decimal import Decimal

from django.db import models


class Level(models.Model):
    """A study level: Level 4, 5 or 6 (the first, second and third year)."""

    number = models.PositiveSmallIntegerField(unique=True)

    def __str__(self):
        return f'Level {self.number}'


class Course(models.Model):
    title = models.CharField(max_length=120)
    course_id = models.CharField(max_length=20)
    description = models.TextField(blank=True, null=True)
    price = models.DecimalField(
        max_digits=15,
        decimal_places=2,
        default=Decimal('99.99')
    )
    level = models.ForeignKey(
        Level,
        on_delete=models.PROTECT,  # a level can't be deleted while courses use it
        related_name='courses',
        null=True,  # courses created before this change have no level yet
        blank=True,
    )

The rest of the Course model (__str__() and the discount_price property) stays as it was. Two choices here matter later:

  • on_delete=models.PROTECT means Django refuses to delete a level while any course uses it, and raises a ProtectedError instead.
  • null=True lets the courses you already have in your database migrate without a level.

Make and run the migration from the backend folder:

python manage.py makemigrations courses
python manage.py migrate

Then add 'level' to the serializer's fields in courses/serializers.py (everything else in the serializer stays as it was at the end of part 3):

        fields = ['id', 'title', 'course_id', 'description', 'price', 'sale', 'level']

DRF shows a foreign key as the related object's id, so a course now looks like {..., "level": 3}, and you send a level's id to set it.

Two more changes before we test

Update and delete. In part 3 our detail view only read a course. To have something to test for PUT, PATCH and DELETE, change its parent class in api/views.py from RetrieveAPIView to RetrieveUpdateDestroyAPIView:

class CourseDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Course.objects.all()
    serializer_class = CourseSerializer

We also use the ListCreateAPIView from the end of part 3 for /api/courses/, so it lists courses (GET) and creates them (POST). In api/urls.py, import CourseListCreateAPIView from .views and point the courses/ pattern at it:

    path('courses/', CourseListCreateAPIView, name='course-list'),

Who may change data. An API that lets anyone delete courses isn't one you'd put online. DRF's IsAuthenticatedOrReadOnly permission lets everyone read but only signed-in users write. In part 3 we added a REST_FRAMEWORK setting inside if not DEBUG:; replace it in django_rest/settings.py with this, which keeps the JSON-only renderer for production and adds the permission everywhere:

REST_FRAMEWORK = {
    # Anyone can read; only signed-in users can create, change or delete.
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ],
}

if not DEBUG:
    REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [
        'rest_framework.renderers.JSONRenderer',
    ]

This also protects the api_create view and /api/courses/create/ from part 3. Our tests will sign in with force_authenticate(), which we meet below.


Installing the test tools

Add the tools to the requirements.txt in the project's root folder, under the packages from part 1:

django>=5.2,<5.3
django-cors-headers==4.9.0
djangorestframework==3.18.3
PyYAML==6.0.3
requests==2.34.2
# Part 4: testing
factory_boy==3.3.3
coverage==7.17.2
pytest==9.2.0
pytest-django==4.14.0
psycopg[binary]==3.3.8

and install them with your virtual environment active:

pip install -r requirements.txt

factory_boy makes test data; coverage, pytest and pytest-django come in near the end; psycopg is the PostgreSQL driver, which you only need for running the tests on PostgreSQL.


Django's test runner

Django has a test runner built in. From the backend folder:

python manage.py test

It finds every file whose name starts with test (such as the tests.py that startapp made in each app), collects the test classes in them and runs every method whose name starts with test.

Your tests never touch your real database. Before the first test, Django creates a separate, empty test database (on SQLite it's kept in memory; on PostgreSQL it's a new database called test_ plus your database name) and runs your migrations on it. When the tests finish, it destroys it, whether they passed or not.

On PostgreSQL, the database user in your settings needs permission to create databases (CREATEDB), or the test runner stops before running any test.

Inside that database, each test is isolated from the others. Django's TestCase "runs each test inside a transaction to provide isolation" (Django docs): whatever a test creates, changes or deletes is rolled back when it ends, so the next test starts with empty tables again.

The test database lifecycle: create the test database and run migrations, run each test inside a transaction that is rolled back, then destroy the database

So you don't need a tearDown() method to empty the tables, and as we'll see in the traps section, writing one can break your tests.

APITestCase and APIClient

DRF's testing tools mirror Django's. rest_framework.test.APITestCase is Django's TestCase (with the same rollback) whose self.client is DRF's APIClient. The client sends requests straight to your views, without a server:

response = self.client.get(url)
response = self.client.post(url, payload, format='json')
response = self.client.put(url, payload, format='json')
response = self.client.patch(url, {'price': '10.00'}, format='json')
response = self.client.delete(url)

A few details worth knowing:

  • format='json' sends the payload as JSON, like our client.py did with requests.post(..., json=...). Without it, DRF's test client sends form data (multipart), which is fine for flat data but not for nested JSON.
  • response.json() turns the response body back into Python lists and dicts.
  • reverse('course-detail', kwargs={'pk': course.pk}) builds a URL from the name= in api/urls.py, so tests don't break if you change a URL's path.
  • rest_framework.status has names for the status codes (status.HTTP_201_CREATED), which read better than bare numbers.
  • self.client.force_authenticate(user=user) makes every following request come from that user, without a password or login. Use it to test views that need a signed-in user; force_authenticate(user=None) signs out again.

Test data with factory_boy

Each test needs some courses. You could create them by hand:

level = Level.objects.create(number=4)
course = Course.objects.create(
    title='Graphics Programming', course_id='CM2030',
    description='Computer graphics with p5.js.', price='300.00', level=level,
)

That's a lot of typing for every test, and most of it doesn't matter to the test. A factory knows how to make a valid object, so a test only spells out the values it cares about. Create courses/factories.py:

# courses/factories.py
import factory
from django.contrib.auth import get_user_model

from .models import Course, Level


class LevelFactory(factory.django.DjangoModelFactory):
    """A study level. Reuses the row if that level already exists."""

    class Meta:
        model = Level
        django_get_or_create = ('number',)

    number = 4


class CourseFactory(factory.django.DjangoModelFactory):
    """A valid course. Each call makes a new one; pass any field to fix its value."""

    class Meta:
        model = Course

    title = factory.Sequence(lambda n: f'Test course {n}')
    course_id = factory.Sequence(lambda n: f'CM{n:04d}')  # CM0000, CM0001, ...
    description = factory.Faker('sentence')
    price = factory.Faker(  # a new random price for each course
        'pydecimal', left_digits=3, right_digits=2, min_value=10, max_value=500
    )
    level = factory.SubFactory(LevelFactory)


class UserFactory(factory.django.DjangoModelFactory):
    """A user to sign in with in tests."""

    class Meta:
        model = get_user_model()

    username = factory.Sequence(lambda n: f'user{n}')

What each part does:

  • DjangoModelFactory with model = Course saves real rows through the Django ORM.
  • factory.Sequence gives each object a new value from a counter: Test course 0, Test course 1, and CM0000, CM0001… Use it for fields that should differ between objects.
  • factory.Faker asks the Faker library for a realistic value: a sentence for the description, a decimal between 10 and 500 for the price. It is called for each object, so every course gets its own price.
  • factory.SubFactory(LevelFactory) makes the related object too: each course gets a level without the test asking for one.
  • django_get_or_create = ('number',) makes LevelFactory use get_or_create(), so asking for Level 4 twice gives the same row instead of breaking the unique=True rule.

Using the factories:

course = CourseFactory()                         # a saved course, with a saved level
course = CourseFactory(price=Decimal('300.00'))  # fix only the field the test checks
courses = CourseFactory.create_batch(3)          # three saved courses
course = CourseFactory.build()                   # a Course object that is NOT saved

CourseFactory() is the same as CourseFactory.create(). Use build() when a test doesn't need the database at all, such as testing a model's __str__(): it is faster, and it shows the reader that nothing is saved.

Random values, fixed asserts. A random price is fine when no test checks it. When a test asserts a value, pass that value in (CourseFactory(price=Decimal('300.00'))). Never assert something the factory chose at random: we'll see in the traps section how that makes tests fail some of the time.

Factory to model to test: CourseFactory fills every field, the test overrides only price, and the assert checks only the value the test chose

Testing the model and the serializer

Start with the smallest pieces. Model and serializer tests don't need any requests, so they use Django's own TestCase. Replace the contents of courses/tests.py:

# courses/tests.py
from decimal import Decimal

from django.test import TestCase

from .factories import CourseFactory, LevelFactory
from .serializers import CourseSerializer


class CourseModelTests(TestCase):
    """Tests for the Level and Course models on their own, without the API."""

    def test_level_str_names_the_level(self):
        self.assertEqual(str(LevelFactory.build(number=5)), 'Level 5')

    def test_str_is_the_title(self):
        course = CourseFactory.build(title='Graphics Programming')
        self.assertEqual(str(course), 'Graphics Programming')

    def test_discount_price_is_half_the_price(self):
        course = CourseFactory.build(price=Decimal('300.00'))
        self.assertEqual(course.discount_price, '150.00')


class CourseSerializerTests(TestCase):
    """Tests for CourseSerializer: what it outputs and what it accepts."""

    def setUp(self):
        self.level = LevelFactory()
        self.valid_data = {
            'title': 'Advanced Web Development',
            'course_id': 'CM3035',
            'description': 'Full stack web development with Django.',
            'price': '400.00',
            'level': self.level.pk,
        }

    def test_output_has_expected_fields(self):
        data = CourseSerializer(CourseFactory()).data
        self.assertEqual(
            set(data),
            {'id', 'title', 'course_id', 'description', 'price', 'sale', 'level'},
        )

    def test_sale_is_the_discount_price(self):
        course = CourseFactory(price=Decimal('100.00'))
        self.assertEqual(CourseSerializer(course).data['sale'], '50.00')

    def test_valid_data_is_valid(self):
        serializer = CourseSerializer(data=self.valid_data)
        self.assertTrue(serializer.is_valid(), serializer.errors)

    def test_course_id_must_be_cm_and_four_digits(self):
        for bad_id in ['XX99', 'CM123', 'cm1010', 'CM10100']:
            with self.subTest(course_id=bad_id):
                serializer = CourseSerializer(data={**self.valid_data, 'course_id': bad_id})
                self.assertFalse(serializer.is_valid())
                self.assertIn('course_id', serializer.errors)

    def test_title_is_required(self):
        data = {**self.valid_data}
        del data['title']
        serializer = CourseSerializer(data=data)
        self.assertFalse(serializer.is_valid())
        self.assertEqual(serializer.errors['title'][0].code, 'required')

Some things to notice:

  • setUp() runs before every test in the class, so each test gets fresh data. There is no tearDown(): the rollback cleans up.
  • test_output_has_expected_fields compares sets, so the order of the fields doesn't matter. If someone adds a field to the serializer by mistake (a password, say), this test fails.
  • self.subTest() runs the same check for several bad ids and reports each failure separately, instead of stopping at the first.
  • serializer.errors['title'][0].code checks the kind of error ('required') rather than its English message, which can change between DRF versions.
  • Passing serializer.errors as the message in assertTrue(serializer.is_valid(), serializer.errors) means a failure shows you why the data was invalid.

Testing the API endpoints

Now the endpoints themselves. Put these in api/tests.py, starting with the imports:

# api/tests.py
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from courses.factories import CourseFactory, LevelFactory, UserFactory
from courses.models import Course

Listing courses

class CourseListTests(APITestCase):
    """GET /api/courses/: anyone can list courses."""

    url = reverse('course-list')

    def test_list_is_empty_when_there_are_no_courses(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.json(), [])

    def test_list_returns_every_course(self):
        CourseFactory.create_batch(3)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.json()), 3)

    def test_list_item_matches_the_course(self):
        course = CourseFactory(price='120.00')
        item = self.client.get(self.url).json()[0]
        self.assertEqual(item['id'], course.pk)
        self.assertEqual(item['course_id'], course.course_id)
        self.assertEqual(item['price'], '120.00')
        self.assertEqual(item['sale'], '60.00')
        self.assertEqual(item['level'], course.level.pk)

The empty-list test looks too simple to be worth it, but it catches a view that crashes when there's nothing to show. Notice that the item test compares with course.pk and course.course_id, read from the object the factory made, rather than with numbers typed into the test.

Creating a course

class CourseCreateTests(APITestCase):
    """POST /api/courses/: signed-in users can add a course."""

    url = reverse('course-list')

    def setUp(self):
        self.client.force_authenticate(user=UserFactory())
        self.payload = {
            'title': 'Graphics Programming',
            'course_id': 'CM2030',
            'description': 'Computer graphics with p5.js.',
            'price': '300.00',
            'level': LevelFactory(number=5).pk,
        }

    def test_create_returns_201_and_saves_the_course(self):
        response = self.client.post(self.url, self.payload, format='json')
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.json()['sale'], '150.00')
        self.assertTrue(Course.objects.filter(course_id='CM2030').exists())

    def test_create_with_bad_course_id_returns_400_and_saves_nothing(self):
        self.payload['course_id'] = 'XX99'
        response = self.client.post(self.url, self.payload, format='json')
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn('course_id', response.json())
        self.assertEqual(Course.objects.count(), 0)

    def test_create_with_unknown_level_returns_400(self):
        self.payload['level'] = 9999
        response = self.client.post(self.url, self.payload, format='json')
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn('level', response.json())

Each test checks the status code, the response and the database. The 400 tests check that nothing was saved, not just that the status was right. The unknown-level test shows that DRF checks foreign keys for you: a level id that doesn't exist is a validation error, not a crash.

One course: read, update, delete

class CourseDetailTests(APITestCase):
    """GET, PUT, PATCH and DELETE on /api/courses/<pk>/."""

    def setUp(self):
        self.course = CourseFactory(title='Graphics Programming', course_id='CM2030')
        self.url = reverse('course-detail', kwargs={'pk': self.course.pk})
        self.client.force_authenticate(user=UserFactory())

    def test_detail_returns_the_course(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.json()['title'], 'Graphics Programming')

    def test_detail_of_deleted_course_returns_404(self):
        missing = CourseFactory()
        url = reverse('course-detail', kwargs={'pk': missing.pk})
        missing.delete()  # the URL is valid, but the view finds no course
        response = self.client.get(url)
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_put_replaces_the_course(self):
        payload = {
            'title': 'Graphics Programming 2',
            'course_id': 'CM2031',
            'description': 'More graphics.',
            'price': '350.00',
            'level': self.course.level.pk,
        }
        response = self.client.put(self.url, payload, format='json')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.course.refresh_from_db()
        self.assertEqual(self.course.course_id, 'CM2031')

    def test_patch_changes_only_the_fields_sent(self):
        response = self.client.patch(self.url, {'price': '10.00'}, format='json')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.course.refresh_from_db()
        self.assertEqual(str(self.course.price), '10.00')
        self.assertEqual(self.course.title, 'Graphics Programming')

    def test_delete_returns_204_and_removes_the_course(self):
        response = self.client.delete(self.url)
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertFalse(Course.objects.filter(pk=self.course.pk).exists())

Two of these deserve a closer look.

A 404 that reaches the view. It's tempting to test a missing course with a URL like /api/courses/abc/. That does return 404, but only because abc doesn't match <int:pk> in api/urls.py: Django's URL routing answers, and your view never runs. To test the view's own "not found" path, use a URL that matches the pattern for a course that doesn't exist. Creating a course, keeping its URL and deleting it is a reliable way to get one.

refresh_from_db(). self.course is a Python object loaded before the request. The view changed the row in the database, not that object, so reload it before checking what was saved. The PATCH test also checks a field that was not sent (title), because "changes only the fields sent" is the whole point of PATCH.

Permissions

class CoursePermissionTests(APITestCase):
    """Visitors who aren't signed in can read but not write."""

    def setUp(self):
        self.course = CourseFactory()
        self.url = reverse('course-detail', kwargs={'pk': self.course.pk})

    def test_anonymous_user_can_read(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_anonymous_user_cannot_delete(self):
        response = self.client.delete(self.url)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
        self.assertTrue(Course.objects.filter(pk=self.course.pk).exists())

    def test_anonymous_user_cannot_create(self):
        response = self.client.post(reverse('course-list'), {}, format='json')
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

These tests don't call force_authenticate(), so the requests are anonymous. They are the tests that would catch someone removing the permission setting by mistake. (DRF answers 403 Forbidden rather than 401 Unauthorized here: the first of its default authentication classes is session authentication, and DRF uses the first class to decide; session authentication has no WWW-Authenticate header to send, so the answer is 403.)

A random endpoint

How do you test /api/random/, which returns a different course each time? Assert what is always true, not which course comes back:

class RandomCourseTests(APITestCase):
    """GET /api/random/ returns one of the courses, chosen at random."""

    url = reverse('random')

    def test_random_with_no_courses_returns_empty_object(self):
        self.assertEqual(self.client.get(self.url).json(), {})

    def test_random_returns_one_of_the_courses(self):
        courses = CourseFactory.create_batch(3)
        data = self.client.get(self.url).json()
        self.assertIn(data['id'], [course.pk for course in courses])

Running the tests

python manage.py test
Found 24 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........................
----------------------------------------------------------------------
Ran 24 tests in 0.096s

OK
Destroying test database for alias 'default'...

Each dot is a passing test; a failure shows an F (or an E for an error) and a report underneath. Some useful variations:

python manage.py test api                                # one app
python manage.py test api.tests.CourseDetailTests        # one class
python manage.py test -v 2                               # list each test by name
python manage.py test --shuffle                          # run the tests in a random order

--shuffle is a good habit now and then: tests that pass in one order and fail in another depend on each other, and that's a bug in the tests.


Three traps

These three mistakes are common, and they all make tests fail for reasons that have nothing to do with the code being tested. Each one below was run to show the error.

Three traps: deleting rows in tearDown gives ProtectedError; a fixed primary key passes on SQLite and fails on PostgreSQL; random values chosen once, or asserted exactly, make tests flaky

Trap 1: cleaning up in tearDown()

Lots of tutorials empty the tables after each test:

    def tearDown(self):
        # Don't do this: the test database is rolled back for you.
        Level.objects.all().delete()
        Course.objects.all().delete()

With our PROTECT foreign key, deleting the levels first fails, so every test in the class errors, even though the API is fine:

django.db.models.deletion.ProtectedError: ("Cannot delete some instances of model 'Level' because they are referenced through protected foreign keys: 'Course.level'.", {<Course: Test course 0>})

Swapping the two lines would hide the error, but the real fix is to delete the method: the rollback already gives each test empty tables.

Trap 2: hard-coding primary keys

These two tests look harmless:

    def test_a_first_course_has_pk_1(self):
        CourseFactory()
        response = self.client.get(reverse('course-detail', kwargs={'pk': 1}))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_b_another_course_has_pk_1(self):
        CourseFactory()
        response = self.client.get(reverse('course-detail', kwargs={'pk': 1}))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

On SQLite both pass. On PostgreSQL the second one fails:

AssertionError: 404 != 200

PostgreSQL hands out ids from a sequence, and a number taken from a sequence isn't given back when the transaction is rolled back: after the first test used id 1, the next course gets id 2, even though the first test's row is gone. On SQLite the id counter is rolled back with everything else, so the mistake goes unnoticed.

Setting a primary key by hand is the same trap the other way round:

    def test_fixed_pk_then_factory(self):
        CourseFactory(pk=1)
        CourseFactory()  # the database picks the next id

Again SQLite is happy, and PostgreSQL isn't, because choosing id 1 yourself doesn't move the sequence on:

django.db.utils.IntegrityError: duplicate key value violates unique constraint "courses_course_pkey"
DETAIL:  Key (id)=(1) already exists.

The fix for both: never type an id. Let the database choose, and read it from the object (course.pk), as all our tests above do. And run your tests on the same database you'll use in production; we'll do that automatically with GitHub Actions below.

Trap 3: randomness in the wrong place

Python's random module in a factory's class body looks like it gives random data:

class RandomCourseFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Course

    title = 'Course'
    course_id = 'CM0001'
    price = Decimal(random.randint(10, 500))  # runs once, when the file is imported
    level = factory.SubFactory(LevelFactory)

But that line runs once, when Python first reads the file, so every course in the test run gets the same price. Making five courses and printing their prices shows a single value:

prices: {Decimal('479')}

Use factory.Faker (or factory.Sequence, or factory.LazyAttribute) so the value is worked out for each object.

The opposite mistake is real randomness combined with an exact assert. If a factory picks the level at random from 4, 5 and 6 and a test asserts course.level.number == 4, the test passes about one run in three (when we ran it 30 times, it failed 21 times). A test like that is flaky: it fails at random, people learn to re-run it until it passes, and then they stop trusting the suite. Keep random values for fields your test doesn't check, and pass in any value it does.


Measuring coverage

How do you know what your tests miss? coverage.py records which lines of your code ran during the tests. Create backend/.coveragerc so the report leaves out files that aren't yours to test (migrations, the tests themselves) and also checks branches (both sides of each if):

[run]
source = .
branch = true
omit =
    manage.py
    */migrations/*
    */tests.py
    */test_*.py
    django_rest/asgi.py
    django_rest/wsgi.py

[report]
show_missing = true
skip_covered = false

Then run the tests through coverage and print the report:

coverage run manage.py test
coverage report

With the tests so far:

Name                      Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------
api/__init__.py               0      0      0      0   100%
api/admin.py                  1      0      0      0   100%
api/apps.py                   4      0      0      0   100%
api/models.py                 1      0      0      0   100%
api/urls.py                   4      0      0      0   100%
api/views.py                 38      6      4      0    81%   14-17, 37-40
courses/__init__.py           0      0      0      0   100%
courses/admin.py              1      0      0      0   100%
courses/apps.py               4      0      0      0   100%
courses/factories.py         20      0      0      0   100%
courses/models.py            20      0      0      0   100%
courses/serializers.py       14      0      2      0   100%
courses/views.py              1      1      0      0     0%   1
django_rest/__init__.py       0      0      0      0   100%
django_rest/settings.py      24      2      4      2    86%   90, 149
django_rest/urls.py           3      0      0      0   100%
---------------------------------------------------------------------
TOTAL                       135      9     10      2    91%

The Missing column is the useful part. Lines 14–17 of api/views.py are part 1's hello_world view, and lines 37–40 are the body of part 3's api_create view: no test has ever called them. The two lines in settings.py are the PostgreSQL and production branches, which only run in those set-ups, and courses/views.py is the empty file startapp made. So let's add a test for api_create:

class ApiCreateTests(APITestCase):
    """POST /api/create/: the function-based create view from part 3."""

    url = reverse('create')

    def setUp(self):
        self.client.force_authenticate(user=UserFactory())

    def test_api_create_saves_the_course(self):
        payload = {
            'title': 'Computational Mathematics',
            'course_id': 'CM1015',
            'description': 'How to do arithmetic in different bases',
            'price': '100.00',
        }
        response = self.client.post(self.url, payload, format='json')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertTrue(Course.objects.filter(course_id='CM1015').exists())

Run coverage again and api/views.py changes to:

api/views.py                 38      2      4      1    93%   14-17, 38->exit

The lines are covered now, and coverage has spotted something else: 38->exit is a partial branch. Line 38 is if serializer.is_valid(raise_exception=True):, and the "false" side of that if never ran. It never can: with raise_exception=True, invalid data raises an error instead of returning False. Coverage can't tell you that, but it points you to the line, and the if could simply go.

The test also documents a small inconsistency: api_create answers a successful create with 200 OK, while the generic CreateAPIView answers 201 Created. If you want them to match, return Response(serializer.data, status=status.HTTP_201_CREATED) and update the test.

A high percentage is not the goal; it only says which lines ran, not whether the tests checked anything. Use the Missing column to find code that no test touches.


The same tests with pytest

Many Python projects use pytest instead of unittest-style classes, and pytest-django connects it to Django. Tell it where your settings are in backend/pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = django_rest.settings
python_files = tests.py test_*.py

pytest can run all the APITestCase tests we've written as they are:

pytest

It also lets you write tests as plain functions with assert. Here are two of our tests in pytest style, in a new file api/test_courses_pytest.py:

# api/test_courses_pytest.py
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient

from courses.factories import CourseFactory, UserFactory
from courses.models import Course


@pytest.fixture
def api_client():
    """An API client that isn't signed in."""
    return APIClient()


@pytest.fixture
def signed_in_client(api_client):
    """An API client signed in as a new user."""
    api_client.force_authenticate(user=UserFactory())
    return api_client


@pytest.mark.django_db
def test_list_returns_every_course(api_client):
    CourseFactory.create_batch(3)
    response = api_client.get(reverse('course-list'))
    assert response.status_code == status.HTTP_200_OK
    assert len(response.json()) == 3


@pytest.mark.django_db
def test_delete_removes_the_course(signed_in_client):
    course = CourseFactory()
    response = signed_in_client.delete(reverse('course-detail', kwargs={'pk': course.pk}))
    assert response.status_code == status.HTTP_204_NO_CONTENT
    assert not Course.objects.filter(pk=course.pk).exists()
  • Fixtures replace setUp(): a test asks for what it needs by naming it as an argument, and fixtures can build on each other (signed_in_client uses api_client).
  • @pytest.mark.django_db lets a test use the database; without it, pytest-django stops the test. Each test is still rolled back.
  • assert replaces the assertEqual family, and pytest prints both sides when an assert fails.

Running pytest now runs all 27 tests: the 25 Django-style ones and these two. Neither style is better; pick one per project. Stick with manage.py test if you're following a course that uses it.


Running the tests on every push with GitHub Actions

Tests help most when they run without anyone remembering to run them. If your project is on GitHub, GitHub Actions can run them every time you push, on SQLite and PostgreSQL, which catches trap 2 automatically.

First, let settings.py switch to PostgreSQL when it's given a database name. Add import os at the top of django_rest/settings.py, and this after the DATABASES setting:

# Use PostgreSQL when POSTGRES_DB is set (for example in GitHub Actions).
if os.environ.get('POSTGRES_DB'):
    DATABASES['default'] = {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ['POSTGRES_DB'],
        'USER': os.environ.get('POSTGRES_USER', 'postgres'),
        'PASSWORD': os.environ.get('POSTGRES_PASSWORD', ''),
        'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
        'PORT': os.environ.get('POSTGRES_PORT', '5432'),
    }

Without POSTGRES_DB, nothing changes: you keep using SQLite. (This is also how you can run the tests against a PostgreSQL server on your own computer; see Django, Docker, and PostgreSQL for one way to run one.)

Then create .github/workflows/tests.yml in the project's root folder (the one that holds requirements.txt and backend/):

name: Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        database: [sqlite, postgres]

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: courses
          POSTGRES_PASSWORD: courses
          POSTGRES_DB: courses
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    defaults:
      run:
        working-directory: backend

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: '3.14'
          cache: pip
          cache-dependency-path: requirements.txt

      - name: Install dependencies
        run: python -m pip install -r requirements.txt
        working-directory: .

      - name: Use PostgreSQL
        if: matrix.database == 'postgres'
        run: |
          {
            echo "POSTGRES_DB=courses"
            echo "POSTGRES_USER=courses"
            echo "POSTGRES_PASSWORD=courses"
            echo "POSTGRES_HOST=localhost"
          } >> "$GITHUB_ENV"

      - name: Run the tests with coverage
        run: |
          coverage run manage.py test
          coverage report

How it works:

  • on: push and pull_request: the workflow runs for every push and every pull request.
  • matrix: the job runs twice, once with database: sqlite and once with database: postgres.
  • services: postgres: GitHub starts a PostgreSQL 16 server next to the job and waits until pg_isready says it's up. The POSTGRES_USER it creates owns the server, so it can create the test database.
  • The "Use PostgreSQL" step only runs in the postgres job; it sets the environment variables that switch settings.py to PostgreSQL for the steps after it.
  • The last step runs the tests through coverage and prints the report in the job's log.

Commit the file and push. On GitHub, the Actions tab shows both jobs, and each commit gets a green tick or a red cross. The password here is only for a throwaway database that exists while the job runs; never put real passwords in a workflow file (GitHub has encrypted secrets for those).


Summary

  • Test what an endpoint promises: the status code, the response data, validation errors and the change in the database.
  • APITestCase gives each test a rolled-back transaction in a separate test database, and an APIClient as self.client. You don't need tearDown().
  • Factories make valid test data in one line. Fix the values a test checks; let the factory choose the rest.
  • Never hard-code ids; read them from the objects. Run your tests on the database you use in production.
  • Random values belong in fields the test doesn't check, and must be chosen per object.
  • Coverage shows the code no test has touched; GitHub Actions runs everything on every push.

The finished project has 27 tests that pass on SQLite and PostgreSQL. A good next step is to write a test before you change something: for example, make api_create return 201, change its test first, watch it fail, then fix the view.


Resources

About the author

  • Head of eLearning at the University of Cambridge Language Centre, full-stack developer and AI engineer, and online tutor on the BSc Computer Science from Goldsmiths, University of London, on Coursera. I started out in neuroscience research before moving into web development, and have spent over twenty years building digital learning at Cambridge. This blog is my supplementary material for students on the BSc: set-up guides, explanations of the ideas that trip people up, and worked examples. New here? Click Website above for my Start here page and module guides.

No comments yet. Be the first to comment.