Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 48

Testing DRF API Endpoints

Recommended resources

Hello! Welcome to our third lesson in the "Testing Backend Applications" module.

In the previous lesson, we established the foundation of our testing pyramid by writing unit tests for Django models and business logic functions. We learned how to test these components in isolation to verify their individual correctness.

Today, we're moving up the pyramid to integration testing. Instead of testing components in isolation, we will now test how they work together. Specifically, we'll focus on testing our API endpoints. This involves simulating a real client making a request and verifying that the entire stack—from URL routing and views to serializers and the database—behaves as expected.

By the end of this lesson, you will be able to write comprehensive integration tests for your API endpoints using the powerful testing tools provided by Django REST Framework.

1. From TestCase to APITestCase

For our unit tests, we used django.test.TestCase, which provides a great foundation with features like an isolated test database. When testing DRF APIs, we can use an even more specialized tool: rest_framework.test.APITestCase.

APITestCase inherits from Django's TestCase, so it has all the features you're already familiar with. However, it adds a crucial enhancement: the self.client attribute is an instance of APIClient, not Django's standard Client.

The APIClient is purpose-built for testing web APIs. Its key advantages include:

  • It simplifies sending data in formats like JSON.
  • It provides convenient methods for authenticating requests, which is essential for testing protected endpoints.
  • The response object it returns has a .data attribute, giving you direct access to the parsed JSON (or other content type) without needing to manually decode response.content.

Testing

The official DRF documentation provides a clear, authoritative overview of its testing tools. Let's start there to understand the key classes we'll be using.

Please read the sections 'API Test cases' and 'Checking the response data'. Focus on what APITestCase provides (specifically self.client being an APIClient) and the convenience of using response.data for assertions.


2. Writing Integration Tests for CRUD Operations

Let's put this into practice by writing tests for a typical set of CRUD (Create, Read, Update, Delete) endpoints for our Post model from the previous lesson. We'll organize these tests in a new file, blog/tests/test_views.py.

The following video is an excellent, practical guide that walks through testing a DRF API from start to finish. We will refer to it throughout this section as we build our tests.

Testing APIs with Django REST Framework

The 'Testing APIs with Django REST Framework' video from BugBytes provides a complete walkthrough of the concepts we're about to cover. It demonstrates setting up the test class, making requests, and handling permissions.

First, watch the setup portion of the video from 02:07 to 06:40. Pay close attention to how the APITestCase is set up with a setUp method to create test data (users and a product) and how django.urls.reverse is used to generate the endpoint URL. This builds directly on what we learned in the last lesson.

A. Testing GET (Read) Requests

Let's start by testing the endpoint that lists all posts.

Steps:

  1. Set up data: In a setUp method, create a couple of Post objects.
  2. Make request: Use self.client.get() to hit the list endpoint.
  3. Assert response:
    • Check for a 200 OK status code.
    • Verify the number of posts in the response.
    • Check the data of a specific post in the response.
# blog/tests/test_views.py
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from blog.models import Post

class PostAPITest(APITestCase):

    def setUp(self):
        """Set up initial data for tests."""
        self.user = User.objects.create_user(username='testuser', password='password123')
        self.post1 = Post.objects.create(author=self.user, title='First Post', content='Content 1')
        self.post2 = Post.objects.create(author=self.user, title='Second Post', content='Content 2')
        
        self.list_url = reverse('post-list') # Assuming you named your URL pattern 'post-list'

    def test_get_post_list(self):
        """Ensure we can retrieve the list of posts."""
        response = self.client.get(self.list_url)
        
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data), 2)
        self.assertEqual(response.data[0]['title'], self.post1.title)

This test confirms that an unauthenticated user can view the list of posts, that the correct number of posts is returned, and that the data is serialized correctly.

Now, let's see how this is done in the video.

Testing APIs with Django REST Framework

Let's continue with the BugBytes video to see how to test a GET request for a single item.

Watch from 06:40 to 09:25. Notice the use of self.assert_equal for both the status code and the response data (response.data). The video also introduces using the status module from rest_framework for readable status codes, which is a great practice.

B. Testing POST (Create) Requests and Authentication

Most POST endpoints are protected and require authentication. This gives us a chance to test two things at once: resource creation and our permission rules.

A robust test suite for a protected creation endpoint should cover:

  • An unauthenticated user trying to create a post (should fail).
  • An authenticated user trying to create a post (should succeed).

The official DRF documentation outlines several ways to handle authentication in tests.

Testing

Before we write the test, let's review the authentication options available for APIClient. This will give you a deeper understanding of the tools at your disposal.

Read the 'Authenticating' subsection under the 'APIClient' heading. Focus on the three main methods: .login(), .credentials(), and .force_authenticate(). Understand when you might use each one.

As the documentation explains:

  • client.login(): Best for APIs using session authentication.
  • client.credentials(): Ideal for token-based authentication (like Bearer tokens for JWT), where you set the Authorization header.
  • client.force_authenticate(): A shortcut to bypass the authentication backend entirely. Useful when you trust your auth system and just want to test the view's logic for an already-authenticated user.

Let's apply this. First, we'll test that an unauthenticated user gets a 401 Unauthorized (or 403 Forbidden, depending on your configuration).

