loading...

Create proper REST API with Django, DRF, JWT and OpenApi - testing

misiekofski profile image Michal Dobrzycki ・6 min read

This is a continuation of previous post

Summary of this post.

  1. Install pytest and configure it for Django project
  2. Write a test that checks Profile and User count (it should be the same)
  3. Write the test that checks if the User can edit his Profile
  4. Write the test that checks if the User cannot edit other Profile than his own.
  5. Fix bugs found by our tests
  6. Create Github Actions to validate every pull request to master branch
  7. Play with the builds in Github Actions

Step #1 - install pytest and configure it

I'll assume for this article, that you have a good knowledge of pytest. If not, please read first this article

Let's start with writing tests for our REST API. First, we need to install pytest-django (wrapper for pytest, which will help us to write more complex tests much easier), install it with the command:

pip install pytest-django

Then we need to create pytest.ini file which will be used by pytest as a configuration to run tests in our application

[pytest]
DJANGO_SETTINGS_MODULE = restapi_article.settings
python_files = tests.py test_*.py *_tests.py

Step 2 - Write a test that checks Profile and User count

Now we can start writing tests. Because in the previous article we used signals to create Profile along with User, let's write a test that will check the number of Users and Profiles in DB is the same (we want to create a new user and then see that amount of profiles is the same).

@pytest.mark.django_db decorator will allow us to use clean DB (right now we are using default SQLite) in our tests. So running pytest will use our settings.py to use configured
DB, and after the test, everything will be reverted back to the state before tests were executed. So first easy test will look like this

import pytest

from django.contrib.auth.models import User
from .models import Profile


@pytest.mark.django_db
def test_user_create_creates_profile():
    User.objects.create_user('michal', 'test@scvconsultants.com', 'michalpassword')
    assert Profile.objects.count() == 1
    assert User.objects.count() == 1

Step 3 - Write the test that checks if the User can edit his Profile

Now we should test that the user can update his (and only his) profile. So let's write a test (I'll extend the previous test to test Profile). The modified test case will check that:

  1. Creating User creates also Profile in DB
  2. User field in created Profile will link to the correct User URL
  3. Next we'll obtain the token for the created user and save it in the script to authorize the next API call.
  4. With this token we'll populate Profile with some data.
  5. And check that response from PUT API call contains the same data that was sent in the previous request.

First, we need to add a fixture to use ApiClient from rest_framework.test. Let's add this to tests.py

@pytest.fixture
def api_client():
    from rest_framework.test import APIClient
    return APIClient()

And now we can use this fixture and extend the previous test. Please notice that i've changed test name, but it still checks the number of users:

@pytest.mark.django_db
def test_user_can_update_his_profile(api_client):
    user = User.objects.create_user('michal', 'test@scvconsultants.com', "michalpassword")
    assert Profile.objects.count() == 1
    assert User.objects.count() == 1

    profile_url = reverse('profile-detail', args=[user.id])
    user_url = reverse('user-detail', args=[user.id])

    # check that profile was created for created user
    response = api_client.get(profile_url)
    assert response.status_code == 200
    assert response.data['user'].endswith(user_url)

    #create bio
    bio_data = {
        "user": response.data['user'],
        "bio": "This is test user",
        "location": "Wroclaw"
    }
    #create login data as user.password contains now encrypted string
    login_data = {
        "username": user.username,
        "password": "michalpassword"
    }

    # get token
    token_url = reverse('token_obtain_pair')
    token = api_client.post(token_url, login_data, format='json')
    # check that access token was sent in response
    assert token.data['access'] is not None
    # add http authorization header with Bearer prefix
    api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + token.data['access'])
    # update profile
    response = api_client.put(profile_url, bio_data, format='json')
    # validate response
    assert response.status_code == 200
    assert response.data['bio'] == bio_data['bio']
    assert response.data['location'] == bio_data['location']

Now we can run tests by typing pytest in our project root in a terminal. Our test should pass without any errors.

Step 4 - Write the test that checks if the User cannot edit other Profile than his own.

Until now we don't see any issues with our application. But let's try to populate other profile than our user's in the next test. We will expect to see error 403 when updating other profile with the wrong JWT Token. Changing someone else Profile shouldn't be possible, but...

