---
title: "Testing Django REST Framework APIs with pytest"
url: "https://nareshkumar.online/blog/testing-drf-apis-with-pytest"
author: "Naresh Kumar"
published: "2026-10-05"
updated: "2026-10-05"
category: "Engineering"
tags: ["Django", "Django REST Framework", "PostgreSQL", "Python", "Testing"]
---

# Testing Django REST Framework APIs with pytest

**In short:** Use pytest-django with APIClient fixtures and factory_boy factories. Test status codes, payloads and permissions for every endpoint, pin query counts, keep the suite fast, and run it against PostgreSQL in CI.

An API is a contract: clients depend on its URLs, status codes and payloads. Tests are how you keep that contract while the code behind it changes. pytest makes those tests short to write and quick to run, and pytest-django connects it to Django's test database.

## Set up pytest-django

```bash
pip install pytest pytest-django pytest-xdist factory-boy
```

`pyproject.toml`

```toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
python_files = ["tests.py", "test_*.py"]
addopts = "--reuse-db"
```

`--reuse-db` keeps the test database between runs instead of rebuilding it every time. A small test settings module speeds things up further. Password hashing is slow on purpose, and tests create a lot of users:

`config/settings/test.py`

```python
from .base import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
```

> **Warning:** Use the MD5 hasher in test settings only. It is fast precisely because it is insecure.

## Fixtures for clients and users

Shared fixtures go in `conftest.py`. Every test in that directory can use them just by naming them as arguments:

`tests/conftest.py`

```python
import pytest
from rest_framework.test import APIClient

from .factories import UserFactory


@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def user(db):
    return UserFactory()


@pytest.fixture
def auth_client(user):
    client = APIClient()
    client.force_authenticate(user=user)
    return client
```

`force_authenticate()` skips the login flow, so a test about orders doesn't depend on how tokens work. Test the token endpoints themselves separately, once.

## Test data with factory\_boy

`tests/factories.py`

```python
import factory
from django.contrib.auth import get_user_model

from shop.models import Order, Product


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = get_user_model()

    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.LazyAttribute(lambda u: f"{u.username}@example.com")
    password = factory.django.Password("correct horse battery staple")


class ProductFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Product

    name = factory.Faker("word")
    price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)


class OrderFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Order

    customer = factory.SubFactory(UserFactory)
```

Each test states only the values it cares about, such as `OrderFactory(customer=user)`, and the factory fills in the rest. Tests stay readable, and when a model gains a required field you update one factory instead of fifty tests.

## Test the endpoint, not the implementation

