Django TDD setup

Unit Tests

Setup

1. Create virtual env. pipenv shell
2. Install django pipenv install django
3. Create django project. django-admin startproject tested
4. Create .gitignore file touch .gitignore

settings

setup a settings file for tests. This will use an in-memory sqlite DB (sqlite3). This is good because we don’t want a PG or MySQL db running i/o operation on the hard-drive. We have 100s of tests and db needs to be setup and teardown for each test. So and in-memory db like sqlite is the best choice for test database. I also add EMAIL_BACKEND in it to set the email backend to local so it accidentally does not start sending emails.

from .settings import *

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': ":memory:",
    }
}

EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'

Install pytest and plugins

Install pytest and related plugins

pipenv install pytest pytest-cov pytest-django mock pytest-factoryboy

pytest-cov is for generating a coverage report that is based on how much of your code is covered by the tests.
mock is a third party mocking application that allows one to create an API of payment gateways and other services
pytest-factoryboy generates testing data including populating the database with random data.

pytest.ini

Next step is to put a pytest.ini file. You put it at the root of the project, same level as manage.py

[pytest]
DJANGO_SETTINGS_MODULE = tested.test_settings
addopts = --nomigrations --cov=. --cov-report=html

addopts are the options that one may add on the command line interface (CLI). We want no migrations for the database and we want a coverage report in HTML. Other options are available here – http://pytest-cov.readthedocs.io/en/latest/readme.html#reporting

At this point, you can try to see if tests can be launched.

In terminal, where pytest.ini is located, run: pytest

.coveragerc

Since we want 100% coverage, we put a .coveragerc file to indicate what files we don’t intend to be included in coverage.

