Recap

In Articles 1 and 2 we built a unit test suite for the orders application: model methods, properties, validators, custom managers, utility functions, and service logic. In Article 3 we migrated that entire suite to pytest, replacing TestCase with plain functions, setUp with composable fixtures, and self.assert* with plain assert.

Unit tests verify that individual pieces of logic produce the right output. They do not verify that those pieces are wired together correctly. A view could call the right service function with the wrong arguments. A URL could point to the wrong view. A middleware could skip a request it should have blocked. A serializer could silently drop a field that the client depends on. None of these bugs would be caught by any unit test.

Integration tests catch exactly this class of bug. They exercise the full request-response cycle: a real HTTP request flows through URL routing, middleware, the view, the database, and back out as a response. If anything in that chain is broken, the test fails.

What integration tests cover

In a Django application, integration tests are responsible for verifying:

  • URL routing: Does the URL pattern match the right view? Does a trailing slash matter? Does a missing path parameter return 404?
  • View logic: Does the view return the right status code? Does it query the right data? Does it pass the right context to the template?
  • Authentication and permissions: Does an unauthenticated request get redirected or rejected? Does a user without the right role get a 403?
  • Serialization and validation: Does invalid input produce a 400 with a useful error message? Does valid input produce the right response shape?
  • Database writes: After a POST request, was the record created? After a DELETE, was it removed?
  • Middleware: Is a security header added? Is a rate limit enforced? Is a request logged?

We are building on the same orders application from Articles 2 and 3. For this article, it also has views and a DRF API.

The test client

Django provides a test HTTP client at django.test.Client. It lets you make GET, POST, PUT, PATCH, and DELETE requests to your views without running a web server. The client handles cookies, sessions, and redirects automatically.

In pytest-django, access it through the client fixture:

python
import pytest


@pytest.mark.django_db
def test_homepage_returns_200(client):
    response = client.get("/")
    assert response.status_code == 200

The client fixture is provided by pytest-django and returns a fresh django.test.Client instance for each test. It is the anonymous client: no user is logged in.

pytest-django also provides an admin_client fixture (logged in as a superuser) and you can create authenticated clients using the client.force_login(user) method.

✦ Tip

Use force_login instead of login for test speed

client.login(username='x', password='y') runs the full authentication backend, including password hashing. client.force_login(user) bypasses authentication entirely and logs the user in directly. It is faster and does not require you to know the user's password.

Testing GET views

Let us say the orders application has these views:

orders/views.py
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import Order


@login_required
def order_list(request):
    orders = Order.objects.filter(customer__user=request.user).order_by("-created_at")
    return render(request, "orders/order_list.html", {"orders": orders})


@login_required
def order_detail(request, pk):
    order = get_object_or_404(Order, pk=pk, customer__user=request.user)
    return render(request, "orders/order_detail.html", {"order": order})
orders/tests/test_views.py
import pytest
from decimal import Decimal
from django.contrib.auth import get_user_model
from orders.models import Customer, Order

User = get_user_model()


@pytest.fixture
def user(db):
    return User.objects.create_user(username="alice", password="testpass123")


@pytest.fixture
def authenticated_client(client, user):
    client.force_login(user)
    return client


@pytest.fixture
def customer(db, user):
    return Customer.objects.create(
        name="Alice", email="alice@example.com", user=user
    )


@pytest.fixture
def order(db, customer):
    return Order.objects.create(customer=customer, status="pending")


# GET /orders/

@pytest.mark.django_db
def test_order_list_returns_200_for_authenticated_user(authenticated_client):
    response = authenticated_client.get("/orders/")
    assert response.status_code == 200


@pytest.mark.django_db
def test_order_list_redirects_anonymous_user(client):
    response = client.get("/orders/")
    assert response.status_code == 302
    assert "/login/" in response["Location"]


@pytest.mark.django_db
def test_order_list_contains_user_orders(authenticated_client, order):
    response = authenticated_client.get("/orders/")
    assert response.status_code == 200
    assert order in response.context["orders"]


@pytest.mark.django_db
def test_order_list_uses_correct_template(authenticated_client):
    response = authenticated_client.get("/orders/")
    assert "orders/order_list.html" in [t.name for t in response.templates]


