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_clientTesting 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 == 404Testing 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 == 200Testing 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 NoneTesting 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 NoneResponse 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_authenticateoverclient.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.datakeys and types, not just status codes - Use
bulk_createfor 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.