Testing Django REST Framework APIs: Views, Serializers, and Auth

Testing Django REST Framework APIs: Views, Serializers, and Auth

Django REST Framework (DRF) provides APIClient and APITestCase — a layer above Django's test client with JSON handling and authentication helpers. Combined with pytest-django, you get a fast, ergonomic API testing workflow.

Setup

pip install djangorestframework pytest pytest-django
# settings/test.py
INSTALLED_APPS = [
    ...
    'rest_framework',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.TokenAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10,
}

APIClient Basics

# conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient

User = get_user_model()


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


@pytest.fixture
def user(db):
    return User.objects.create_user(
        username='alice', email='alice@example.com', password='pass123'
    )


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

Testing List and Detail Views

# tests/test_articles_api.py
import pytest
from django.urls import reverse
from myapp.models import Article


@pytest.fixture
def articles(db, user):
    return [
        Article.objects.create(
            title=f'Article {i}',
            content=f'Content {i}',
            author=user,
        )
        for i in range(3)
    ]


@pytest.mark.django_db
class TestArticleListAPI:
    def test_list_returns_200(self, authenticated_client, articles):
        response = authenticated_client.get('/api/articles/')
        assert response.status_code == 200

    def test_list_returns_all_articles(self, authenticated_client, articles):
        response = authenticated_client.get('/api/articles/')
        assert response.data['count'] == 3

    def test_list_unauthenticated_returns_401(self, api_client, articles):
        response = api_client.get('/api/articles/')
        assert response.status_code == 401

    def test_list_filters_by_author(self, authenticated_client, articles, user, db):
        other_user = User.objects.create_user(username='bob', password='pass')
        Article.objects.create(title='Bob post', content='...', author=other_user)

        response = authenticated_client.get(f'/api/articles/?author={user.pk}')
        assert response.data['count'] == 3  # Only user's articles


@pytest.mark.django_db
class TestArticleDetailAPI:
    def test_retrieve_returns_correct_data(self, authenticated_client, articles):
        article = articles[0]
        response = authenticated_client.get(f'/api/articles/{article.pk}/')
        assert response.status_code == 200
        assert response.data['title'] == article.title
        assert response.data['id'] == article.pk

    def test_retrieve_nonexistent_returns_404(self, authenticated_client):
        response = authenticated_client.get('/api/articles/99999/')
        assert response.status_code == 404

Testing Create, Update, Delete

@pytest.mark.django_db
class TestArticleCRUD:
    def test_create_article(self, authenticated_client, user):
        payload = {
            'title': 'New Article',
            'content': 'Body text here',
        }
        response = authenticated_client.post('/api/articles/', payload, format='json')

        assert response.status_code == 201
        assert response.data['title'] == 'New Article'
        assert Article.objects.filter(title='New Article').exists()

    def test_create_requires_title(self, authenticated_client):
        response = authenticated_client.post(
            '/api/articles/', {'content': 'No title'}, format='json'
        )
        assert response.status_code == 400
        assert 'title' in response.data

    def test_update_own_article(self, authenticated_client, articles):
        article = articles[0]
        response = authenticated_client.patch(
            f'/api/articles/{article.pk}/',
            {'title': 'Updated Title'},
            format='json',
        )
        assert response.status_code == 200
        article.refresh_from_db()
        assert article.title == 'Updated Title'

    def test_cannot_update_others_article(self, api_client, articles, db):
        other_user = User.objects.create_user(username='other', password='pass')
        api_client.force_authenticate(user=other_user)

        article = articles[0]  # owned by 'alice'
        response = api_client.patch(
            f'/api/articles/{article.pk}/',
            {'title': 'Stolen'},
            format='json',
        )
        assert response.status_code == 403

    def test_delete_article(self, authenticated_client, articles):
        article = articles[0]
        response = authenticated_client.delete(f'/api/articles/{article.pk}/')
        assert response.status_code == 204
        assert not Article.objects.filter(pk=article.pk).exists()

Testing Serializers

Test serializers independently from views — they're plain Python:

# tests/test_serializers.py
import pytest
from myapp.serializers import ArticleSerializer
from django.contrib.auth import get_user_model

User = get_user_model()


@pytest.mark.django_db
class TestArticleSerializer:
    def test_valid_data_serializes(self, user):
        serializer = ArticleSerializer(
            data={'title': 'My Post', 'content': 'Body'},
            context={'request': type('Request', (), {'user': user})()},
        )
        assert serializer.is_valid(), serializer.errors

    def test_missing_title_is_invalid(self):
        serializer = ArticleSerializer(data={'content': 'Body'})
        assert not serializer.is_valid()
        assert 'title' in serializer.errors

    def test_title_too_long_is_invalid(self):
        serializer = ArticleSerializer(data={
            'title': 'x' * 256,
            'content': 'Body',
        })
        assert not serializer.is_valid()
        assert 'title' in serializer.errors

    def test_serialized_output_fields(self, user):
        from myapp.models import Article
        article = Article.objects.create(
            title='Test',
            content='Content',
            author=user,
        )
        serializer = ArticleSerializer(article)
        data = serializer.data

        assert 'id' in data
        assert 'title' in data
        assert 'created_at' in data
        assert 'password' not in data  # Sensitive fields excluded

    def test_nested_author_serializer(self, user):
        from myapp.models import Article
        article = Article.objects.create(
            title='Test', content='...', author=user
        )
        serializer = ArticleSerializer(article)
        assert serializer.data['author']['username'] == 'alice'
        assert 'password' not in serializer.data['author']