# GET /orders/<pk>/

@pytest.mark.django_db
def test_order_detail_returns_200_for_owner(authenticated_client, order):
    response = authenticated_client.get(f"/orders/{order.pk}/")
    assert response.status_code == 200


@pytest.mark.django_db
def test_order_detail_returns_404_for_nonexistent_order(authenticated_client):
    response = authenticated_client.get("/orders/99999/")
    assert response.status_code == 404


@pytest.mark.django_db
def test_order_detail_returns_404_for_another_users_order(client, db):
    other_user = User.objects.create_user(username="bob", password="testpass123")
    other_customer = Customer.objects.create(
        name="Bob", email="bob@example.com", user=other_user
    )
    other_order = Order.objects.create(customer=other_customer, status="pending")

    alice = User.objects.create_user(username="alice2", password="testpass123")
    client.force_login(alice)

    response = client.get(f"/orders/{other_order.pk}/")
    assert response.status_code == 404

The fixture hierarchy here is worth understanding. authenticated_client depends on client and user. It calls client.force_login(user) and returns the same client, now logged in. Any test that uses authenticated_client is automatically operating as that user, with no extra setup required.

The test for another user's order does not use the authenticated_client fixture because it needs a specifically different user. Creating the objects inline is the right call when the fixture does not match the scenario.

Testing POST views

POST tests verify that data submitted to a view is processed correctly: the record is created or updated, the response has the right status code, and the database is in the expected state.

orders/views.py (addition)
from django.views.decorators.http import require_POST
from django.http import JsonResponse
from .services import cancel_order


@login_required
@require_POST
def cancel_order_view(request, pk):
    order = get_object_or_404(Order, pk=pk, customer__user=request.user)
    try:
        cancel_order(order)
    except ValueError as exc:
        return JsonResponse({"error": str(exc)}, status=400)
    return JsonResponse({"status": "cancelled"})
python
@pytest.mark.django_db
def test_cancel_order_sets_status_to_cancelled(authenticated_client, order):
    response = authenticated_client.post(f"/orders/{order.pk}/cancel/")
    assert response.status_code == 200
    order.refresh_from_db()
    assert order.status == "cancelled"


@pytest.mark.django_db
def test_cancel_shipped_order_returns_400(authenticated_client, customer):
    shipped_order = Order.objects.create(customer=customer, status="shipped")
    response = authenticated_client.post(f"/orders/{shipped_order.pk}/cancel/")
    assert response.status_code == 400
    assert "cannot be cancelled" in response.json()["error"]


@pytest.mark.django_db
def test_cancel_order_rejects_get_request(authenticated_client, order):
    response = authenticated_client.get(f"/orders/{order.pk}/cancel/")
    assert response.status_code == 405


@pytest.mark.django_db
def test_cancel_order_rejects_anonymous_user(client, order):
    response = client.post(f"/orders/{order.pk}/cancel/")
    assert response.status_code == 302

Testing that GET is rejected on a POST-only view (405 Method Not Allowed) is easy to overlook but important. An endpoint that accidentally accepts GET for a state-changing operation is a security problem.

Testing redirects

Django's test client follows redirects by default when you pass follow=True. Without it, you get the 302 response directly and can assert on the Location header. Both approaches are useful depending on what you are verifying.

python
@pytest.mark.django_db
def test_login_required_redirects_to_login_page(client):
    response = client.get("/orders/")
    # Assert on the redirect itself
    assert response.status_code == 302
    assert response["Location"].startswith("/login/")


@pytest.mark.django_db
def test_login_required_redirects_back_after_login(client, user):
    # Follow the redirect chain
    response = client.get("/orders/", follow=True)
    assert response.status_code == 200
    assert "login" in response.redirect_chain[0][0]


@pytest.mark.django_db
def test_successful_form_submission_redirects_to_list(authenticated_client, customer):
    response = authenticated_client.post(
        "/orders/create/",
        data={"product": "Widget", "quantity": 1},
        follow=False,
    )
    assert response.status_code == 302
    assert response["Location"] == "/orders/"

✦ Tip

