Create proper REST API with Django, DRF, JWT and OpenApi - testing
Tags: django, python, devops, testing
Published: 2020-09-26 15:42:18

This is a continuation of [previous post](https://dev.to/misiekofski/create-proper-rest-api-with-django-drf-jwt-and-openapi-chp)

# *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](https://docs.pytest.org/en/stable/). If not, please read first [this article](https://djangostars.com/blog/django-pytest-testing/)

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


```python
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`

```python
@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:

```python
@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...

```python
@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:

```python
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:

```python
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](https://dev-to-uploads.s3.amazonaws.com/i/s45j1jl3glhdax82w59b.png)

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

```yaml
# 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
```python
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](https://dev-to-uploads.s3.amazonaws.com/i/3rux7lmn6zvs691akiay.png)

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](https://dev-to-uploads.s3.amazonaws.com/i/26rx0zbg3dera1j52k9s.png)

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](https://dev-to-uploads.s3.amazonaws.com/i/zutiv6lqinykx05mjgiv.png)

## Coming next:
1. Deployment to Heroku
2. Dockerization