Easily handle authentication in Django tests

💡 The following article will present two ways to get around authentication in Django tests. The same principles could also apply to other languages/frameworks as well.

Table of contents

Introduction

Let’s say you have added an authentication mechanism to your endpoints, and all the tests for your back end are failing since they don’t provide any login credentials.

Goals

By the end of this article, one should be able to successfully handle authentication for any Django project in the tests environment.

Creating the DRF project

For this matter, we’ll use the DRF’s quickstart documentation:

mkdir tutorial
cd tutorial

python3 -m venv env
source env/bin/activate

pip install django
pip install djangorestframework


django-admin startproject tutorial .
cd tutorial
django-admin startapp quickstart
cd ..

python manage.py migrate

First, we have to add rest_framework to the INSTALLED_APPS setting of Django:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'rest_framework',
]

Then, we’re going to bootstrap two endpoints, one for users and one for user groups. Thus, we need to add serializers for our models:

from django.contrib.auth.models import User, Group
from rest_framework import serializers

class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ['url', 'username', 'email', 'groups']

class GroupSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Group
        fields = ['url', 'name']

… and their corresponding viewsets:

from django.contrib.auth.models import User, Group
from rest_framework import viewsets
from rest_framework import permissions
from tutorial.quickstart.serializers import UserSerializer, GroupSerializer

class UserViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows users to be viewed or edited.
    """
    queryset = User.objects.order_by('-date_joined')
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAuthenticated]

class GroupViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows groups to be viewed or edited.
    """
    queryset = Group.objects.all()
    serializer_class = GroupSerializer
    permission_classes = [permissions.IsAuthenticated]

In the end, tie up the viewsets to some actual routes:

from django.urls import include, path
from rest_framework import routers
from tutorial.quickstart import views

router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'groups', views.GroupViewSet)

# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
    path('', include(router.urls)),
    path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
]

Any request to any of the above endpoints will be forbidden, unless we specify the authentication credentials: curl http://localhost:8000/users{"detail":"Authentication credentials were not provided."}

We’re going to skip this part since it’s beyond the scope of the article. The quickstart docs have a example on how to do it.

Adding our first test

# tests.py, at the same level as serializers.py
from rest_framework.test import APITestCase
from rest_framework import status

class UserApiTests(APITestCase):
    base_url = '/users/'

    def test_get_users(self):
        status_code = self.client.get(self.base_url).status_code
        self.assertEqual(status_code, status.HTTP_200_OK)

A request is sent to the users endpoint, and a 200 HTTP code is expected. Running the app’s tests reveals that the test is failing, as expected:

$ python manage.py testAssertionError: 403 != 200

Possible solutions

1. Forcefully authenticate a user before any request is sent

from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth.models import User

class UserApiTests(APITestCase):
    base_url = '/users/'

    def test_get_users(self):
        user = User.objects.create_user('user', 'pass')
        self.client.force_authenticate(user)
        status_code = self.client.get(self.base_url).status_code
        self.assertEqual(status_code, status.HTTP_200_OK)

If we had more than one test, we could override the setUp method of the tests class:

class UserApiTests(APITestCase):
    base_url = '/users/'

    def setUp(self):
        user = User.objects.create_user('user', 'pass')
        self.client.force_authenticate(user)

    def test_get_users(self):
        status_code = self.client.get(self.base_url).status_code
        self.assertEqual(status_code, status.HTTP_200_OK)

    def test_users_count(self):
        users_list = self.client.get(self.base_url).data
        self.assertEqual(len(users_list), 1)

Both tests are passing:

..
----------------------------------------------------------------------
Ran 2 tests in 0.007s

OK

However, the downsides with this approach are:

  1. A user is created and authenticated before each test is run. At a large scale of tests, the extra operations could be a considerable overhead.
  2. It forces us to change our tests in order to pass. For example, in test_users_count, it’s not that obvious that a user already exists in the database.

A better option would be to disable authentication for tests.

2. Disable authentication altogether in tests

💡 If one needs to test the authentication mechanism, it can be enabled for some specific tests. More specifically, one can override the permission_classes of a viewset by using patch.object.

First, we need to be able to enable/disable the authentication through the settings file. Head to settings.py and add a new property: ENABLE_AUTHENTICATION = True. It can be placed on any line in the file.

Then, we need to reference it in the viewsets (views.py):

from django.contrib.auth.models import User, Group
from rest_framework import viewsets
from rest_framework import permissions
from tutorial.quickstart.serializers import UserSerializer, GroupSerializer
from django.conf import settings