@pytest.mark.django_db
def test_user_should_not_be_able_to_update_other_profile(api_client):
    first_user = User.objects.create_user('michal', 'test@scvconsultants.com', "michalpassword")
    second_user = User.objects.create_user('michal2', 'test2@scvconsultants.com', "michalpassword2")
    assert Profile.objects.count() == User.objects.count()

    #get token for first_user
    token_url = reverse('token_obtain_pair')
    login_data = {
        "username": first_user.username,
        "password": "michalpassword"
    }
    token = api_client.post(token_url, login_data, format='json')
    api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + token.data['access'])
    # now update second_user Profile with first_user token
    profile_url = reverse('profile-detail', args=[second_user.id])
    response = api_client.get(profile_url)
    bio_data = {
        "user": response.data['user'],
        "bio": "This is test user",
        "location": "Wroclaw"
    }
    response = api_client.put(profile_url, bio_data, format='json')
    assert response.status_code == 403

Let's run those tests again with pytest and now we should see that the second test fails because we are able to update other Profile than ours!

FAILED restapi/tests.py::test_user_should_not_be_able_to_update_other_profile - assert 200 == 403

Step 5 - Fix bugs found by our tests

We need to fix this bug in our code (and it's a security bug). First, let's add permissions.py file and create proper permission logic there:

from rest_framework import permissions


class IsProperUserOrReadOnly(permissions.BasePermission):
    """
    Custom permission to only allow owners of an object to edit it.
    """
    def has_object_permission(self, request, view, obj):
        # Read permissions are allowed to any request,
        # so we'll always allow GET, HEAD or OPTIONS requests.
        if request.method in permissions.SAFE_METHODS:
            return True

        # Write permissions are only allowed to the User linked with the Profile.
        return obj.user == request.user

Then we can change ProfileViewSet in our views.py to include this logic. Don't forget to import newly created permission:

from .permissions import IsProperUserOrReadOnly
# some code here
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsProperUserOrReadOnly]

Now we can run again tests with pytest and we should have 2 passing tests! It's that easy :)

Step 6 - Create Github Actions to validate every pull request to master branch

Once the tests are passing, it's a good time to set up GitHub and GitHub actions. Let's block our repository from pushing anything straight to the master branch.

We can go to settings -> branches and add branch policy for starters. Type "master" into branch name pattern and select "Require pull request reviews before merging".

Then we go to Actions in our Github repo, and add Python application which should be suggested on the first page.

Github Actions - Python Application

It will add yaml file python-app.yml in directory .github/workflows/. Our file should look like this:

# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: Python application

on:
  pull_request:
    branches: [ master ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python 3.8
      uses: actions/setup-python@v2
      with:
        python-version: 3.8
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install flake8 pytest
        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
    - name: Lint with flake8
      run: |
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Test with pytest
      run: |
        pytest

Step 8 - Play with the builds in Github Actions

Let's break our tests on a locally created branch, so do the following:

git pull
git checkout -b feature/broken-test

And change last line in tests.py from 403 to 200

assert response.status_code == 200

Now we can add, commit, and push the code and create a pull request to trigger Github Action. After pushing this code to branch, create Pull Request from the Github repository page.

And you should see in your GitHub actions Error on step "Test with pytest"

Github Actions - Tests fail

And the log should show something like this:

>       assert response.status_code == 200
E       assert 403 == 200
E        +  where 403 = <Response status_code=403, "application/json">.status_code

restapi/tests.py:79: AssertionError
------------------------------ Captured log call -------------------------------
WARNING  django.request:log.py:224 Forbidden: /api/v1/profile/2/

Those error will populate to Pull Request view and show you "All checks failed". This is a pretty good warning for you not to merge changes to master branch.

Build error - Github Actions

Let's fix the last line in tests.py to expect status 403 again and push the code again. And we should see now that the tests are passing in Actions and now we can safely merge this to master (pull request will also update it's view).

Build pass - Github Actions

Coming next:

  1. Deployment to Heroku
  2. Dockerization

Posted on by:

misiekofski profile

Michal Dobrzycki

@misiekofski

Automated Testing, DevOps, Django and Python

Discussion

pic
Editor guide