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
- We want to create a view that can be only accessed by superusers
- We will use the @method_decorator(login_required) trick to protect our view
- 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.
- 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
No Comments
You can leave the first : )