# 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](https://cdn.hashnode.com/uploads/covers/5fdbaa361485f22e24b325e3/010df11e-b1f1-4ac7-adc9-86e228c3c443.png align="center")

> **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](https://pedbad.hashnode.dev/django-restful-apis-the-django-rest-framework), [part 2](https://pedbad.hashnode.dev/restful-apis-with-django-and-the-django-rest-framework) and [part 3](https://pedbad.hashnode.dev/using-model-serializers-with-django-rest-framework) 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](https://cdn.hashnode.com/uploads/covers/5fdbaa361485f22e24b325e3/c6f8e616-1e14-4e11-aba3-95b40375b85f.png align="center")

- **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:

```python
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:

```bash
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):

```python
        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`:

```python
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:

```python
    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:

```python
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:

```plaintext
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:

```bash
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:

```bash
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](https://docs.djangoproject.com/en/5.2/topics/testing/overview/)): 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](https://cdn.hashnode.com/uploads/covers/5fdbaa361485f22e24b325e3/e12d0beb-68cf-40ad-92bc-92eb5b4db15c.png align="center")

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](https://www.django-rest-framework.org/api-guide/testing/) 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:

```python
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:

```python
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`:

```python
# 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](https://faker.readthedocs.io/) 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:

```python
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](https://cdn.hashnode.com/uploads/covers/5fdbaa361485f22e24b325e3/936ac0b8-d91e-4482-87f0-785784c26aba.png align="center")

---

### 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`:

```python
# 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:

```python
# 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

```python
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

```python
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

```python
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

```python
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:

```python
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

```bash
python manage.py test
```

```plaintext
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:

```bash
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](https://cdn.hashnode.com/uploads/covers/5fdbaa361485f22e24b325e3/6633593b-e450-411b-8a26-22f2b1636dec.png align="center")

#### Trap 1: cleaning up in `tearDown()`

Lots of tutorials empty the tables after each test:

```python
    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:

```plaintext
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:

```python
    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:

```plaintext
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:

```python
    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:

```plaintext
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:

```python
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:

```plaintext
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](https://coverage.readthedocs.io/) 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`):

```ini
[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:

```bash
coverage run manage.py test
coverage report
```

With the tests so far:

```plaintext
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`:

```python
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:

```plaintext
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](https://docs.pytest.org/) instead of `unittest`-style classes, and [pytest-django](https://pytest-django.readthedocs.io/) connects it to Django. Tell it where your settings are in `backend/pytest.ini`:

```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:

```bash
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`:

```python
# 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:

```python
# 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](https://pedbad.hashnode.dev/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/`):

```yaml
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](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-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**

* [Django 5.2: Writing and running tests](https://docs.djangoproject.com/en/5.2/topics/testing/overview/)

* [Django 5.2: Testing tools](https://docs.djangoproject.com/en/5.2/topics/testing/tools/)

* [Django REST framework: Testing](https://www.django-rest-framework.org/api-guide/testing/)

* [Django REST framework: Permissions](https://www.django-rest-framework.org/api-guide/permissions/)

* [factory_boy documentation](https://factoryboy.readthedocs.io/en/stable/)

* [Faker documentation](https://faker.readthedocs.io/)

* [coverage.py documentation](https://coverage.readthedocs.io/)

* [pytest-django documentation](https://pytest-django.readthedocs.io/)

* [GitHub Actions: Creating PostgreSQL service containers](https://docs.github.com/en/actions/tutorials/use-containerized-services/create-postgresql-service-containers)

