People are often confused regarding what tests to write. Let’s look into some scenarios for which tests can be written.
##Setting up the project
We start a Django project inside a virtual environment. In this post we would be using django 1.4.
(dj)~/play/dj$ django-admin.py startproject testcases
Let’s start an app named blog
.
(dj)~/play/dj/testcases$ python manage.py startapp blog
We will have the following model in blog/models.py
:
class BlogEntry(models.Model):
title = models.CharField(max_length=100)
text = models.TextField()
is_published = models.BooleanField(default=True)
user = models.ForeignKey(User)
We will do test driven development which requires:
- Thinking about our assumption.
- Writing the test to satisfy that assumption.
- Run the test and it will fail since we won’t have view written till this point.
- Adding the view.
- Run the test and fixing the view or anything else till our test passes.
If I could not explain the project structure properly, you can find the complete project here.
##First test
We want a page which shows all blog entries at url /blog/entries/
.
We need following line in urls i.e testcases/urls.py
url(r'^blog/', include('blog.urls')),
blog/urls.py
url(r'^entries/$', 'blog.views.entries', name='entries'),
Let’s add a test which satisfies our assumption.
Every app we create gets a tests.py
where we can put our tests. You can remove the simple addition test generated by default by Django.
blog/tests.py
from django.test import TestCase
from django.test.client import Client
class BlogEntriesTest(TestCase):
def setUp(self):
self.c = Client()
def test_entries_access(self):
response = self.c.get('/blog/entries/')
self.assertEqual(response.status_code, 200)
###Explanation
- We need a test class which is
BlogEntriesTest
. Any test class we write must subclass TestCase which is defined indjango.test
. - Actual tests go in methods defined on the test class. So, our test goes in
test_entries_access
. Every test method name must start with test for it to be found by the django test runner. - Before every test,
setUp
method is run. So anything which is common on all the test methods can go insetUp
. - We created a
Client
object in setUp. This object is needed to makeGET
orPOST
request.Client
object simulates request to a url similar to a browser can. Since it is created in setUp, it will be available in all the test methods. - From
test_entries_access
, we make a GET request to the url which we have defined. We capture the response in variable response. - We are asserting that
status_code
of response must be 200 since we assumed this url to return a 200.
###Running the test Tests use a database. Since sqlite is faster than mysql or postgresql, we would use sqlite as the test database. You can continue using your any other database for development or production. We are only interested in running the tests using sqlite.
testcases/test_settings.py
from settings import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'abc'
}
}
Running the test.
python manage.py test blog --settings=testcases.test_settings
blog
says that only run the tests for app blog
. Not providing this would run the tests for all the apps defined in INSTALLED_APPS which would take quite some time. Make sure that you have added blog
to INSTALLED_APPS.
--settings
flag tells django test runner to use the specified settings file.
And our test fails.
ERROR: test_entries_access (blog.tests.BlogEntriesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
......
......
ViewDoesNotExist: Could not import blog.views.entries. View does not exist in module blog.views.
----------------------------------------------------------------------
Ran 1 test in 0.019s
FAILED (errors=1)
Because we have not written the view yet. We didn’t have to go to browser to check if this url works, our test does it for us.
We should test every url we want available in our project. So, that if some url breaks our test would tell that to us immediately.
###Adding the view
from blog.models import BlogEntry
from django.shortcuts import render
def entries(request):
entries = BlogEntry.objects.all()
return render("blog/entries.html", {'entries': entries})
Make sure to add the template blog/entries.html
in your template directory.
Run the test again.
python manage.py test blog --settings=testcases.test_settings
Ran 1 test in 0.019s
OK
Destroying test database for alias 'default'...
###Minor modification to test
Since we should not hardcode the urls, we will use reverse
.
def test_entries_access(self):
response = self.c.get(reverse('entries'))
self.assertEqual(response.status_code, 200)
Run the test again to make sure our change has not broken anything.
##Test that context is populated in template
We want to make sure that our expectation regarding number of visible blog entries on the page matches the result. Say we have two entries in the database then both of them should be shown on the page as per our view definition.
A blog must be associated with a user. So, we will create an instance of user in setUp
because we would need this user in other tests as well.
def setUp(self):
self.c = Client()
self.user = User.objects.create_user(username="test", email="[email protected]", password="test")
###Testing the context
def test_entries_template_context(self):
#create few blog entries
BlogEntry.objects.create(title='Test', text='Test', user=self.user)
BlogEntry.objects.create(title='Test', text='Test', user=self.user)
response = self.c.get(reverse('entries'))
#assert that context contains as many entries as you expect
self.assertEqual(len(response.context['entries']), 2)
response
contains an attribute context which is a dictionary containing the context sent to template.
###Assertion
- We created two blogs and asserted that context contains both of them.
This test is not much useful now. We will see how response.context
becomes useful when we write a custom manager.
##Only logged in user must access create entry page.
def test_entry_create(self):
response = self.c.get(reverse('entry_create'))
self.assertEqual(response.status_code, 302)
self.c.login(username='test', password='test')
response = self.c.get(reverse('entry_create'))
self.assertEqual(response.status_code, 200)
Run the test. We know it will fail because we have not written the view or the url yet.
python manage.py test blog --settings=testcases.test_settings
.....
ERROR: test_entry_create (blog.tests.BlogEntriesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
....
NoReverseMatch: Reverse for 'entry_create' with arguments '()' and keyword arguments '{}' not found.
----------------------------------------------------------------------
Ran 3 tests in 0.051s
FAILED (errors=1)
###Explanation:
- We expect a url with name
entry_create
to be available. - A non-logged user should not be able to access this url and should be redirected to login urll. So, we assert that status code be 302.
- There is a
login
method defined onClient
. So, we can callself.c.login()
. login()
takes a username and password. You should provide credentials of some user already present in db. Remember we created an user insetUp
. We pass the same credentials here.- After this client i.e
self.c
starts behaving like a logged in user. - Now client should be able to access create page and so we assert the status code as 200.
Let’s start fixing this test.
Need to add following in blog/urls.py
url(r'^entry/create/$', 'blog.views.entry_create', name='entry_create'),
Adding a modelform. This will be used in create view.
class BlogEntryForm(ModelForm):
class Meta:
model = BlogEntry
Adding the view
def entry_create(request):
form = BlogEntryForm()
if request.method == "POST":
form = BlogEntryForm(request.POST)
if form.is_valid():
return HttpResponseRedirect(reverse('entries'))
form.save()
return render(request, "blog/entry_create.html", {'form': form})
Make sure to create the template. And then run the test.
You will still see an error.
File "/home/akshar/play/dj/testcases/blog/tests.py", line 30, in test_entry_create
self.assertEqual(response.status_code, 302)
AssertionError: 200 != 302
Because we missed adding login_required
to the view and even anonymous users are able to access this url. And hence they are getting a 200 instead of 302.
Let’s fix it by adding login_required
decorator to entry_create
view.
Run the test again and it should pass now.
##Test invalid form
So, we wrote entry_create
with assumption that it will handle POST requests.
We want to make sure that this view doesn’t allow invalid POST and raises an exception in that case.
def test_invalid_entry_create(self):
self.c.login(username='test', password='test')
data = {'text': 'Test text'}
response = self.c.post(reverse('entry_create'), data)
self.assertEqual(response.status_code, 200)
self.assertFormError(response, "form", "title", "This field is required.")
###Assertions:
- Since we posted an invalid form, we expect to remain on the same page. So asserted for status code of 200.
- We expect an error to be present on the
title
field.
##Test valid form
def test_valid_entry_create(self):
self.c.login(username='test', password='test')
data = {'text': 'Test text', 'title': 'Test title'}
data['user'] = self.user.id
self.assertEqual(BlogEntry.objects.count(), 0)
response = self.c.post(reverse('entry_create'), data)
self.assertEqual(response.status_code, 302)
self.assertEqual(BlogEntry.objects.count(), 1)
###Assertions:
- Before posting we assert that there is no
BlogEntry
in the db. - After posting we check that the user is redirected and so asserted for status code of 302
- We make sure that a
BlogEntry
is created in the database on post by checking that count ofBlogEntry
has been increased to 1.
##Test custom manager methods
Suppose you find yourself writing the same filter multiple times for getting the blog entries which have is_published as True. In that case you would write a custom manager.
We will add the custom manager in models.py
class PublishedBlogManager(models.Manager):
def get_query_set(self, *args, **kwargs):
return super(PublishedBlogManager, self).get_query_set(*args, **kwargs).filter(is_published=True)
Also we need to add this manager on BlogEntry
. So, don’t forget to add next two lines to BlogEntry
objects = models.Manager()
published = PublishedBlogManager()
Let’s write a test now:
def test_entry_custom_managers(self):
BlogEntry.objects.create(title='Test', text='Test', user=self.user, is_published=False)
BlogEntry.objects.create(title='Test', text='Test', user=self.user)
self.assertEqual(BlogEntry.objects.count(), 2)
self.assertEqual(BlogEntry.published.count(), 1)
###Assertions:
- We created two entries. One with is_published as False, and another with True.
objects
i.e default manager returns all the entries.published
i.e custom manager returns only entries which haveis_published=True
.
###Using custom manager in test_entries_template_context:
Say now we decide that all entries should not be shown on list entries page. Only published entries should be shown.
Remember test_entries_template_context
. We only created two blog entries in that test. Edit that test and create one more entry with is_published=False
def test_entries_template_context(self):
#create few blog entries
BlogEntry.objects.create(title='Test', text='Test', user=self.user)
BlogEntry.objects.create(title='Test', text='Test', user=self.user)
BlogEntry.objects.create(title='Test', text='Test', user=self.user, is_published=False)
response = self.c.get(reverse('entries'))
#assert that context contains only published entries
self.assertEqual(len(response.context['entries']), 2)
We created three entries. Only two of them are published.
###Assertion:
- Entries page should only show 2 entries.
Run the test and it will fail.
======================================================================
FAIL: test_entries_template_context (blog.tests.BlogEntriesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/akshar/play/dj/testcases/blog/tests.py", line 29, in test_entries_template_context
self.assertEqual(len(response.context['entries']), 2)
AssertionError: 3 != 2
----------------------------------------------------------------------
Happened because we forgot to change our view. Our view still says BlogEntry.objects.all()
. We should change it to BlogEntry.published.all()
. Make this change and the test will pass.
def entries(request):
entries = BlogEntry.published.all()
return render_to_response("blog/entries.html", {'entries': entries})
So, response.context helped us find that we do not show more entries than we should.
##Test for pagination
Suppose we expect maximum ten entries to be available on each page. There are 15 entries in the db, so first page should show 10 entries and the second page should show 5. If user tries to access the third page a 404 page should be shown.
Our decided url pattern for getting entries on a particular page:
url(r'^entries/(?P<page>\d+)/$', 'blog.views.entries_page', name='entries_page')
Writing the test:
def test_entries_page(self):
for i in range(15):
BlogEntry.objects.create(title="title", text="text", user=self.user, is_published=True)
#access first page
response = self.c.get(reverse("entries_page", args=[1,]))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['entries']), 10)
#access second page
response = self.c.get(reverse("entries_page", args=[2,]))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context['entries']), 5)
Run the test and it will fail. Let’s add view to make it pass.
def entries_page(request, page):
page = int(page)
entries = BlogEntry.published.all()
paginator = Paginator(entries, 10) #10 entries per page
page_ = paginator.page(page)
object_list = page_.object_list
return render(request, "blog/entries_page.html", {"entries": object_list})
Test should pass now provided you have added the template.
Let’s try to access third page in the test. We need to add following in test_entries_page
for that.
#access third page
response = self.c.get(reverse("entries_page", args=[3,]))
self.assertEqual(response.status_code, 404)
Running the test raises an error.
....
raise EmptyPage('That page contains no results')
EmptyPage: That page contains no results
We find that there is a bug in our view and any page which doesn’t contain entries is not being handled as we want. Let’s change our view:
def entries_page(request, page):
page = int(page)
entries = BlogEntry.published.all()
paginator = Paginator(entries, 10) #10 entries per page
if page > paginator.num_pages:
raise Http404()
page_ = paginator.page(page)
object_list = page_.object_list
return render(request, "blog/entries_page.html", {"entries": object_list})
Run the test again.
If you have a 404 template defined then your test will pass. In this project we do not have a 404 template and so we get another exception
ERROR: test_entries_page (blog.tests.BlogEntriesTest)
----------------------------------------------------------------------
Traceback (most recent call last):
....
raise TemplateDoesNotExist(name)
TemplateDoesNotExist: 404.html
So, let’s add a 404 template. Test passes once we do it.
So, this test also helped us find a missing 404 template.
##Test for static files
You can look at our blog on static file if you have some confusion regarding how static files are served in Django.
We will create a static
directory in our app blog
and will put an image in this directory. Let’s say this image is default.jpg
Let’s add a test to make sure this image is found by django static file handlers. This test makes us confident that we can expect this image to be served at default.jpg
from django.contrib.staticfiles import finders
from django.contrib.staticfiles.storage import staticfiles_storage
def test_images(self):
abs_path = finders.find('default.jpg')
self.assertTrue(staticfiles_storage.exists(abs_path))
Run the test and it should pass.
You can view the complete project here.
Thank you for reading the Agiliq blog. This article was written by akshar on Apr 28, 2013 in testing .
You can subscribe ⚛ to our blog.
We love building amazing apps for web and mobile for our clients. If you are looking for development help, contact us today ✉.
Would you like to download 10+ free Django and Python books? Get them here