# Add this method to the PostAPITest class
def test_unauthorized_post_creation(self):
    """Ensure unauthenticated users cannot create posts."""
    data = {'title': 'A new post', 'content': 'Some content'}
    response = self.client.post(self.list_url, data, format='json')
    self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

Next, let's test the success case for an authenticated user. We'll use self.client.force_authenticate() for simplicity.

# Add this method to the PostAPITest class
def test_authorized_post_creation(self):
    """Ensure authenticated users can create posts."""
    self.client.force_authenticate(user=self.user)
    
    data = {'title': 'A shiny new post', 'content': 'Exciting content here.'}
    response = self.client.post(self.list_url, data, format='json')
    
    # Check the response
    self.assertEqual(response.status_code, status.HTTP_201_CREATED)
    self.assertEqual(response.data['title'], 'A shiny new post')
    
    # Check the database
    self.assertEqual(Post.objects.count(), 3)
    self.assertTrue(Post.objects.filter(title='A shiny new post').exists())

Note the format='json' argument in the .post() call. This tells DRF's test client to serialize our Python dictionary into a JSON request body and set the Content-Type header correctly.

Test your understanding!

In the test_authorized_post_creation example above, we authenticated the user with force_authenticate. How would you rewrite the authentication part of that test to use client.login() instead? Assume your API supports session authentication.

Show answer

You would replace self.client.force_authenticate(user=self.user) with self.client.login(username='testuser', password='password123'). The rest of the test would remain the same.

def test_authorized_post_creation_with_login(self):
    """Ensure authenticated users can create posts using login."""
    # Log the client in
    self.client.login(username='testuser', password='password123')
    
    data = {'title': 'A shiny new post', 'content': 'Exciting content here.'}
    response = self.client.post(self.list_url, data, format='json')
    
    self.assertEqual(response.status_code, status.HTTP_201_CREATED)
    self.assertEqual(Post.objects.count(), 3)

C. Testing PUT (Update) and DELETE Requests

Testing PUT/PATCH and DELETE requests follows the same pattern, but with an added layer: object-level permissions. It's not enough to be authenticated; you often need to be the owner of the object to modify or delete it.

The BugBytes video has an excellent segment on testing exactly this scenario: an unauthenticated user, a non-admin user, and an admin user all trying to perform an action.

Testing APIs with Django REST Framework

Let's watch how to test different permission levels for a DELETE request. This demonstrates how to structure tests for complex authorization rules.

Watch from 09:25 to 17:07. This is a dense but very valuable section. Pay attention to: Testing for a 401 Unauthorized status for an unauthenticated user. Using client.login() to authenticate a regular user and asserting a 403 Forbidden status. Logging in as an admin user and asserting a successful 204 No Content status. Asserting the state of the database after the request (e.g., using .exists() to check if a product was deleted).

Inspired by the video, let's write a test for updating a Post. We'll test that only the author can update their own post.

# Add this to your PostAPITest class
def setUp(self):
    # ... existing setUp code ...
    self.other_user = User.objects.create_user(username='otheruser', password='password123')
    self.detail_url = reverse('post-detail', kwargs={'pk': self.post1.pk}) # Assumes URL name 'post-detail'

def test_update_own_post(self):
    """Ensure a user can update their own post."""
    self.client.force_authenticate(user=self.user)
    data = {'title': 'Updated Title', 'content': self.post1.content}
    response = self.client.put(self.detail_url, data, format='json')

    self.assertEqual(response.status_code, status.HTTP_200_OK)
    
    # Refresh the object from the database to see changes
    self.post1.refresh_from_db()
    self.assertEqual(self.post1.title, 'Updated Title')

def test_prevent_update_other_users_post(self):
    """Ensure a user cannot update another user's post."""
    # Authenticate as the 'other_user'
    self.client.force_authenticate(user=self.other_user)
    data = {'title': 'Malicious Update'}
    response = self.client.put(self.detail_url, data, format='json')

    # The post belongs to 'self.user', so this should be forbidden
    self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

The refresh_from_db() method is essential here. After a successful PUT request, the local self.post1 object in our test doesn't automatically reflect the changes made in the database. refresh_from_db() updates the object, allowing us to assert that the change was persisted correctly.


Conclusion

Today you've made the leap from unit testing isolated components to writing integration tests that verify your entire API request-response cycle. This is a critical skill for building reliable and robust backend services.

Key Takeaways:

  • Integration tests for APIs validate the interaction between views, serializers, the ORM, and the database.
  • Use rest_framework.test.APITestCase which provides an APIClient instance via self.client.
  • The APIClient simplifies making requests (get, post, etc.) and asserting responses using response.status_code and response.data.
  • Test authentication and authorization by simulating requests from unauthenticated users, authenticated users, and users with different permission levels.
  • Use client.login(), client.credentials(), or client.force_authenticate() to handle authentication in your tests.
  • Always assert the final state of the database to confirm that your API endpoint had the intended effect (e.g., an object was created, updated, or deleted).

Preview of the Next Lesson:
In our tests today, we manually created model instances in the setUp method. While this works for simple cases, it can become repetitive and difficult to manage as your models grow more complex. In the next lesson, we will learn how to use fixtures and factory libraries to generate consistent, reusable, and complex test data with ease.