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.

Table Of Contents

In this blog we will be looking at how to start with testing and later on how to implement.

Coverage

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.

Testing Models

let's open up index.html in our newly created htmlscov folder in our project:

book-library > htmlscov > index.html

We will see something like this:
Screenshot_2021-06-28_13-08-04

Let's go to our catalog/models.py.
Screenshot_2021-06-28_13-13-12

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)])

Test Model Implementation

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)

Testing str

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.

Testing Many To Many

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.

Testing Absolute Url

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.

Testing Views

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.

  1. Check if only the logged in user can authorize your view (If user needs to be logged in to access).
  2. Check if the correct user can authorize your view.
  3. Check if the both GET and POST is working the way it should.
  4. 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)

Test View Implementation

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)

Deny Unauthorized User

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.

URL Is Accessed By Name

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.

Correct Template Usage

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.

Test num_books

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.

Testing Forms

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
        )

Next Topics To Cover

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 of unittest

  • 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.

Conclusion

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.

So this is it for today, if you have any question regarding this please do mention it in the comment or if you have any other questions please do not hesitate to contact me at the following platforms I am available on twitter, GitHub, LinkedIn

14