class UserViewSet(viewsets.ModelViewSet):
    """
    API endpoint that allows users to be viewed or edited.
    """
    queryset = User.objects.all().order_by('-date_joined')
    serializer_class = UserSerializer
    permission_classes = [
        permissions.IsAuthenticated if settings.ENABLE_AUTHENTICATION else permissions.AllowAny]

Basically, add the IsAuthenticated permission class only if ENABLE_AUTHENTICATION is set to true, otherwise allow any incoming request. The same statement would be added to GroupViewSet as well.

This logic can be extracted in a mixin and be reused through the viewsets:

from django.contrib.auth.models import User, Group
from rest_framework import viewsets
from rest_framework import permissions
from tutorial.quickstart.serializers import UserSerializer, GroupSerializer
from django.conf import settings

class AuthenticationMixin:
    permission_classes = [
        permissions.IsAuthenticated if settings.ENABLE_AUTHENTICATION else permissions.AllowAny]

class UserViewSet(AuthenticationMixin, viewsets.ModelViewSet):
    """
    API endpoint that allows users to be viewed or edited.
    """
    queryset = User.objects.order_by('-date_joined')
    serializer_class = UserSerializer

class GroupViewSet(AuthenticationMixin, viewsets.ModelViewSet):
    """
    API endpoint that allows groups to be viewed or edited.
    """
    queryset = Group.objects.all()
    serializer_class = GroupSerializer

If we set ENABLE_AUTHENTICATION to False and send the earlier request using curl, curl http://localhost:8000/users/, the response would be [], meaning the request was fulfilled successfully, yet the database doesn’t have any user stored.

The problem is that, in this case, we have disabled authentication for the whole application. In order to disable it only for tests, we’re going to use different environment files, one for the application and one for tests.

First things first, add the .env file. Locate it at the same level as the settings.py file:

# .env
 ENABLE_AUTHENTICATION=True

Install django-environ to be able to read from .env files: pip install django-environ

Configure django-environ:

# settings.py
from pathlib import Path
import os
import environ

# Build paths inside the project like this: BASE_DIR / 'subdir'.
CURRENT_DIR = Path(__file__).resolve().parent
BASE_DIR = CURRENT_DIR.parent

env = environ.Env()
environ.Env.read_env(os.path.join(CURRENT_DIR, '.env'))

Read the ENABLE_AUTHENTICATION setting from the environment file:

# settings.py
ENABLE_AUTHENTICATION = env.bool('ENABLE_AUTHENTICATION', False)

If a value could not be found, it will default to False.

❗ Any change to the .env requires a Django server restart. It only reads from the environment file on “startup”.

Create a separate environment file for tests, .test.env, located at the same level as .env:

# .test.env
ENABLE_AUTHENTICATION=False

In the end, we need to tell Django which environment file to choose, based on the action we’re doing. Set an environment variable with the environment file name, which will be accessed in settings.py:

# manage.py
def main():
    """Run administrative tasks."""
    os.environ.setdefault(
        'ENV_FILE', '.test.env' if 'test' in sys.argv else '.env')
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings')
# settings.py 
... 
ENV_FILE = os.environ.get('ENV_FILE', '.env')
environ.Env.read_env(os.path.join(CURRENT_DIR, ENV_FILE))

That’s about it! We can now remove the setUp method from the tests, which authenticated a dummy user:

# tests.py
from rest_framework.test import APITestCase
from rest_framework import status

class UserApiTests(APITestCase):
    base_url = '/users/'
    
    def test_get_users(self):
        status_code = self.client.get(self.base_url).status_code
        self.assertEqual(status_code, status.HTTP_200_OK)

    def test_users_count(self):
        users_list = self.client.get(self.base_url).data
        self.assertEqual(len(users_list), 0)

Run python manage.py test to make sure the tests are passing. At the same time, the application’s endpoints are still secured.

References

  1. https://django-environ.readthedocs.io/en/latest/quickstart.html
  2. https://www.django-rest-framework.org/tutorial/quickstart/
  3. https://www.django-rest-framework.org/api-guide/permissions/#allowany
  4. https://www.django-rest-framework.org/api-guide/testing/

Posted

in

by

Comments

One response to “Easily handle authentication in Django tests”

  1. Iust Avatar
    Iust

    Useful article.

Leave a Reply

Your email address will not be published. Required fields are marked *