💡 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 test
→ AssertionError: 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:
- 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.
- 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.
As usual, here is the link to the repository: https://github.com/ISilviu/authentication-django-tests
Leave a Reply