Categories
Python Tutorial

Testing Your Django App With pytest : A Beginner’s Guide (Part 2)

We have covered installation and how to configure the Django project for the pytest in the previous blog post. Today we will learn how to write tests with pytest for a Django project with some basic examples.

We are going to write tests for:

  • models
  • views
  • and forms
Project Structure

We are going to test the blog app. We have deleted the default tests.py in blog app, created a folder named tests. The project structure currently looks like this:

├── blog
│   ├── admin.py
│   ├── apps.py
│   ├── forms.py
│   ├── __init__.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   └── __init__.py
│   ├── models.py
│   ├── tests
│   │   ├── __init__.py
│   │   ├── test_forms.py
│   │   ├── test_models.py
│   │   └── test_views.py
│   ├── urls.py
│   └── views.py
├── db.sqlite3
├── manage.py
├── media
├── proj
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── pytest.ini
├── README.md
├── static
├── templates
│   └── blog
│       └── index.html
└── test_settings.py

In the previous post, we have shown how to create pytest.ini and test_settings.py.

From the filenames in tests, you may assume what’s their purpose. test_forms.py, test_models.py, and test_views.py will contain tests for forms, models, and views respectively. This is the preferred structure for tests.

Let’s get into testing …

Testing Model

So at first, we would like to start with models.py. We have a model BlogPost in the blog app.

import datetime

from django.db import models
from django.utils import timezone


