Michal Dobrzycki
Posted on September 26, 2020
This is a continuation of previous post
Summary of this post.
- Install pytest and configure it for Django project
- Write a test that checks Profile and User count (it should be the same)
- Write the test that checks if the User can edit his Profile
- Write the test that checks if the User cannot edit other Profile than his own.
- Fix bugs found by our tests
- Create Github Actions to validate every pull request to
master
branch - 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:
- Creating User creates also Profile in DB
- User field in created Profile will link to the correct User URL
- Next we'll obtain the token for the created user and save it in the script to authorize the next API call.
- With this token we'll populate Profile with some data.
- 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.
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"
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.
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).
Coming next:
- Deployment to Heroku
- Dockerization
Posted on September 26, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.