Django JWT Testing
Since we implemented JWT authentication in our previous post all our mutation tests will fail. Why? Because our server/todo_app/schema.py is now testing to confirm that an authenticated user is logged in. After all, we don’t want anybody to be able to mess with our data. Only authenticated users should have permission to create, edit or update todo app posts which we have accomplished using the create a category mutation as an example below:
class CreateCategory(graphene.Mutation): id = graphene.Int() name = graphene.String() slug = graphene.String() parent = graphene.Field(CategoryType) posted_by = graphene.Field(UserType) class Arguments: name = graphene.String() parentSlug = graphene.String() def mutate(self, info, name, parentSlug=None): user = info.context.user if user.is_anonymous: raise GraphQLError('You must be logged in to create new todos!') if parentSlug: parent = Category.objects.get(slug=parentSlug) category = Category.objects.create(name=name, parent=parent, posted_by=user) else: category = Category.objects.create(name=name, posted_by=user) return CreateCategory( id=category.id, name=category.name, slug=category.slug, parent=category.parent, posted_by=category.posted_by, )
Now we need to update our mutation tests so the user’s web token is included with our GraphQL request. We can obtain our web token by using get_token from graphql_jwt.shortcuts and GraphQLTestCase from graphene_django.utils.testing.
Let’s dive into the nuts and bolts by updating our create category test: server/todo_app/tests/test_graphql_mutations.py:
# server/todo_app/tests/test_graphql_mutations.py
def test_create_category_no_parent(self):
response = self.query(
'''
mutation createCategory($name: String!) {
createCategory(name: $name) {
id
name
slug
}
}
''', #1
variables = {
'name': 'my category',
}, #2
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
Notice the additions from our first version.
- Variables
- Headers
In chapter 4, we had yet to implement user authentication with JWT. Now we have JWT authentication in place so, if we were to run pytest now the mutation tests would fail because JWT requires the user’s token to be passed in the headers of the request.
In the above test I’ve added variables to the mix for the sake of completeness. For the above test to pass we still need to add a setUp method to our MutationTestCases class:
# server/todo_app/tests/test_graphql_mutations.py
# code ...
class MutationTestCases(GraphQLTestCase):
def setUp(self):
self.user = get_user_model().objects.create(username='ron')
self.token = get_token(self.user)
self.headers = {"HTTP_AUTHORIZATION": f"JWT {self.token}"}
# code ...
def test_create_category_no_parent(self):
response = self.query(
'''
mutation createCategory($name: String!) {
createCategory(name: $name) {
id
name
slug
}
}
''', #1
variables = {
'name': 'my category',
}, #2
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
Updating Mutation Tests to Pass JWT Authentication
If we were to run our pytest now, this create category test would pass while the remaining mutation tests would fail. To refactor all our tests in order to pass JWT authentication we need to add the variables and headers to the remaining tests which I have done with the updated tests below:
# server/todo_app/tests/test_graphql_mutations.py
from graphql_jwt.shortcuts import get_token
from django.contrib.auth import get_user_model
from graphene_django.utils.testing import GraphQLTestCase
from todo_app.models import Todo, Project, Category
import json
class MutationTestCases(GraphQLTestCase):
def setUp(self):
self.user = get_user_model().objects.create(username='ron')
self.token = get_token(self.user)
self.headers = {"HTTP_AUTHORIZATION": f"JWT {self.token}"}
def test_create_todo(self):
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
response = self.query(
'''
mutation createTodo($title: String!, $task: String!, $projId: Int!, $catId: Int!) {
createTodo(title: $title, task: $task, projId: $projId, catId: $catId) {
title
task
slug
project {
name
slug
}
category {
name
slug
}
}
}
''',
variables = {
'title': 'todo',
'task': 'task',
'projId': proj1.id,
'catId': cat1.id,
},
headers=self.headers,
)
expected = {
'data': {
'createTodo': {
'title': 'todo',
'task': 'task',
'slug': 'todo',
'project': {
'name': 'proj1',
'slug': 'proj1',
},
'category': {
'name': 'cat1',
'slug': 'cat1',
}
}
}
}
content = json.loads(response.content)
assert content == expected
self.assertResponseNoErrors(response)
def test_update_todo_name(self):
# Test for updating a todos name
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
response = self.query(
'''
mutation updateTodo($id: Int!, $title: String!) {
updateTodo(id: $id, title: $title){
id
slug
title
task
postedBy {
id
username
email
}
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'title': 'todo updated',
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_todo_task(self):
# Test for updating a todos task
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
response = self.query(
'''
mutation updateTodo($id: Int!, $task: String!) {
updateTodo(id: $id, task: $task){
id
slug
title
task
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'task': 'todo updated task',
},
headers=self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_todo_project(self):
# Test for updating a todos project
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
proj2 = Project(name='proj2')
proj2.save()
response = self.query(
'''
mutation updateTodo($id: Int!, $projId: Int!) {
updateTodo(id: $id, projId: $projId){
id
slug
title
task
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'projId': proj2.id,
},
headers=self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_todo_category(self):
# Test for updating a todos category
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
cat2 = Category(name='cat2')
cat2.save()
response = self.query(
'''
mutation updateTodo($id: Int!, $catId: Int!) {
updateTodo(id: $id, catId: $catId){
id
slug
title
task
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'catId': cat2.id,
},
headers=self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_todo_is_completed(self):
# Test for updating a todos is_completed status
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
response = self.query(
'''
mutation updateTodo($id: Int!, $isCompleted: Boolean!) {
updateTodo(id: $id, isCompleted: $isCompleted){
id
slug
title
task
isCompleted
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'isCompleted': True,
},
headers=self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_todo_everything(self):
# Test for updating all of a todo's attributes at once
proj1 = Project(name='proj1')
proj1.save()
cat1 = Category(name='cat1')
cat1.save()
proj2 = Project(name='proj2')
proj2.save()
cat2 = Category(name='cat2')
cat2.save()
todo = Todo(title='todo', task='todo task', project=proj1, category=cat1, posted_by=self.user)
todo.save()
response = self.query(
'''
mutation updateTodo(
$id: Int!,
$title: String!,
$task: String!,
$projId: Int!,
$catId: Int!,
$isCompleted: Boolean!
) {
updateTodo(
id: $id,
title: $title,
task: $task,
projId: $projId,
catId: $catId,
isCompleted: $isCompleted,
){
id
slug
title
task
isCompleted
project {
slug
name
}
category {
slug
name
parent {
slug
name
}
}
}
}
''',
variables = {
'id': todo.id,
'title': 'updated title',
'task': 'updated task',
'projId': proj2.id,
'catId': cat2.id,
'isCompleted': True,
},
headers=self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_create_project(self):
response = self.query(
'''
mutation createProject($name: String!, $description: String!) {
createProject(name: $name, description: $description) {
id
name
description
slug
}
}
''',
variables = {
'name': 'My Project',
'description': 'Description for my project',
},
headers = self.headers
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_project_name(self):
project = Project.objects.create(name='foo', posted_by=self.user)
# Test updating name of a project
response = self.query(
'''
mutation updateProject($id: Int!, $name: String!) {
updateProject(id:$id, name: $name) {
id
name
description
slug
}
}
''',
variables = {
'id': project.id,
'name': 'My Project',
},
headers = self.headers
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_project_description(self):
# Test updating of project description
project = Project.objects.create(name='foo', description='bar', posted_by=self.user)
response = self.query(
'''
mutation updateProject($id: Int!, $description: String!) {
updateProject(id:$id, description: $description) {
id
name
description
slug
}
}
''',
variables = {
'id': project.id,
'description': 'Description for my project',
},
headers = self.headers
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_project_name_description(self):
project = Project.objects.create(name='foo', posted_by=self.user)
# Test updating both name and description of a project
response = self.query(
'''
mutation updateProject($id: Int!, $name: String!, $description: String!) {
updateProject(id:$id, name: $name, description: $description) {
id
name
description
slug
}
}
''',
variables = {
'id': project.id,
'name': 'My Project',
'description': 'Description for my project',
},
headers = self.headers
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_delete_project(self):
project = Project.objects.create(name='foo', posted_by=self.user)
response = self.query(
'''
mutation deleteProject($id: Int!) {
deleteProject(id: $id) {
id
name
}
}
''',
variables = {
'id': project.id,
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_create_category_no_parent(self):
response = self.query(
'''
mutation createCategory($name: String!) {
createCategory(name: $name) {
id
name
slug
}
}
''',
variables = {
'name': 'my category',
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_create_category_with_parent(self):
parent = Category.objects.create(name='Category Parent', posted_by=self.user)
response = self.query(
'''
mutation createCategory($name: String!, $parentId: Int!) {
createCategory(name: $name, parentId: $parentId) {
id
name
slug
}
}
''',
variables = {
'name': 'my category',
'parentId': parent.id
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_category_name(self):
category = Category.objects.create(name='foo', posted_by=self.user)
# Test for changing a category's name
response = self.query(
'''
mutation updateCategory($id: Int!,$name: String!){
updateCategory(id: $id, name: $name) {
id
name
slug
}
}
''',
variables = {
'id': category.id,
'name': 'New Category Name',
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_category_parent(self):
categoryParent = Category.objects.create(name='Parent Category', posted_by=self.user)
category = Category.objects.create(name='foo', posted_by=self.user)
# Test for moving category into a parent category.
response = self.query(
'''
mutation updateCategory($id: Int!,$parentId: Int!){
updateCategory(id: $id, parentId: $parentId) {
id
name
slug
parent {
id
name
slug
}
}
}
''',
variables = {
'id': category.id,
'parentId': categoryParent.id,
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_update_category_name_parent(self):
categoryParent = Category.objects.create(name='category parent', posted_by=self.user)
category = Category.objects.create(name='foobar', posted_by=self.user)
# Test for changing a category's name & moving renamed category into a parent category
response = self.query(
'''
mutation updateCategory($id: Int!, $name: String!, $parentId: Int!){
updateCategory(id: $id, name: $name, parentId: $parentId) {
id
name
slug
parent {
id
name
slug
}
}
}
''',
variables = {
'id': category.id,
'name': 'New Category Name',
'parentId': categoryParent.id
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
def test_delete_category(self):
category = Category.objects.create(name='foo', posted_by=self.user)
response = self.query(
'''
mutation deleteCategory($id: Int!) {
deleteCategory(id: $id) {
id
name
}
}
''',
variables = {
'id': category.id,
},
headers = self.headers,
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
We are ready to run the updated mutation tests:
docker-compose run server pytest
Creating todo_mptt_4_server_run ... done
========================================== test session starts ===========================================
platform linux -- Python 3.8.5, pytest-6.1.1, py-1.9.0, pluggy-0.13.1
django: settings: todo_proj.settings (from env)
rootdir: /var/www/server
plugins: django-4.1.0
collected 29 items
todo_app/tests/test_graphql_mutations.py .................. [ 62%]
todo_app/tests/test_graphql_queries.py ...... [ 82%]
todo_app/tests/test_models.py ..... [100%]
============================================ warnings summary ============================================
../../../usr/local/lib/python3.8/site-packages/graphene/types/field.py:2
/usr/local/lib/python3.8/site-packages/graphene/types/field.py:2: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.9 it will stop working
from collections import Mapping, OrderedDict
../../../usr/local/lib/python3.8/site-packages/graphene/relay/connection.py:2
/usr/local/lib/python3.8/site-packages/graphene/relay/connection.py:2: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.9 it will stop working
from collections import Iterable, OrderedDict
../../../usr/local/lib/python3.8/site-packages/mptt/signals.py:8
/usr/local/lib/python3.8/site-packages/mptt/signals.py:8: RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring.
node_moved = ModelSignal(providing_args=[
../../../usr/local/lib/python3.8/site-packages/graphql_jwt/signals.py:3
/usr/local/lib/python3.8/site-packages/graphql_jwt/signals.py:3: RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring.
token_issued = Signal(providing_args=['request', 'user'])
../../../usr/local/lib/python3.8/site-packages/graphql_jwt/signals.py:4
/usr/local/lib/python3.8/site-packages/graphql_jwt/signals.py:4: RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring.
token_refreshed = Signal(providing_args=['request', 'user'])
../../../usr/local/lib/python3.8/site-packages/graphql_jwt/refresh_token/signals.py:3
/usr/local/lib/python3.8/site-packages/graphql_jwt/refresh_token/signals.py:3: RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring.
refresh_token_revoked = Signal(providing_args=['request', 'refresh_token'])
../../../usr/local/lib/python3.8/site-packages/graphql_jwt/refresh_token/signals.py:4
/usr/local/lib/python3.8/site-packages/graphql_jwt/refresh_token/signals.py:4: RemovedInDjango40Warning: The providing_args argument is deprecated. As it is purely documentational, it has no replacement. If you rely on this argument as documentation, you can move the text to a code comment or docstring.
refresh_token_rotated = Signal(
-- Docs: https://docs.pytest.org/en/stable/warnings.html
===================================== 29 passed, 7 warnings in 1.56s =====================================
The tests pass. Success! If the tests didn’t pass you can troubleshoot the error with:
- Running pytest in verbose mode with the -vv flag.
- Test the failing query in GraphiQL. If the GraphiQL query executes successfully this implies that the issue is not related to the schema.
- Since self.assertResponseNoErrors(response) will only tell you that the test returns error code 400 and nothing else, I like to use a hack to force pytest to print out the query result by adding assert content != {} above self.assertResponseNoErrors(response). The test will fail but the query result will be printed to the terminal, (when pytest is in verbose mode), which should tell you a lot.
Conclusion
Where are we now? In:
- Chapter 1 we setup our development environment for
- Docker
- Django Graphene
- Chapter 2 we used TDD in developing our
- model tests
- and models necessary to pass our model tests
- how to use the Django shell to load data
- configure Django Admin to manipulate our data
- Chapter 3 we
- installed and configured Django Graphene,
- setup our schemas
- configured GraphiQL for passing GraphQL queries to the server.
- Chapter 4 we introduced mutations using TDD to define
- all our mutations
- updated our schemas to accommodate all CRUD operations.
- Chapter 5 we implemented JWT user authentication.
- Chapter 6 we updated our mutation tests to JWT user authentication.
To finish off this tutorial, next chapter we backup our database using Docker and populate our Makefile.