Check what a client sees: the status code, the payload and the effect on the database. These tests assume a paginated order API like the one in [the serializers guide](https://nareshkumar.online/blog/drf-serializers-validation-nested-writes), with `allow_empty=False` on the items field and `related_name="orders"` on the customer field:

`tests/test_orders.py`

```python
import pytest
from django.urls import reverse

from .factories import OrderFactory, ProductFactory

pytestmark = pytest.mark.django_db


def test_list_returns_only_my_orders(auth_client, user):
    mine = OrderFactory(customer=user)
    OrderFactory()  # someone else's order

    response = auth_client.get(reverse("order-list"))

    assert response.status_code == 200
    assert [order["id"] for order in response.data["results"]] == [mine.id]


def test_create_order(auth_client, user):
    product = ProductFactory()
    payload = {"items": [{"product": product.id, "quantity": 2}]}

    response = auth_client.post(reverse("order-list"), payload, format="json")

    assert response.status_code == 201
    assert user.orders.count() == 1


def test_create_rejects_an_order_without_items(auth_client):
    response = auth_client.post(reverse("order-list"), {"items": []}, format="json")

    assert response.status_code == 400
    assert "items" in response.data
```

The first test is often the most important one in the whole suite: it proves that one user can't see another user's data. Write one like it for every endpoint that returns private rows.

## Test permissions as a matrix

`pytest.mark.parametrize` turns a permission table into tests:

`tests/test_permissions.py`

```python
import pytest
from django.urls import reverse
from rest_framework.test import APIClient

from .factories import OrderFactory, UserFactory


@pytest.mark.django_db
@pytest.mark.parametrize(
    ("who", "expected"),
    [
        ("anonymous", 401),
        ("other_user", 404),
        ("owner", 200),
        ("staff", 200),
    ],
)
def test_order_detail_access(who, expected):
    owner = UserFactory()
    order = OrderFactory(customer=owner)
    client = APIClient()
    if who == "other_user":
        client.force_authenticate(UserFactory())
    elif who == "owner":
        client.force_authenticate(owner)
    elif who == "staff":
        client.force_authenticate(UserFactory(is_staff=True))

    response = client.get(reverse("order-detail", args=[order.id]))

    assert response.status_code == expected
```

An anonymous caller gets 401 because this API authenticates with JWT; with session authentication it would be 403. Another user gets 404 because `get_queryset()` is scoped to the owner, and staff see everything. [The authentication and permissions guide](https://nareshkumar.online/blog/drf-authentication-permissions-jwt) explains both choices.

## Pin the number of queries

`tests/test_orders.py`

```python
def test_order_list_query_count(auth_client, user, django_assert_num_queries):
    OrderFactory.create_batch(5, customer=user)

    with django_assert_num_queries(3):
        auth_client.get(reverse("order-list"))
```

For this view, the three queries are the page count, the orders joined with their customers, and one prefetch for the items. Pick the number your view needs today and keep it there. If a change adds a query per order, the test fails and prints every SQL statement, which usually points straight at the missing `select_related()` or `prefetch_related()`. [The N+1 guide](https://nareshkumar.online/blog/django-n-plus-one-queries) covers the fixes.

## Test serializers on their own

Validation rules with many edge cases are quicker to test on the serializer directly. Remember that `validate()` only runs when every field is valid, so give the test valid values for the other fields, including a room that exists:

`tests/test_serializers.py`

```python
import pytest

from shop.serializers import BookingSerializer

from .factories import RoomFactory


@pytest.mark.django_db
def test_booking_must_end_after_it_starts():
    room = RoomFactory()
    serializer = BookingSerializer(
        data={
            "room": room.id,
            "starts_at": "2030-01-02T10:00:00Z",
            "ends_at": "2030-01-02T09:00:00Z",
            "guests": 2,
        }
    )

    assert not serializer.is_valid()
    assert "ends_at" in serializer.errors
```

## Run the suite against PostgreSQL in CI

SQLite is convenient on a laptop, but it behaves differently from PostgreSQL in ways tests notice: case-sensitive matching, JSON fields, constraint checks and full-text search. Run CI against the database you deploy on. A GitHub Actions job, assuming your settings read `DATABASE_URL` (for example with dj-database-url):

`.github/workflows/tests.yml`

```yaml
name: tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt
      - run: pytest -n auto
```

`-n auto` comes from pytest-xdist. It spreads the tests across the runner's CPU cores, each worker with its own test database.

## What to test for every endpoint

- [ ] The happy path: status code and the shape of the payload.
- [ ] Validation: one test per rule, asserting which field failed.
- [ ] Authentication: anonymous requests are rejected.
- [ ] Authorization: another user can't read, change or delete the object.
- [ ] Performance: the query count stays the same as rows are added.
- [ ] Side effects: rows created, emails queued, background tasks sent.

## Frequently asked questions

**Should I use pytest or Django's TestCase for DRF tests?**

Either works. pytest-django runs Django `TestCase` classes too, so you can switch gradually. pytest's fixtures, `parametrize` and plain `assert` statements usually make API tests shorter.

**What is the difference between APIClient and APIRequestFactory?**

`APIClient` sends a request through the whole stack: URL routing, middleware, authentication and the view. `APIRequestFactory` only builds a request that you pass to a view yourself. Use the client for endpoint tests and the factory to unit-test a view in isolation.

**How do I test JWT authentication itself?**

Post real credentials to the token endpoint once, assert that you get an access and a refresh token, then call a protected endpoint with `client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")`. Everywhere else, `force_authenticate()` keeps tests independent of the authentication scheme.

**Why are my Django tests slow?**

The usual causes are slow password hashing, rebuilding the test database on every run, and creating more rows than a test needs. Use a fast hasher in test settings, `--reuse-db`, lean factories, and `pytest -n auto`.

## More in this series

This is part 5 of 5 in **Django REST Framework in practice**:

1. [DRF serializers: validation, nested writes and speed](https://nareshkumar.online/blog/drf-serializers-validation-nested-writes)
2. [Authentication, permissions and JWT in DRF](https://nareshkumar.online/blog/drf-authentication-permissions-jwt)
3. [Pagination, filtering and search in DRF](https://nareshkumar.online/blog/drf-pagination-filtering-search)
4. [Fixing N+1 queries with select\_related and prefetch\_related](https://nareshkumar.online/blog/django-n-plus-one-queries)
5. Testing DRF APIs with pytest (this post)