Assert on the redirect, not the final page

When testing that a form submission redirects correctly, assert on the 302 and the Location header rather than following the redirect chain. If you follow=True and the redirect destination has its own problems, your test fails for the wrong reason. Test one thing at a time.

Testing authentication

Every protected view needs two baseline tests: one that verifies authenticated access works, and one that verifies unauthenticated access is denied. These are so consistent that it is worth establishing a fixture pattern for them.

orders/tests/conftest.py (addition)
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.fixture
def user(db):
    return User.objects.create_user(username="alice", password="testpass123")


@pytest.fixture
def other_user(db):
    return User.objects.create_user(username="bob", password="testpass123")


@pytest.fixture
def authenticated_client(client, user):
    client.force_login(user)
    return client


@pytest.fixture
def other_authenticated_client(client, other_user):
    client.force_login(other_user)
    return client
python
@pytest.mark.django_db
def test_protected_view_allows_authenticated_user(authenticated_client):
    response = authenticated_client.get("/orders/")
    assert response.status_code == 200


@pytest.mark.django_db
def test_protected_view_rejects_anonymous_user(client):
    response = client.get("/orders/")
    assert response.status_code == 302


@pytest.mark.django_db
def test_user_cannot_access_another_users_resource(other_authenticated_client, order):
    # order belongs to 'alice', this client is 'bob'
    response = other_authenticated_client.get(f"/orders/{order.pk}/")
    assert response.status_code == 404

Testing URL routing

URL routing is a source of bugs that unit tests cannot catch. A view can be correct but unreachable if the URL pattern is misconfigured. Django provides reverse() to look up URLs by name, and resolve() to check that a URL resolves to the right view.

orders/tests/test_urls.py
import pytest
from django.urls import reverse, resolve
from orders.views import order_list, order_detail, cancel_order_view


def test_order_list_url_resolves_to_correct_view():
    url = reverse("orders:order-list")
    resolved = resolve(url)
    assert resolved.func == order_list


def test_order_detail_url_resolves_to_correct_view():
    url = reverse("orders:order-detail", kwargs={"pk": 1})
    resolved = resolve(url)
    assert resolved.func == order_detail


def test_cancel_order_url_resolves_to_correct_view():
    url = reverse("orders:cancel-order", kwargs={"pk": 1})
    resolved = resolve(url)
    assert resolved.func == cancel_order_view


def test_order_list_url_is_correct():
    assert reverse("orders:order-list") == "/orders/"


def test_order_detail_url_is_correct():
    assert reverse("orders:order-detail", kwargs={"pk": 42}) == "/orders/42/"

These tests have no database access and no HTTP requests. They verify the URL configuration alone. They catch the common mistake of renaming a URL pattern without updating all the reverse() calls that depend on it.

Using reverse() in integration tests rather than hardcoded paths is also a best practice. If the URL changes, one test (the URL test) fails, not twenty view tests.

python
# Hardcoded paths break when URLs change
response = authenticated_client.get("/orders/")  # fragile

# reverse() breaks only the URL test when a URL changes
from django.urls import reverse
response = authenticated_client.get(reverse("orders:order-list"))  # robust

Testing middleware

Middleware modifies every request or response that passes through it. Testing it means making a real request and asserting on the side effects: a header was added, a log was written, or a request was rejected.

orders/middleware.py
class RequestIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        response["X-Request-Id"] = request.META.get("HTTP_X_REQUEST_ID", "none")
        return response
python
@pytest.mark.django_db
def test_middleware_adds_request_id_header(client):
    response = client.get("/", HTTP_X_REQUEST_ID="test-id-123")
    assert response["X-Request-Id"] == "test-id-123"


@pytest.mark.django_db
def test_middleware_sets_none_when_request_id_is_absent(client):
    response = client.get("/")
    assert response["X-Request-Id"] == "none"

Pass custom HTTP headers to the test client using the HTTP_ prefix with the header name uppercased and hyphens replaced by underscores. X-Request-Id becomes HTTP_X_REQUEST_ID.

DRF and APIClient

