Django REST Testing
Introduction
Testing is a crucial part of developing robust and reliable APIs with Django REST Framework (DRF). Well-tested code reduces bugs, ensures your API behaves as expected, and makes it easier to maintain and extend your codebase. This guide will introduce you to testing in Django REST Framework, walking through the fundamentals and providing practical examples to help you get started.
Django REST Framework builds on Django's existing test framework, which is built on Python's standard unittest
module. By the end of this tutorial, you'll understand how to write tests for your API endpoints, validate responses, and ensure your API is working as expected.
Getting Started with API Testing
Prerequisites
Before we dive into testing, ensure you have:
- A Django project with Django REST Framework installed
- Basic knowledge of Django models and DRF serializers/views
- Understanding of Python's testing concepts
Setting Up Your Testing Environment
Django provides a built-in TestCase
class, but for REST Framework testing, we'll use DRF's enhanced APITestCase
which is specifically designed for testing APIs.
First, let's create a simple model and API to test:
# models.py
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.CharField(max_length=100)
published_date = models.DateField()
isbn = models.CharField(max_length=13)
def __str__(self):
return self.title
# serializers.py
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ['id', 'title', 'author', 'published_date', 'isbn']
# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BookViewSet
router = DefaultRouter()
router.register(r'books', BookViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]
Writing Your First API Test
Now let's create a test file to test our Book API:
# tests.py
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Book
import datetime
class BookAPITests(APITestCase):
def setUp(self):
# This method will run before every test
self.book = Book.objects.create(
title='Test Book',
author='Test Author',
published_date=datetime.date(2020, 1, 1),
isbn='1234567890123'
)
def test_get_book_list(self):
"""
Ensure we can retrieve the book list.
"""
url = reverse('book-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['title'], 'Test Book')
Testing API Endpoints
Let's extend our tests to cover all CRUD operations:
def test_create_book(self):
"""
Ensure we can create a new book.
"""
url = reverse('book-list')
data = {
'title': 'New Book',
'author': 'New Author',
'published_date': '2021-01-01',
'isbn': '9876543210123'
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Book.objects.count(), 2)
self.assertEqual(Book.objects.get(pk=response.data['id']).title, 'New Book')
def test_get_book_detail(self):
"""
Ensure we can retrieve a specific book.
"""
url = reverse('book-detail', args=[self.book.id])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['title'], 'Test Book')
def test_update_book(self):
"""
Ensure we can update a book.
"""
url = reverse('book-detail', args=[self.book.id])
data = {
'title': 'Updated Book',
'author': 'Test Author',
'published_date': '2020-01-01',
'isbn': '1234567890123'
}
response = self.client.put(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['title'], 'Updated Book')
def test_delete_book(self):
"""
Ensure we can delete a book.
"""
url = reverse('book-detail', args=[self.book.id])
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Book.objects.count(), 0)
Advanced Testing Topics
Testing Authentication
Many APIs require authentication. Let's see how to test an API endpoint that requires authentication:
from django.contrib.auth.models import User
from rest_framework.authtoken.models import Token
class AuthenticatedAPITests(APITestCase):
def setUp(self):
# Create a user
self.user = User.objects.create_user(
username='testuser',
email='[email protected]',
password='testpass123'
)
# Create a token for the user
self.token = Token.objects.create(user=self.user)
# Add the token to the authorization header
self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
# Create a test book
self.book = Book.objects.create(
title='Secret Book',
author='Secret Author',
published_date=datetime.date(2020, 1, 1),
isbn='1234567890123'
)
def test_authenticated_request(self):
"""
Ensure we can access protected endpoints when authenticated.
"""
url = reverse('book-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_unauthenticated_request(self):
"""
Ensure unauthenticated requests are rejected.
"""
# Remove the authentication credentials
self.client.credentials()
url = reverse('book-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
Testing Permissions
Testing different permission scenarios:
def test_admin_user_permissions(self):
"""
Ensure admin users can perform all actions.
"""
# Make the user a staff member
self.user.is_staff = True
self.user.save()
# Test creating a book
url = reverse('book-list')
data = {
'title': 'Admin Book',
'author': 'Admin Author',
'published_date': '2021-01-01',
'isbn': '9876543210123'
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_regular_user_permissions(self):
"""
Ensure regular users have limited permissions.
"""
# Assuming regular users can only read
url = reverse('book-list')
data = {
'title': 'Unauthorized Book',
'author': 'Unauthorized Author',
'published_date': '2021-01-01',
'isbn': '9876543210123'
}
response = self.client.post(url, data, format='json')
# Status code depends on your permission settings
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
Testing Serializers and Validation
Testing serializer validation logic:
from django.test import TestCase
from .serializers import BookSerializer
class BookSerializerTest(TestCase):
def test_valid_serializer(self):
data = {
'title': 'Test Book',
'author': 'Test Author',
'published_date': '2020-01-01',
'isbn': '1234567890123'
}
serializer = BookSerializer(data=data)
self.assertTrue(serializer.is_valid())
def test_invalid_serializer(self):
# Missing required field 'title'
data = {
'author': 'Test Author',
'published_date': '2020-01-01',
'isbn': '1234567890123'
}
serializer = BookSerializer(data=data)
self.assertFalse(serializer.is_valid())
self.assertIn('title', serializer.errors)
Creating Test Fixtures
For complex tests, you might want to use fixtures to load test data:
- Create a fixture file
books.json
in afixtures
directory inside your app:
[
{
"model": "myapp.book",
"pk": 1,
"fields": {
"title": "Django for Beginners",
"author": "William S. Vincent",
"published_date": "2020-01-01",
"isbn": "1234567890123"
}
},
{
"model": "myapp.book",
"pk": 2,
"fields": {
"title": "Django for APIs",
"author": "William S. Vincent",
"published_date": "2020-02-01",
"isbn": "3210987654321"
}
}
]
- Load the fixtures in your test:
class BookFixtureTests(APITestCase):
fixtures = ['books.json']
def test_book_count(self):
self.assertEqual(Book.objects.count(), 2)
def test_get_books_from_fixture(self):
url = reverse('book-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
Real-World Application: Testing an E-commerce API
Let's create a more complex test case for an e-commerce API:
class OrderAPITests(APITestCase):
def setUp(self):
# Create a user
self.user = User.objects.create_user(
username='customer',
email='[email protected]',
password='customerpass123'
)
# Authenticate the user
self.client.force_authenticate(user=self.user)
# Create a product
self.product = Product.objects.create(
name='Test Product',
description='Test description',
price=19.99,
stock=100
)
def test_create_order(self):
"""
Ensure a user can place an order.
"""
url = reverse('order-list')
data = {
'items': [
{'product_id': self.product.id, 'quantity': 2}
],
'shipping_address': {
'street': '123 Main St',
'city': 'Test City',
'state': 'TS',
'zip_code': '12345'
}
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Verify order details
order_id = response.data['id']
order_url = reverse('order-detail', args=[order_id])
order_response = self.client.get(order_url)
self.assertEqual(order_response.status_code, status.HTTP_200_OK)
self.assertEqual(len(order_response.data['items']), 1)
self.assertEqual(order_response.data['items'][0]['product_name'], 'Test Product')
self.assertEqual(order_response.data['items'][0]['quantity'], 2)
self.assertEqual(order_response.data['total'], 39.98)
# Check if stock was updated
product_url = reverse('product-detail', args=[self.product.id])
product_response = self.client.get(product_url)
self.assertEqual(product_response.data['stock'], 98)
Best Practices for API Testing
-
Test isolation: Each test should be independent and not rely on the state from other tests.
-
Use descriptive test names: Your test name should describe what the test is checking.
-
Test both positive and negative cases: Don't just test that things work, but also test how your API handles errors.
-
Use setUp and tearDown methods: To reuse code for test setup and cleanup.
-
Test one thing per test: Each test should focus on testing a single behavior.
-
Use appropriate assertions: Django REST Framework provides specific assertions for API testing.
-
Test performance: For critical endpoints, consider adding tests that verify response times.
-
Use test coverage tools: Use tools like
coverage.py
to ensure your tests are comprehensive:
# Install coverage
pip install coverage
# Run tests with coverage
coverage run --source='.' manage.py test myapp
# Generate report
coverage report
Running Your Tests
To run your tests, use Django's test command:
python manage.py test
To run a specific test case or method:
python manage.py test myapp.tests.BookAPITests
python manage.py test myapp.tests.BookAPITests.test_create_book
Summary
We've covered the essentials of testing Django REST Framework APIs including:
- Setting up test cases with
APITestCase
- Testing CRUD operations
- Testing authentication and permissions
- Validating serializers
- Using fixtures for test data
- Best practices for API testing
Testing your API ensures that your endpoints work as expected, validates your business logic, and provides confidence when making changes or adding new features. While it may seem like extra work up front, well-written tests save time in the long run and contribute to the overall quality of your API.
Additional Resources
- Django REST Framework Testing Documentation
- Django Testing Documentation
- Python unittest Documentation
Exercises
- Create a test case for an API endpoint that filters books by author.
- Write tests for API pagination to ensure it returns the correct number of items per page.
- Implement and test a custom permission class that only allows users to edit books they've created.
- Write tests for a search API that returns books based on title or author keywords.
- Create a test suite for an API that needs to handle file uploads for book covers.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)