RESTful APIs with Django and the Django Rest Framework

Part 4 - Testing Your API with APITestCase and factory_boy
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:
- The status code:
200 OK,201 Created,204 No Content,400 Bad Request,403 Forbidden,404 Not Found. - The response data: the JSON has the fields and values you expect.
- Validation errors: bad data is rejected, with an error for the right field.
- 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: 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.PROTECTmeans Django refuses to delete a level while any course uses it, and raises aProtectedErrorinstead.null=Truelets 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_createview and/api/courses/create/from part 3. Our tests will sign in withforce_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.
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 ourclient.pydid withrequests.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 thename=inapi/urls.py, so tests don't break if you change a URL's path.rest_framework.statushas 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:
DjangoModelFactorywithmodel = Coursesaves real rows through the Django ORM.factory.Sequencegives each object a new value from a counter:Test course 0,Test course 1, andCM0000,CM0001… Use it for fields that should differ between objects.factory.Fakerasks 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',)makesLevelFactoryuseget_or_create(), so asking for Level 4 twice gives the same row instead of breaking theunique=Truerule.
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.
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 notearDown(): the rollback cleans up.test_output_has_expected_fieldscompares 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].codechecks the kind of error ('required') rather than its English message, which can change between DRF versions.- Passing
serializer.errorsas the message inassertTrue(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.
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_clientusesapi_client). @pytest.mark.django_dblets a test use the database; without it, pytest-django stops the test. Each test is still rolled back.assertreplaces theassertEqualfamily, 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: pushandpull_request: the workflow runs for every push and every pull request.matrix: the job runs twice, once withdatabase: sqliteand once withdatabase: postgres.services: postgres: GitHub starts a PostgreSQL 16 server next to the job and waits untilpg_isreadysays it's up. ThePOSTGRES_USERit creates owns the server, so it can create the test database.- The "Use PostgreSQL" step only runs in the
postgresjob; it sets the environment variables that switchsettings.pyto 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.
APITestCasegives each test a rolled-back transaction in a separate test database, and anAPIClientasself.client. You don't needtearDown().- 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.
Comments
Join the discussionNo comments yet. Be the first to comment.
Advanced Web Development (CM3035)
Through this module, you will learn how to build dynamic, data-driven websites using databases, front-end frameworks and server-side programming.