If your Django application has a REST API built with Django REST Framework, use rest_framework.test.APIClient instead of Django's plain Client. APIClient adds several conveniences for API testing:

  • Sends and receives JSON by default.
  • Supports token authentication, JWT, and session auth out of the box.
  • Parses response bodies into Python dictionaries automatically via response.data.
  • Has a force_authenticate(user) method that bypasses auth entirely, faster than force_login for APIs.

Here are the views we will test:

orders/api/views.py
from rest_framework import generics, permissions, filters
from rest_framework.exceptions import PermissionDenied
from .serializers import OrderSerializer, OrderCreateSerializer
from ..models import Order


class OrderListCreateView(generics.ListCreateAPIView):
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return Order.objects.filter(
            customer__user=self.request.user
        ).order_by("-created_at")

    def get_serializer_class(self):
        if self.request.method == "POST":
            return OrderCreateSerializer
        return OrderSerializer

    def perform_create(self, serializer):
        serializer.save(customer=self.request.user.customer)


class OrderDetailView(generics.RetrieveDestroyAPIView):
    serializer_class = OrderSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return Order.objects.filter(customer__user=self.request.user)
orders/api/serializers.py
from rest_framework import serializers
from ..models import Order


class OrderSerializer(serializers.ModelSerializer):
    total = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)
    customer_name = serializers.CharField(source="customer.name", read_only=True)

    class Meta:
        model = Order
        fields = ["id", "status", "subtotal", "total", "customer_name", "created_at"]
        read_only_fields = ["id", "status", "total", "customer_name", "created_at"]


class OrderCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["subtotal"]

Set up the API client fixture in conftest.py:

orders/tests/conftest.py (addition)
import pytest
from rest_framework.test import APIClient


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


@pytest.fixture
def authenticated_api_client(api_client, user):
    api_client.force_authenticate(user=user)
    return api_client

Testing API authentication

orders/tests/test_api.py
import pytest
from decimal import Decimal
from django.urls import reverse
from orders.models import Customer, Order


@pytest.mark.django_db
def test_list_orders_returns_200_for_authenticated_user(authenticated_api_client):
    response = authenticated_api_client.get(reverse("api:order-list"))
    assert response.status_code == 200


@pytest.mark.django_db
def test_list_orders_returns_401_for_anonymous_user(api_client):
    response = api_client.get(reverse("api:order-list"))
    assert response.status_code == 401


@pytest.mark.django_db
def test_create_order_returns_401_for_anonymous_user(api_client):
    response = api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "100.00"},
        format="json",
    )
    assert response.status_code == 401

Testing serializer validation

Serializer validation is the API's contract with its callers. If the API accepts invalid input silently, or rejects valid input with a cryptic error, clients cannot rely on it. Integration tests pin this behaviour down.

python
@pytest.mark.django_db
def test_create_order_with_valid_data_returns_201(authenticated_api_client, customer):
    response = authenticated_api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "100.00"},
        format="json",
    )
    assert response.status_code == 201


@pytest.mark.django_db
def test_create_order_without_subtotal_returns_400(authenticated_api_client, customer):
    response = authenticated_api_client.post(
        reverse("api:order-list"),
        data={},
        format="json",
    )
    assert response.status_code == 400
    assert "subtotal" in response.data


@pytest.mark.django_db
def test_create_order_with_negative_subtotal_returns_400(authenticated_api_client, customer):
    response = authenticated_api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "-50.00"},
        format="json",
    )
    assert response.status_code == 400


@pytest.mark.django_db
def test_create_order_with_non_numeric_subtotal_returns_400(authenticated_api_client, customer):
    response = authenticated_api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "not-a-number"},
        format="json",
    )
    assert response.status_code == 400
    assert "subtotal" in response.data

The pattern for validation tests is consistent: one test for each invalid input type, always asserting on both the status code (400) and which field the error belongs to. This ensures that errors are not just happening but are attributed to the right field, which is what clients use to display inline form errors.

Testing response shape

The shape of an API response is a contract. If a client depends on response.customer_name and the field is renamed or removed, the client breaks. Integration tests that assert on the response structure catch these regressions before they reach production.