[run]
omit =
    *apps.py,
    *migrations/*,
    *settings*,
    *tests/*,
    *urls.py,
    *wsgi.py,
    *asgi.py,
    manage.py

this file is at same level as pytest.ini file.

Test procedure

The test setup is now complete. pytest will find all files called “test_*.py”. In these files, pytest will execute all functions called “test_*()” on all classes that start with Test

Each django app will have a tests folder. For each code in apps, like forms.py, we will have test_forms.py in the test folder.

Write first app and run first test

python manage.py startapp birdie
Update settings.py file to include 'birdie'

Wiring for pytest-factoryboy

cd birdie
mkdir tests
cd tests
touch __init__.py
touch conftest.py
touch factories.py
touch test_models.py

inside birdie, remove tests.py since we don’t want to pust all our tests in one file. We will create a folder tests and make it a python module by making __init__.py inside it.

Next we create a file called factories.py. In this file we setup all the factories for generating test data, like generating model objects for testing using the faker library. Factories look very similar to models.

import factory
from faker import Factory as FakerFactory

from .. models import Post

faker = FakerFactory.create()

class PostFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Post

    body = factory.LazyAttribute(lambda x: faker.text())

Next, we create a conftest.py file where we register the factories.
Fixtures defined in conftest.py file will be recognized in any test file. “Registering” the factories lets you inject them into your tests with a lowercase-underscore class name. In this same file, I’m defining fixtures with @pytest.fixture() which I’ll use in my tests, and injecting them with the factories I just registered.

import pytest
from pytest_factoryboy import register

from .factories import PostFactory

register(PostFactory)

@pytest.fixture()
def post(post_factory):
    return post_factory()

Test models

Now, we will create one test_*.py file for each code file. For example, if we have models.py file, we will create test_models.py file.

Since most apps have a model, lets start with writing a test for model.

import pytest

# pytestmark = pytest.mark.django_db

from .. models import Post

pytestmark = pytest.mark.django_db

class TestPost:
    pytestmark = pytest.mark.django_db
    def test_creation(self, post_factory):
        post_created = post_factory()
        post = Post.objects.get(id = post_created.id)
        assert post.id == 1, 'Should be 1'

    def test_get_excerpt(self, post_factory):
        post_created = post_factory()
        post = Post.objects.get(id = post_created.id)
        assert post.get_excerpt(5) == post_created.body[:5], 'Should be first few letters of bodyf'

Test admin

If we create admin interfaces of our models, we also need to write test for admin interface of models.
One important trivia is that to access admin interface, we need to instantiate AdminSite. Only after we have created this AdminSite() instance, can we create an instance of PostAdmin. This is based on pure observation.

For example:

import pytest
from django.contrib.admin.sites import AdminSite

from ..models import Post
from .. import admin

pytestmark = pytest.mark.django_db

class TestPostAdmin:
    def test_excerpt(self, post_factory):
        post_created = post_factory()
        admin_site = AdminSite()
        post_admin = admin.PostAdmin(model=Post, admin_site=admin_site)
        result = post_admin.excerpt(post_created)
        assert result == post_created.body[:5], 'Should return first five letters'

has the corresponding admin:

from django.contrib import admin

from .models import Post

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    model = Post
    list_display = ('excerpt',)

    def excerpt(self, obj=None):
        return obj.body[:5]

Test views

For testing views, we use Django’s RequestFactory. Like its name suggests, its a factory that will generate a Django request object for you. RequestFactory allows us to instantiate our view classes. In other words, the views we instantiate for testing only work only if we pass in a request. We need to create a fake request object that we can pass into our view. This is what a RequestFactory is good for.We can use something like Selenium but that makes testing very slow. The con of using RequestFactory is that it does not test the Django templates but that is something I can live with. I will manually test each page in the browser anyways.

Some tips:

  • we can use self.client.get() to do a get request on any view but this is very slow, almost like using selenium
  • we can instantiate our class based views just like we do it in our urls.py, via ViewName.as_view(). This turns our view class into a functions and then we can use this request object as that function’s arguement.
  • To test our views, we create a Request, pass it into our View, then make assertions on the returned Response.

Example test view:

from django.test import RequestFactory
from .. import views

class TestHomeView:
    def test_anonymous(self):
        '''HomeView should be accessable by anyone.'''
        req = RequestFactory().get('/')
        resp = views.HomeView.as_view()(req)
        assert resp.status_code == 200, 'Should be callable by anyone'

A small trivia is that in this code RequestFactory().get('/'), we can put anything in the url, here we put ‘/’. The url never gets evaluated. Even if we had kept url part empty like get(”) or garbage like get(‘asdkjfasf’), the test will still pass.

And here is the view:

from django.shortcuts import render
from django.views.generic import TemplateView

class HomeView(TemplateView):
    template_name = 'birdie/home.html'

Note that I don’t even need to have the actual html file as RequestFactory never evaluates the url or templates. The test will still pass even if there is no file called ‘home.html’ in templates/birdie folder.

Testing Authentication

  1. We want to create a view that can be only accessed by superusers
  2. We will use the @method_decorator(login_required) trick to protect our view
  3. that means, that there must be a .user attribute on the Request. By default, RequestFactory does not create a request object with the user attribute. So we will have to manually create a user and attach it to the request object.
  4. Even if we want to test as an anonymous user, in that case Django automatically attached a AnonymousUser instance to Request, so we have to fake this as well

Small trivia: Django actually has a class called AnonymousUser but this class does not have an associated database table.

So, first we need a way to fake authenticated users. Here it is:

Step 1: Create a user factory in factories.py:

import factory
from faker import Factory as FakerFactory
from django.contrib.auth.models import User
from django.contrib.auth.hashers import make_password

from .. models import Post

faker = FakerFactory.create()

class PostFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Post

    body = factory.LazyAttribute(lambda x: faker.text())

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    first_name = factory.Faker('first_name')
    last_name = factory.Faker('last_name')
    username = factory.Faker('email')
    password = factory.LazyFunction(lambda: make_password('admin'))  # all superusers have admin as password
    is_staff = True
    is_superuser = True

Step 2: Create a global fixture in contest.py

import pytest
from pytest_factoryboy import register

from .factories import (
    PostFactory,
    UserFactory,
)

register(PostFactory)
register(UserFactory)

@pytest.fixture()
def post(post_factory):
    return post_factory()

@pytest.fixture()
def user(user_factory):
    return user_factory()

Step 3: Write your test for the view, lets call it AdminView

import pytest
from django.test import RequestFactory
from django.contrib.auth.models import AnonymousUser
from .. import views
class TestHomeView:
    def test_anonymous(self):
        '''HomeView should be accessable by anyone.'''
        req = RequestFactory().get('/')
        resp = views.HomeView.as_view()(req)
        assert resp.status_code == 200, 'Should be callable by anyone'

pytestmark = pytest.mark.django_db
class TestAdminView:
    def test_anonymous(self):
        '''check that anonymous user gets redirected to login oage'''
        req = RequestFactory().get('/')
        req.user = AnonymousUser()  # manually attach AnonymousUser
        resp = views.AdminView.as_view()(req)
        # for checking, I can directly check the status_code as 302 but this is not good as the redirect might
        # also happen due to some actual logic in the view.
        # Better way: I know that a view protected by LoginRequiredMixin that redirects to '/accounts/login/?next='
        # (default value of redirect). So I check if the word 'login' is present in resp.url
        assert 'login' in resp.url

    def test_superuser(self, user_factory):
       '''check that authenticated user is able to access the view by getting 200 as response status code'''
       user = user_factory()
       req = RequestFactory().get('/')
       req.user = user  # manually attach the user variable to the request
       resp = views.AdminView.as_view()(req)
       assert resp.status_code == 200, 'Should be callable by the superuser'

Step 4: Create the corresponding view

from django.shortcuts import render
from django.contrib.auth.mixins import LoginRequiredMixin

from django.views.generic import TemplateView

class HomeView(TemplateView):
    template_name = 'birdie/home.html'


class AdminView(LoginRequiredMixin, TemplateView):
    template_name = 'birdie/admin.html'

Testing Forms

Lets imagine that we want to create a form that creates a Post object. Lets call it PostForm.

We can instantiate a PostForm by PostForm(). Since a form takes some data, we can give the data to the form when instantiating like so: PostForm(data={}). Now, usually, if a form is empty, it should not be valid. So this can be our first test. Then, since we are tweeting messages, and we have a minimum character length requirement of 6 for a valid message, we will write tests for too-short and OK messages.

So, lets do the wiring first.

Step 1: First we write fixtures for generating random test, both short and long. we use FuzzyText from factory.fuzzy to generate random text of specified length.

import pytest
import factory.fuzzy

from pytest_factoryboy import register

from .factories import (
    PostFactory,
    UserFactory,
)

register(PostFactory)
register(UserFactory)

@pytest.fixture()
def post(post_factory):
    return post_factory()

@pytest.fixture()
def user(user_factory):
    return user_factory()

@pytest.fixture()
def post_form_short_text():
    return {'body': factory.fuzzy.FuzzyText(length=5).fuzz()}

@pytest.fixture()
def post_form_long_text():
    return {'body': factory.fuzzy.FuzzyText(length=50).fuzz()}

Step 2: We write the test_forms.py file:

import factory
from .. import forms


class TestPostForm:
    def test_form(self, post_form_short_text, post_form_long_text):
        form = forms.PostForm(data={})
        assert form.is_valid() is False, 'Should return False for an empty form'

        form = forms.PostForm(data=post_form_short_text)
        assert form.is_valid() is False, 'Should be invalid if too short'

        form = forms.PostForm(data=post_form_long_text)
        assert form.is_valid() is True, 'Should be valid if long enough'

Step 3: We write the forms.py file

from django import forms
from .models import Post


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ('body',)

    def clean_body(self):
        data = self.cleaned_data.get('body')
        if len(data) <=5:
            raise forms.ValidationError('Message is too short')
        return data

Testing Post request

  • We want to create a view that uses the PostForm to update a Post
  • Testing POST requests works in the same way like GET requests.
  • Since UpdateView uses Form, we will use the PostForm created above

So, since we are test a PostUpdateView, as in updating a Post that is already present, lets first create a fixture to create the text for that post update.

Add this new fixture to conftest.py

from faker import Faker
@pytest.fixture()
def post_form_message():
    fake = Faker()
    while True:
        message = fake.text()
        if len(message) > 20:
            break
    return {'body': message}

Now, lets write the test in test_views.py. Since the view is going to be called PostUpdateView, we write the test like so:

class TestPostUpdateView:
    """So, the view needs to be callable, i.e. it must support a get request so we can get the form
    Then the view must also support a POST request so we can do an update"""
    pytestmark = pytest.mark.django_db
    def test_get(self, post_factory):
        ''' testing get request for the form'''
        # the way update view works is that to get the form, you need to provide the primary key
        # so lets create the post first. This is the post that we will update through the form
        post_created = post_factory()
        req = RequestFactory().get('/')
        # this is how you pass kwargs to a RequestFactory object
        resp = views.PostUpdateView.as_view()(req, pk=post_created.pk)
        assert resp.status_code == 200, 'Should be callable by anyone'

    pytestmark = pytest.mark.django_db
    def test_post(self, post_factory, post_form_message):
        post_created = post_factory()
        data = post_form_message
        req = RequestFactory().post('/', data=data)
        resp = views.PostUpdateView.as_view()(req, pk=post_created.pk)
        # django update views by design redirect you to a success url is POST was successful
        assert resp.status_code == 302, 'Should redirect to success view'

        #modified_post = models.Post.objects.filter(pk=post_created.pk)
        post_created.refresh_from_db()
        assert post_created.body == post_form_message.get('body', object())  # object() isn't equal to anything else

trivia: refresh_from_db() is a method on all django query objects that do exactly that .
PostForm already exists. So we finally add the view to views.py:

from django.views.generic import UpdateView
from .models import Post
from .forms import PostForm

class PostUpdateView(UpdateView):
    model = Post
    form_class = PostForm
    template_name = 'birdie/update.html'
    success_url = '/'

Testing 404, 500 errors

use self.client.get()

Testing Template tags

Testing Django management commands

Testing with sessions

Testing with Files

Like how do you test the image upload field of a form

https://afroshok.com/articles/test-driven-development-pytest/




https://dev.to/aduranil/writing-tests-with-pytest-and-factory-k7
https://pytest-django.readthedocs.io/en/latest/database.html
https://faker.readthedocs.io/en/master/

Functional Tests

We will be using unittest with Selenium, and Firefox.

Download the geckodriver from https://github.com/mozilla/geckodriver/releases

Extract geckodriver executable. I put it in my project root (lets call it my_project. Inside my_project we have manage.py). You can optionally add it to .gitignore as this will never be deployed to production. I tend to not put it in my /usr/local/bin.

So, geckodriver sits here: my_project/geckodriver

pipenv install selenium

Now, before running functional tests, open the shell where you will be running the functional tests. Then add the path to the geckodriver to you $PATH.

export PATH=$PATH:/home/mykonos/Python/projects/my_project

now create a file called functional_tests.py in the same level as manage.py

from selenium import webdriver
import unittest


class FunctionalTests(unittest.TestCase):
    URL = 'http://localhost:8000'
    def setUp(self) -> None:
        self.browser = webdriver.Firefox()

    def tearDown(self) -> None:
        self.browser.quit()

    def test_content(self):
        self.browser.get(self.URL)
        self.assertIn(self.browser.title, 'MY_TITLE')


if __name__ == '__main__':
    unittest.main()

then run the test like so:

python -m functional_tests

Tek Shinobi
Author: Tek Shinobi

Hiya Ninjas, I am the ninja who invented the fire, wheel, science, technology and everything intelligent this humanity has ever experienced since its evolution from monkeys and germs. Actually, above is a partial list. I also was involved with the Big Bang that created this universe. What was it like before the Big Bang is so secret that if I tell you, I will have to turn you into a hobbit and force you to be my gardener. Okay. Sayonanra, Namaste Tek Shinobi




No Comments


You can leave the first : )



Leave a Reply

Your email address will not be published. Required fields are marked *