Common testing scenarios for Django app.

By : Akshar Raaj

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 in django.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 in setUp.
  • We created a Client object in setUp. This object is needed to make GET or POST 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="test@test.com", 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 on Client. So, we can call self.c.login().
  • login() takes a username and password. You should provide credentials of some user already present in db. Remember we created an user in setUp. 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 of BlogEntry 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 have is_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 {{STATIC_URL}}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.


Related Posts


Can we help you build amazing apps? Contact us today.

Topics : django testing

Comments

Pradip Caulagi

One of the important metrics when writing tests is to make sure they run fast. And while testing the way you demonstrate is easy to get started, people need to be aware that these tests are going to be very slow because we hit all the layers - database persistence, routing, middleware, etc. There is a better way to test your views here - http://www.youtube.com/watch?v=ickNQcNXiS4. The full video is worth a watch but there are better techniques to write view tests after 25 mins or so.

commmenttor
wC 1st May, 2013

Regardless of the speed of the tests, this is great documentation. Thanks!

commmenttor
One particular Phase To On the lookout Similar to a Celeb With Ray Ban Caravan

Common testing scenarios for Django app. - Agiliq Blog | Django web app development

commmenttor
© Agiliq, 2009-2012