class BlogPost(models.Model):
    title = models.CharField(max_length=120)
    slug = models.SlugField(unique=True)
    content = models.TextField(null=True, blank=True)
    publish_date = models.DateTimeField(auto_now=False, auto_now_add=False, null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    modified_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ('-publish_date', )

    def published_recently(self):
        now = timezone.now()
        two_days_ago = now - datetime.timedelta(days=2)
        return two_days_ago < self.publish_date <= now

    def __str__(self):
        return self.title

Now, we are going to test the functionality of the method published_recently of the BlogPost model whether it’s serving its purpose or not. published_recently checks a BlogPost instance is published recently based on the publish_date field. If publish_date is within 2 days from the current time then it returns True otherwise False.

import datetime

import pytest

from django.utils import timezone

from blog.models import BlogPost


@pytest.mark.django_db
def test_blog_post_published_recently_with_future_post():
    publish_date = timezone.now() + datetime.timedelta(days=30)
    post = BlogPost.objects.create(
        title="Test blog post",
        slug="test-blog-post",
        content="Test content for blog post.",
        publish_date=publish_date
    )
    assert post.published_recently() is False


@pytest.mark.django_db
def test_blog_post_published_recently_with_old_post():
    publish_date = timezone.now() - datetime.timedelta(days=7)
    post = BlogPost.objects.create(
        title="Test blog post",
        slug="test-blog-post",
        content="Test content for blog post.",
        publish_date=publish_date
    )
    assert post.published_recently() is False


@pytest.mark.django_db
def test_blog_post_published_recently_with_recent_post():
    publish_date = timezone.now()
    post = BlogPost.objects.create(
        title="Test blog post",
        slug="test-blog-post",
        content="Test content for blog post.",
        publish_date=publish_date
    )
    assert post.published_recently() is True

As you can see, we are using @pytest.mark.django_db, which is a decorator that helps pytest-django to get access to the test database. Here we have written three tests to test three scenarios that can happen. The first one checks the method returns False for a future blog post. The second test checks for a post published 7 days ago the method returns False. And the last one check for a currently published blog post the method returns True

Testing View

In the blog app, we have a single view index, which renders template index.html with the first three blog posts.

from django.shortcuts import render

from .models import BlogPost


def index(request):
    latest_post_list = BlogPost.objects.all()[:3]
    context = {"latest_post_list": latest_post_list}
    return render(request, "blog/index.html", context)

To test the index view, we have test_index_view function. The test creates two blog posts, gets the response of view using client from pytest. Then it checks if status_code is equal to 200 or not, and checks the two blog posts are in the response’s content.

import pytest

from django.urls import reverse
from django.utils import timezone

from blog.models import BlogPost
from blog.views import index


@pytest.mark.django_db
def test_index_view(client):
    publish_date = timezone.now()
    BlogPost.objects.create(
        title="Test blog post 1",
        slug="test-blog-post-1",
        content="Test content for blog post 1.",
        publish_date=publish_date
    )
    BlogPost.objects.create(
        title="Test blog post 2",
        slug="test-blog-post-2",
        content="Test content for blog post 2.",
        publish_date=publish_date
    )

    url = reverse("blog:home")
    request = client.get(url)
    response = index(request)
    content = response.content.decode(response.charset)
    assert response.status_code == 200
    assert "Test blog post 1" in content
    assert "Test blog post 2" in content
Testing Form

BlogPostForm, a django model form for the model BlogPost. The form is responsible to take and validate user inputs. It raises a validation error if the user submits a blog post with a title that is already used in another blog post.

from django import forms

from .models import BlogPost


class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPost
        fields = ["title", "slug", "content", "publish_date"]

    def clean_title(self, *args, **kwargs):
        instance = self.instance
        title = self.cleaned_data.get("title")
        qs = BlogPost.objects.filter(title__iexact=title)
        if instance is not None:
            qs = qs.exclude(id=instance.id)
        if qs.exists():
            raise forms.ValidationError("This title has already been used. Please try again.")
        return title

So we have written two tests for the BlogPostForm. The first one check passes valid form data and checks the form is valid. and the second one checks invalid form, at first we create a blog post and in the form data, we pass the exact same title of that blog post. The test checks the form is invalid and makes sure the form shows the right validation error.

import pytest

from django.utils import timezone

from blog.forms import BlogPostForm
from blog.models import BlogPost


@pytest.mark.django_db
def test_blog_post_form_valid():
    publish_date = timezone.now()
    form_data = {
        "title": "Test blog post",
        "slug": "test-blog-post",
        "content": "Test content for blog post.",
        "publish_date": publish_date,
    }
    form = BlogPostForm(data=form_data)
    assert form.is_valid() is True


@pytest.mark.django_db
def test_blog_post_form_invalid():
    publish_date = timezone.now()
    BlogPost.objects.create(
        title="Test blog post",
        slug="test-blog-post",
        content="Test content for blog post.",
        publish_date=publish_date
    )
    form_data = {
        "title": "Test blog post",
        "slug": "test-blog-post",
        "content": "Test content for blog post.",
        "publish_date": publish_date,
    }
    form = BlogPostForm(data=form_data)
    assert form.is_valid() is False
    assert "This title has already been used. Please try again." == form.errors["title"][0]

Now it’s time to run the tests we have written so far!

pytest
=============================================================================================== test session starts ===============================================================================================
platform linux -- Python 3.6.9, pytest-5.3.2, py-1.8.1, pluggy-0.13.1
Django settings: test_settings (from ini file)
rootdir: /home/adnan/Blog_Post_Snippets/blog_project/blog_proj, inifile: pytest.ini
plugins: django-3.7.0
collected 6 items                                                                                                                                                                                                 

blog/tests/test_forms.py ..                                                                                                                                                                                 [ 33%]
blog/tests/test_models.py ...                                                                                                                                                                               [ 83%]
blog/tests/test_views.py .                                                                                                                                                                                  [100%]

================================================================================================ 6 passed in 0.23s ================================================================================================

Tests passed!

The source code of the project is available on GitHub: https://github.com/dreamcatcherit/django_pytest_blog_project

I hope this post helped you to give a basic overview of the possibilities of the pytest . See you next time!

Author:

Adnan Alam

Self-taught programmer | Pythonista | Love to solve problems!
Dreamcatcher IT
Github | Website | Linkedin