python
@pytest.mark.django_db
def test_order_detail_response_has_expected_fields(authenticated_api_client, order):
    response = authenticated_api_client.get(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    assert response.status_code == 200

    data = response.data
    assert "id" in data
    assert "status" in data
    assert "subtotal" in data
    assert "total" in data
    assert "customer_name" in data
    assert "created_at" in data


@pytest.mark.django_db
def test_order_detail_response_values_are_correct(authenticated_api_client, order, customer):
    response = authenticated_api_client.get(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    data = response.data
    assert data["id"] == order.pk
    assert data["status"] == "pending"
    assert data["customer_name"] == customer.name


@pytest.mark.django_db
def test_order_list_returns_only_current_user_orders(authenticated_api_client, order, db):
    from django.contrib.auth import get_user_model
    User = get_user_model()
    other_user = User.objects.create_user(username="bob", password="pass")
    other_customer = Customer.objects.create(name="Bob", email="bob@example.com", user=other_user)
    Order.objects.create(customer=other_customer, status="pending")

    response = authenticated_api_client.get(reverse("api:order-list"))

    assert response.status_code == 200
    ids_in_response = [item["id"] for item in response.data["results"]]
    assert order.pk in ids_in_response
    assert len(ids_in_response) == 1

⚠ Gotcha

Assert on response.data, not response.content

response.data is the DRF-parsed Python dictionary. response.content is raw bytes. Always use response.data in API tests so you are working with structured data rather than string-searching through JSON.

Testing filtering and pagination

Filtering and pagination are easy to break because they depend on query parameters, and the bugs tend to be silent: a filter that does nothing returns all records instead of failing, and nobody notices until a client processes ten times the expected data.

orders/api/views.py (with filtering)
from rest_framework import generics, permissions, filters


class OrderListCreateView(generics.ListCreateAPIView):
    permission_classes = [permissions.IsAuthenticated]
    filter_backends = [filters.SearchFilter, filters.OrderingFilter]
    search_fields = ["status"]
    ordering_fields = ["created_at", "subtotal"]
    ordering = ["-created_at"]

    def get_queryset(self):
        queryset = Order.objects.filter(customer__user=self.request.user)
        status = self.request.query_params.get("status")
        if status:
            queryset = queryset.filter(status=status)
        return queryset.order_by("-created_at")
python
@pytest.mark.django_db
def test_filter_by_status_returns_only_matching_orders(authenticated_api_client, customer):
    Order.objects.create(customer=customer, status="pending")
    Order.objects.create(customer=customer, status="confirmed")
    Order.objects.create(customer=customer, status="shipped")

    response = authenticated_api_client.get(
        reverse("api:order-list"),
        {"status": "pending"},
    )

    assert response.status_code == 200
    statuses = [item["status"] for item in response.data["results"]]
    assert statuses == ["pending"]


@pytest.mark.django_db
def test_filter_by_unknown_status_returns_empty(authenticated_api_client, customer):
    Order.objects.create(customer=customer, status="pending")

    response = authenticated_api_client.get(
        reverse("api:order-list"),
        {"status": "nonexistent"},
    )

    assert response.status_code == 200
    assert response.data["results"] == []


@pytest.mark.django_db
def test_pagination_limits_results_per_page(authenticated_api_client, customer):
    for i in range(15):
        Order.objects.create(customer=customer, status="pending")

    response = authenticated_api_client.get(reverse("api:order-list"))

    assert response.status_code == 200
    assert response.data["count"] == 15
    assert len(response.data["results"]) == 10  # assuming page_size=10
    assert response.data["next"] is not None


@pytest.mark.django_db
def test_second_page_returns_remaining_results(authenticated_api_client, customer):
    for i in range(15):
        Order.objects.create(customer=customer, status="pending")

    response = authenticated_api_client.get(
        reverse("api:order-list"),
        {"page": 2},
    )

    assert response.status_code == 200
    assert len(response.data["results"]) == 5
    assert response.data["next"] is None

Testing permissions

Permission bugs are among the most consequential bugs in a web application. A user who can access data they should not be able to access is a data breach. Test permission boundaries explicitly, for every resource and every role.

orders/api/permissions.py
from rest_framework.permissions import BasePermission


class IsOrderOwner(BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.customer.user == request.user
python
@pytest.mark.django_db
def test_owner_can_retrieve_their_order(authenticated_api_client, order):
    response = authenticated_api_client.get(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    assert response.status_code == 200


@pytest.mark.django_db
def test_non_owner_cannot_retrieve_order(api_client, order, db):
    from django.contrib.auth import get_user_model
    User = get_user_model()
    other_user = User.objects.create_user(username="bob", password="pass")
    api_client.force_authenticate(user=other_user)

    response = api_client.get(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    assert response.status_code == 404


@pytest.mark.django_db
def test_owner_can_delete_their_order(authenticated_api_client, order):
    response = authenticated_api_client.delete(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    assert response.status_code == 204
    assert not Order.objects.filter(pk=order.pk).exists()


@pytest.mark.django_db
def test_non_owner_cannot_delete_order(api_client, order, db):
    from django.contrib.auth import get_user_model
    User = get_user_model()
    other_user = User.objects.create_user(username="bob", password="pass")
    api_client.force_authenticate(user=other_user)

    response = api_client.delete(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )
    assert response.status_code == 404
    assert Order.objects.filter(pk=order.pk).exists()

The final assertion in each negative permission test (confirming the record still exists) is critical. A 404 response means the view could not find the record for this user. But you need to verify the record was not silently deleted anyway. Without that assertion, a broken permission check that deletes everything and returns 404 would pass your test.

Database state assertions

Integration tests should assert on database state after write operations, not just on the HTTP response. A 201 response means the view returned success. The database assertion confirms the record was actually written.

python
@pytest.mark.django_db
def test_create_order_writes_record_to_database(authenticated_api_client, customer):
    assert Order.objects.count() == 0

    authenticated_api_client.post(
        reverse("api:order-list"),
        data={"subtotal": "100.00"},
        format="json",
    )

    assert Order.objects.count() == 1
    order = Order.objects.first()
    assert order.subtotal == Decimal("100.00")
    assert order.status == "pending"
    assert order.customer == customer


@pytest.mark.django_db
def test_delete_order_removes_record_from_database(authenticated_api_client, order):
    assert Order.objects.filter(pk=order.pk).exists()

    authenticated_api_client.delete(
        reverse("api:order-detail", kwargs={"pk": order.pk})
    )

    assert not Order.objects.filter(pk=order.pk).exists()

✦ Tip

Assert count before and after write operations

Asserting count == 0 before a create test and count == 1 after ensures the test is not accidentally passing because the record was created in a previous test that leaked state. It also confirms exactly one record was created, not multiple.

For more complex database assertions, use filter() with specific field values rather than asserting on the whole object. This makes test failures more informative:

python
# Less informative: tells you the object is wrong but not what field
assert Order.objects.first() == expected_order

# More informative: tells you exactly which field is wrong
order = Order.objects.first()
assert order.status == "pending"
assert order.customer == customer
assert order.subtotal == Decimal("100.00")

Summary

  • Integration tests verify that your components are wired together correctly: URL routing, views, middleware, serializers, authentication, and database writes all working as a system.
  • Use client from pytest-django for standard Django views. Use APIClient from DRF for REST API endpoints.
  • Use force_login and force_authenticate instead of login to skip password hashing and speed up test setup.
  • Define authenticated_client and authenticated_api_client as fixtures in conftest.py so every test that needs an authenticated user can get one by declaring a parameter.
  • Use reverse() instead of hardcoded URLs in integration tests. When a URL changes, one test fails instead of many.
  • Test URL routing separately with resolve() to catch misconfigured URL patterns without making HTTP requests.
  • For every protected view, write an authenticated test and an unauthenticated test.
  • For permission boundaries, assert on database state after a failed write to confirm the operation was truly rejected, not just that the response code was correct.
  • Use response.data in DRF tests, not response.content.
  • Assert on response shape (field names and values), not just status codes. The shape is part of the API contract.
  • Test the zero case for filters: a filter that returns no results is valid and should be tested explicitly.

In the next article we go deep on authentication and permissions: login flows, token-based auth, custom permission classes, and role-based access control, with a test for every boundary.