Testing Authentication

# tests/test_auth.py
import pytest
from rest_framework.authtoken.models import Token


@pytest.mark.django_db
class TestTokenAuth:
    def test_obtain_token(self, api_client, user):
        response = api_client.post('/api/auth/token/', {
            'username': 'alice',
            'password': 'pass123',
        })
        assert response.status_code == 200
        assert 'token' in response.data

    def test_token_authentication_works(self, api_client, user):
        token = Token.objects.create(user=user)
        api_client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}')

        response = api_client.get('/api/articles/')
        assert response.status_code == 200

    def test_invalid_token_returns_401(self, api_client):
        api_client.credentials(HTTP_AUTHORIZATION='Token invalidtoken123')
        response = api_client.get('/api/articles/')
        assert response.status_code == 401

    def test_logout_invalidates_token(self, authenticated_client, user):
        Token.objects.create(user=user)
        response = authenticated_client.post('/api/auth/logout/')
        assert response.status_code == 200
        assert not Token.objects.filter(user=user).exists()

Testing Permissions

@pytest.mark.django_db
class TestArticlePermissions:
    def test_regular_user_cannot_publish(self, api_client, user, articles):
        api_client.force_authenticate(user=user)
        response = api_client.post(
            f'/api/articles/{articles[0].pk}/publish/'
        )
        assert response.status_code == 403

    def test_staff_user_can_publish(self, api_client, db):
        staff = User.objects.create_user(
            username='staff', password='pass', is_staff=True
        )
        Article.objects.create(title='Draft', content='...', author=staff)
        api_client.force_authenticate(user=staff)

        article = Article.objects.get(title='Draft')
        response = api_client.post(f'/api/articles/{article.pk}/publish/')
        assert response.status_code == 200

Testing Pagination

@pytest.mark.django_db
def test_pagination(authenticated_client, user, db):
    # Create 25 articles (more than PAGE_SIZE=10)
    Article.objects.bulk_create([
        Article(title=f'Article {i}', content='...', author=user)
        for i in range(25)
    ])

    response = authenticated_client.get('/api/articles/')
    assert response.status_code == 200
    assert response.data['count'] == 25
    assert len(response.data['results']) == 10
    assert response.data['next'] is not None

    # Test second page
    response = authenticated_client.get('/api/articles/?page=2')
    assert len(response.data['results']) == 10

    # Test last page
    response = authenticated_client.get('/api/articles/?page=3')
    assert len(response.data['results']) == 5
    assert response.data['next'] is None

Testing File Uploads

import io
from PIL import Image


def create_test_image():
    image = Image.new('RGB', (100, 100), color='red')
    img_io = io.BytesIO()
    image.save(img_io, format='JPEG')
    img_io.seek(0)
    return img_io


@pytest.mark.django_db
def test_upload_article_image(authenticated_client, articles):
    article = articles[0]
    image = create_test_image()

    response = authenticated_client.patch(
        f'/api/articles/{article.pk}/',
        {'cover_image': image},
        format='multipart',
    )
    assert response.status_code == 200
    assert response.data['cover_image'] is not None

Response Data Patterns

# Assert specific fields without coupling to full response shape
def test_article_response_shape(authenticated_client, articles):
    response = authenticated_client.get(f'/api/articles/{articles[0].pk}/')

    assert response.status_code == 200
    data = response.data

    # Required fields present
    assert {'id', 'title', 'content', 'author', 'created_at'}.issubset(data.keys())

    # Sensitive fields absent
    assert 'author_password' not in data
    assert '_internal_id' not in data

    # Types correct
    assert isinstance(data['id'], int)
    assert isinstance(data['title'], str)

Key Patterns

  • Use force_authenticate over client.login() — faster and doesn't hit the auth backend
  • Test serializers independently from views — they're pure Python classes
  • Use format='json' when posting JSON to avoid form encoding issues
  • Test permissions explicitly for every role (anonymous, regular, staff, admin)
  • Assert response.data keys and types, not just status codes
  • Use bulk_create for pagination tests — creating 25+ objects one by one is slow

DRF's clear separation of serializers, permissions, and views makes each layer independently testable. Start with serializer validation, move to permission checks, then verify end-to-end view behavior.

Read more

Start now free