14
Django: Testing The What, Why and How. (Code)
This is a continuation of two part series, please check out part-1 to look at the theoretical part of testing.
In this blog we will be looking at how to start with testing and later on how to implement.
First we need to know what needs to be tested. As I said before, there is no need to test Django' vanilla model or Django ORM and python's in-built functions.
This is where coverage comes in handy, it is a convenient way for a beginner to find out the functions we need to test.
let's open up index.html
in our newly created htmlscov
folder in our project:
book-library > htmlscov > index.html
The red line shows the line we need to test and the percentage represents how much code has been covered with testing.
Considering our Book
model:
class Book(models.Model):
"""Model representing a book
"""
title = models.CharField(max_length=200)
author = models.ForeignKey('Author', on_delete=models.SET_NULL, null=True)
summary = models.TextField(
max_length=1000, help_text='Enter a brief description of the book')
isbn = models.CharField('ISBN', max_length=13, unique=True,
help_text='13 Character <a href="https://www.isbn-international.org/content/what-isbn">ISBN number</a>')
genre = models.ManyToManyField(Genre, help_text='Select a genre for this book', related_name='books')
def __str__(self):
return self.title
def get_absolute_url(self):
"""Returns the url to access a detail record for this book.
"""
return reverse('book-detail', args=[str(self.id)])
class TestModels(TestCase):
@classmethod
def setUpTestData(self):
thriller = Genre.objects.create(name='thriller')
scifi = Genre.objects.create(name='scifi')
book = Book.objects.create(
title='test title',
summary='test summary',
isbn='test isbn',
)
book.genre.set([thriller.pk, scifi.pk])
def test_book_has_genre(self):
"""checks for genres in book
"""
book = Book.objects.first()
self.assertEqual(book.genre.count(), 2)
def test_book_str(self):
"""Checks str for book
"""
book = Book.objects.first()
self.assertEqual(str(book), "test title")
def test_book_absolute_url_with_200(self):
book = Book.objects.first()
self.assertEqual(book.get_absolute_url(), '/catalog/book/1')
response = self.client.get(book.get_absolute_url())
self.assertEqual(response.status_code, 200)
First we setup our data and then we test for the str
representation.
def test_book_str(self):
"""Checks str for book
"""
book = Book.objects.first()
self.assertEqual(str(book), "test title")
One thing to note is the test method name should always start with test_
and it should be precise so that one can understand what the test is about. However, in cases where you feel that the naming may not be precise or meaningful make sure to use docstring to explain what it is supposed to do.
def test_book_has_genre(self):
"""checks for genres in book
"""
book = Book.objects.first()
self.assertEqual(book.genre.count(), 2)
We assigned two genres for our book when we were creating our book. We are simply checking for the count of genres in the book, which tells us that the genres have been assigned successfully.
def test_book_absolute_url_with_200(self):
book = Book.objects.first()
self.assertEqual(book.get_absolute_url(), '/catalog/book/1')
response = self.client.get(book.get_absolute_url())
self.assertEqual(response.status_code, 200)
In our Book
model we have a method called get_absolute_url
which gets the information of a single book. We check if the status code is 200 and the get_absolute_url
method actually is the correct url.
To validate our views we will make use of Django's test client.
Note: This is not strictly "unit test". It is actually quite difficult to write independent unit test for Django view code.
We can check for multiple things in our views but these are some common ones.
- Check if only the logged in user can authorize your view (If user needs to be logged in to access).
- Check if the correct user can authorize your view.
- Check if the both
GET
andPOST
is working the way it should. - Check for the validation you may have implemented.
We will refer to our index
view for our testing.
from .models import Book, Author, BookInstance, Genre
from django.contrib.auth.decorators import login_required
@login_required(login_url='/login/')
def index(request):
"""View function for home page of site."""
# Generate counts of some of the main objects
num_books = Book.objects.all().count()
num_instances = BookInstance.objects.all().count()
# Available books (status = 'a')
num_instances_available = BookInstance.objects.filter(status__exact='a').count()
num_authors = Author.objects.count()
# Number of visits to this view, as counted in the session variable.
num_visits = request.session.get('num_visits', 0)
request.session['num_visits'] = num_visits + 1
context = {
'num_books': num_books,
'num_instances': num_instances,
'num_instances_available': num_instances_available,
'num_authors': num_authors,
'num_visits': num_visits,
}
# Render the HTML template index.html with the data in the context variable
return render(request, 'index.html', context=context)
from django.test import TestCase
from catalog.models import Author
from django.urls import reverse
from django.contrib.auth.models import User
from model_bakery import baker
class IndexViewTest(TestCase):
@classmethod
def setUpTestData(cls):
User.objects.create_user(username='test_user', password='test_password')
books = baker.make('catalog.Book', _quantity=10)
def test_view_deny_anonymous(self):
"""Test the view for unauthenticated user if unauthenticated will redirect to login page
"""
response = self.client.get('/catalog/')
self.assertRedirects(response, '/login/?next=/catalog/')
response = self.client.post('/catalog/')
self.assertRedirects(response, '/login/?next=/catalog/')
def test_view_url_accesible_by_name(self):
"""Test view is accesible by the reverse method
"""
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('index'))
self.assertEqual(response.status_code, 200)
def test_view_uses_correct_template_(self):
"""Test view is using correct template
"""
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'index.html')
def test_view_has_context_num_books(self):
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertTrue('num_books' in response.context)
self.assertEqual(response.context['num_books'], 10)
def test_view_deny_anonymous(self):
"""Test the view for unauthenticated user if unauthenticated will redirect to login page
"""
response = self.client.get('/catalog/')
self.assertRedirects(response, '/login/?next=/catalog/')
response = self.client.post('/catalog/')
self.assertRedirects(response, '/login/?next=/catalog/')
We are checking if the unauthenticated user is being denied and redirected to the login page.
def test_view_url_accessible_by_name(self):
"""Test view is accessible by the reverse method
"""
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('index'))
self.assertEqual(response.status_code, 200)
We check if the reverse
method is working and gets us a status code 200
for both GET
and POST
method.
def test_view_uses_correct_template_(self):
"""Test view is using correct template
"""
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'index.html')
This checks if the correct template is being used by using assertTemplateUsed
method.
def test_view_has_context_num_books(self):
self.client.login(username='test_user', password='test_password')
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertTrue('num_books' in response.context)
self.assertEqual(response.context['num_books'], 10)
In this method we test if the number of books are assigned correctly. We use model-bakery here to create 10 Book
instances.
Forms are something that can be easily unit tested. We can test for the validation in forms. They accept dictionary of values, validate it and return either errors or cleaned data.
In our project we do not have a form, so let's make one for this tutorial's sake.
from django import forms
from .models import Book
class AddBookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title"]
def clean_title(self):
title = self.cleaned_data['title']
if not title:
return title
if not title[0].isupper():
self.add_error("title", "Should start with an uppercase")
if title.endswith("."):
self.add_error("title", "Should not end with a full stop")
return title
We included some validation in our model which we will be testing.
Our testing would look something like this.
from django.test import TestCase
from catalog.forms import AddBookForm
class AddBookFormTests(TestCase):
def test_title_starting_lowercase(self):
form = AddBookForm(data={"title": "a lowercase title"})
self.assertEqual(
form.errors["title"], ["Should start with an uppercase"]
)
def test_title_ending_full_stop(self):
form = AddBookForm(data={"title": "A stopped title."})
self.assertEqual(
form.errors["title"], ["Should not end with a full stop"]
)
def test_title_with_ampersand(self):
form = AddBookForm(data={"title": ""})
self.assertEqual(
form.errors["title"], ["This field is required."]
)
As we can see in forms we mainly test for validations. If we want to add some sort of integration tests alongside unit test we could check the validation by using using client doing something like this:
def test_title_starting_lowercase(self):
response = self.client.post(
"/books/add/", data={"title": "a lowercase title"}
)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(
response, "Should start with an uppercase letter", html=True
)
I have tried to cover as much as possible in this series but there are tons of things out there when it comes to testing. I will try to cover these topics in my upcoming blogs. A short list includes:
Continuous Integration: Automatically run all tests whenever there a new commit is made. It can be done using GitHub Actions, Travis CI, Circle CI.
Mock: It is an act of replacing part of the application you are testing with a dummy version.
Pytest: It is a testing framework just like
unittest
. We can also use this instead ofunittest
Model Bakery: We saw a small implementation of this in our project but there is more to discuss.
TDD: Test Driven Development, this is actually quite an advance topic which takes a while to get used to, but once you start doing and understand the benefits, you will never want to go back.
To conclude, there is not any concrete way to write your tests. It depends on the project you are working on or the company you are working at. Everyone can have their own way of testing their codes.
The final advice I can give you is, do not be scared of writing tests. Yes, it can be intimidating at first but once you start writing tests you will understand your codes better, it will stimulate to question yourself to think about all the cases where your program can go wrong making you a better programmer.
14