Testing Django REST Framework APIs: APITestCase, Authentication, and Serializer Validation

Testing Django REST Framework APIs: APITestCase, Authentication, and Serializer Validation

Django REST Framework comes with its own testing layer built on top of Django's TestCase. APITestCase and APIClient understand DRF's authentication schemes, content negotiation, and response parsing. If you're writing raw Django TestCase to test DRF views, you're working harder than you need to.

APIClient vs Django's Test Client

APIClient extends Django's Client with DRF-specific conveniences:

  • Content type defaults — sends application/json by default
  • Authentication helpersforce_authenticate(), credentials(), token helpers
  • Response parsingresponse.data gives you the parsed Python object, not raw JSON bytes
  • Format negotiation — supports format="json", format="multipart", etc.
from rest_framework.test import APIClient, APITestCase
from rest_framework import status
from django.contrib.auth.models import User
from myapp.models import Article

class ArticleAPITest(APITestCase):
    def setUp(self):
        self.client = APIClient()
        self.user = User.objects.create_user(
            username="testuser",
            password="testpass123",
            email="test@example.com"
        )

    def test_list_articles_unauthenticated(self):
        response = self.client.get("/api/articles/")
        # DRF returns 401 for unauthenticated requests to protected endpoints
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_list_articles_authenticated(self):
        self.client.force_authenticate(user=self.user)
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        # response.data is already parsed — no .json() needed
        self.assertIsInstance(response.data, list)

force_authenticate() bypasses all authentication checks and attaches a user directly. Use it when you want to test view behavior independently of authentication logic.

Authentication Testing

Token Authentication

from rest_framework.authtoken.models import Token

class TokenAuthTest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="tokenuser",
            password="tokenpass123"
        )
        self.token = Token.objects.create(user=self.user)

    def test_obtain_token(self):
        response = self.client.post("/api/auth/token/", {
            "username": "tokenuser",
            "password": "tokenpass123"
        })
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIn("token", response.data)

    def test_authenticate_with_token(self):
        self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_invalid_token_rejected(self):
        self.client.credentials(HTTP_AUTHORIZATION="Token invalidtoken12345")
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_missing_token_rejected(self):
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
        self.assertEqual(response.data["detail"].code, "not_authenticated")

JWT Authentication

For djangorestframework-simplejwt:

from rest_framework_simplejwt.tokens import RefreshToken

class JWTAuthTest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="jwtuser",
            password="jwtpass123"
        )

    def get_tokens_for_user(self, user):
        refresh = RefreshToken.for_user(user)
        return {
            "refresh": str(refresh),
            "access": str(refresh.access_token),
        }

    def test_obtain_jwt_tokens(self):
        response = self.client.post("/api/auth/jwt/token/", {
            "username": "jwtuser",
            "password": "jwtpass123"
        }, format="json")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIn("access", response.data)
        self.assertIn("refresh", response.data)

    def test_access_token_authenticates(self):
        tokens = self.get_tokens_for_user(self.user)
        self.client.credentials(
            HTTP_AUTHORIZATION=f"Bearer {tokens['access']}"
        )
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_refresh_token_returns_new_access(self):
        tokens = self.get_tokens_for_user(self.user)
        response = self.client.post("/api/auth/jwt/refresh/", {
            "refresh": tokens["refresh"]
        }, format="json")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIn("access", response.data)
        # New access token should differ from original
        self.assertNotEqual(response.data["access"], tokens["access"])

    def test_expired_access_token_rejected(self):
        # Create a token, then manually expire it
        from datetime import timedelta
        from django.utils import timezone
        tokens = self.get_tokens_for_user(self.user)
        # Simulate expiry by using an invalid token
        self.client.credentials(
            HTTP_AUTHORIZATION="Bearer expiredtokenvalue"
        )
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

Session Authentication

class SessionAuthTest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="sessionuser",
            password="sessionpass123"
        )

    def test_session_login_and_access(self):
        # Login via the session endpoint
        self.client.login(username="sessionuser", password="sessionpass123")
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_logout_revokes_access(self):
        self.client.login(username="sessionuser", password="sessionpass123")
        self.client.post("/api/auth/logout/")
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

CRUD Endpoint Testing

class ArticleCRUDTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(
            username="cruduser",
            password="crudpass123"
        )
        cls.other_user = User.objects.create_user(
            username="otheruser",
            password="otherpass123"
        )

    def setUp(self):
        self.client = APIClient()
        self.client.force_authenticate(user=self.user)
        self.article = Article.objects.create(
            title="Existing Article",
            content="Some content",
            author=self.user,
            status="published"
        )

    def test_create_article(self):
        payload = {
            "title": "New Article",
            "content": "New content here",
            "status": "draft"
        }
        response = self.client.post("/api/articles/", payload, format="json")
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data["title"], "New Article")
        self.assertEqual(response.data["author"], self.user.id)
        # Verify it's actually in the database
        self.assertTrue(
            Article.objects.filter(title="New Article").exists()
        )

    def test_retrieve_article(self):
        response = self.client.get(f"/api/articles/{self.article.pk}/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["id"], self.article.pk)
        self.assertEqual(response.data["title"], "Existing Article")

    def test_update_own_article(self):
        payload = {"title": "Updated Title", "content": "Updated content"}
        response = self.client.patch(
            f"/api/articles/{self.article.pk}/",
            payload,
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["title"], "Updated Title")
        self.article.refresh_from_db()
        self.assertEqual(self.article.title, "Updated Title")

    def test_cannot_update_others_article(self):
        other_article = Article.objects.create(
            title="Other's Article",
            content="Content",
            author=self.other_user,
            status="published"
        )
        response = self.client.patch(
            f"/api/articles/{other_article.pk}/",
            {"title": "Hijacked Title"},
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_delete_own_article(self):
        response = self.client.delete(f"/api/articles/{self.article.pk}/")
        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
        self.assertFalse(
            Article.objects.filter(pk=self.article.pk).exists()
        )

    def test_retrieve_nonexistent_returns_404(self):
        response = self.client.get("/api/articles/99999/")
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

Serializer Validation Testing

Test serializers directly, independently of views. This is faster and isolates validation logic:

# myapp/serializers.py
from rest_framework import serializers
from myapp.models import Article

class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["id", "title", "content", "status", "author", "published_at"]
        read_only_fields = ["id", "author", "published_at"]

    def validate_title(self, value):
        if len(value.strip()) < 5:
            raise serializers.ValidationError(
                "Title must be at least 5 characters."
            )
        return value.strip()

    def validate_status(self, value):
        allowed = ["draft", "published", "archived"]
        if value not in allowed:
            raise serializers.ValidationError(
                f"Status must be one of: {', '.join(allowed)}"
            )
        return value

    def validate(self, attrs):
        if attrs.get("status") == "published" and not attrs.get("content"):
            raise serializers.ValidationError(
                "Published articles must have content."
            )
        return attrs
# tests/test_serializers.py
from django.test import TestCase
from django.contrib.auth.models import User
from myapp.serializers import ArticleSerializer

class ArticleSerializerTest(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="serializertest",
            password="testpass"
        )
        self.valid_data = {
            "title": "Valid Article Title",
            "content": "This is valid content.",
            "status": "draft"
        }

    def test_valid_data_passes(self):
        serializer = ArticleSerializer(data=self.valid_data)
        self.assertTrue(serializer.is_valid(), serializer.errors)

    def test_title_too_short_fails(self):
        data = {**self.valid_data, "title": "Hi"}
        serializer = ArticleSerializer(data=data)
        self.assertFalse(serializer.is_valid())
        self.assertIn("title", serializer.errors)
        self.assertIn("at least 5 characters", str(serializer.errors["title"]))

    def test_title_stripped_of_whitespace(self):
        data = {**self.valid_data, "title": "  Valid Title  "}
        serializer = ArticleSerializer(data=data)
        self.assertTrue(serializer.is_valid())
        self.assertEqual(serializer.validated_data["title"], "Valid Title")

    def test_invalid_status_fails(self):
        data = {**self.valid_data, "status": "pending"}
        serializer = ArticleSerializer(data=data)
        self.assertFalse(serializer.is_valid())
        self.assertIn("status", serializer.errors)

    def test_published_without_content_fails(self):
        data = {
            "title": "Published Article",
            "content": "",
            "status": "published"
        }
        serializer = ArticleSerializer(data=data)
        self.assertFalse(serializer.is_valid())
        self.assertIn("non_field_errors", serializer.errors)

    def test_read_only_fields_ignored(self):
        data = {
            **self.valid_data,
            "id": 999,
            "author": self.user.id,
            "published_at": "2024-01-01T00:00:00Z"
        }
        serializer = ArticleSerializer(data=data)
        self.assertTrue(serializer.is_valid())
        # Read-only fields should not appear in validated_data
        self.assertNotIn("id", serializer.validated_data)
        self.assertNotIn("author", serializer.validated_data)
        self.assertNotIn("published_at", serializer.validated_data)

    def test_serializes_output_correctly(self):
        article = Article.objects.create(
            title="Output Test",
            content="Content",
            author=self.user,
            status="published"
        )
        serializer = ArticleSerializer(article)
        data = serializer.data
        self.assertEqual(data["title"], "Output Test")
        self.assertEqual(data["author"], self.user.id)
        self.assertIn("id", data)
        self.assertNotIn("password", data)  # no leaked fields

Pagination Testing

class ArticlePaginationTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(username="pageuser", password="pass")
        Article.objects.bulk_create([
            Article(
                title=f"Article {i}",
                content="Content",
                author=cls.user,
                status="published"
            )
            for i in range(25)
        ])

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

    def test_paginated_response_structure(self):
        response = self.client.get("/api/articles/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        # PageNumberPagination response shape
        self.assertIn("count", response.data)
        self.assertIn("next", response.data)
        self.assertIn("previous", response.data)
        self.assertIn("results", response.data)

    def test_total_count_correct(self):
        response = self.client.get("/api/articles/")
        self.assertEqual(response.data["count"], 25)

    def test_second_page_accessible(self):
        response = self.client.get("/api/articles/?page=2")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertIsNotNone(response.data["previous"])

    def test_page_size_respected(self):
        response = self.client.get("/api/articles/?page_size=5")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data["results"]), 5)

Filtering and Search Testing

class ArticleFilterTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(username="filteruser", password="pass")
        cls.published = Article.objects.create(
            title="Published Post",
            content="Content",
            author=cls.user,
            status="published"
        )
        cls.draft = Article.objects.create(
            title="Draft Post",
            content="Content",
            author=cls.user,
            status="draft"
        )

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

    def test_filter_by_status(self):
        response = self.client.get("/api/articles/?status=published")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        statuses = [a["status"] for a in response.data["results"]]
        self.assertTrue(all(s == "published" for s in statuses))

    def test_search_by_title(self):
        response = self.client.get("/api/articles/?search=Published")
        results = response.data["results"]
        titles = [a["title"] for a in results]
        self.assertIn("Published Post", titles)
        self.assertNotIn("Draft Post", titles)

    def test_ordering_by_created_at(self):
        response = self.client.get("/api/articles/?ordering=-created_at")
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        results = response.data["results"]
        # Most recent first
        dates = [r["created_at"] for r in results]
        self.assertEqual(dates, sorted(dates, reverse=True))

Permission Testing

class ArticlePermissionTest(APITestCase):
    def setUp(self):
        self.owner = User.objects.create_user(username="owner", password="pass")
        self.staff = User.objects.create_user(
            username="staff",
            password="pass",
            is_staff=True
        )
        self.stranger = User.objects.create_user(username="stranger", password="pass")
        self.article = Article.objects.create(
            title="Owner's Article",
            content="Content",
            author=self.owner,
            status="published"
        )

    def test_owner_can_edit(self):
        self.client.force_authenticate(user=self.owner)
        response = self.client.patch(
            f"/api/articles/{self.article.pk}/",
            {"title": "Updated by Owner"},
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_staff_can_edit(self):
        self.client.force_authenticate(user=self.staff)
        response = self.client.patch(
            f"/api/articles/{self.article.pk}/",
            {"title": "Updated by Staff"},
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_stranger_cannot_edit(self):
        self.client.force_authenticate(user=self.stranger)
        response = self.client.patch(
            f"/api/articles/{self.article.pk}/",
            {"title": "Hijacked"},
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_unauthenticated_cannot_edit(self):
        response = self.client.patch(
            f"/api/articles/{self.article.pk}/",
            {"title": "Hijacked"},
            format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

Running DRF Tests

# Run all tests
python manage.py <span class="hljs-built_in">test

<span class="hljs-comment"># Run specific test file
python manage.py <span class="hljs-built_in">test myapp.tests.test_api

<span class="hljs-comment"># Run with pytest (if using pytest-django)
pytest myapp/tests/test_api.py -v

<span class="hljs-comment"># Generate coverage report
pytest --cov=myapp --cov-report=html myapp/tests/

From Unit Tests to Live API Monitoring

Your APITestCase suite verifies that your DRF endpoints behave correctly under controlled conditions. What it can't verify is that your API behaves correctly under real-world conditions: production authentication tokens, real database state, CDN and load balancer behavior, and the exact sequence of requests your mobile clients make.

HelpMeTest monitors your live API continuously, running browser and API test scenarios against your deployed application. When your DRF authentication starts rejecting valid tokens, or your pagination breaks after a data migration, HelpMeTest catches it before your users do. Think of it as the canary layer above your test suite.

Summary

Testing DRF APIs effectively means working at three levels:

  1. Serializer tests — fast, no HTTP overhead, isolate validation logic
  2. View tests with APITestCase — verify HTTP behavior, status codes, response shapes
  3. Permission and authentication tests — verify the access control matrix

APIClient.force_authenticate() is your best tool for testing view behavior independently of auth logic. Use credentials() when you need to test the authentication mechanism itself. Test serializers directly when you have complex validate_* methods — it's faster than making HTTP requests for every validation edge case.

Read more