Writing your first Django app, part 5

This tutorial begins where Tutorial 4 left off.We’ve built a Web-poll application, and we’ll now create some automated testsfor it.

Introducing automated testing

What are automated tests?

Tests are routines that check the operation of your code.

Testing operates at different levels. Some tests might apply to a tiny detail(does a particular model method return values as expected?) while othersexamine the overall operation of the software (does a sequence of user inputson the site produce the desired result?). That’s no different from the kind oftesting you did earlier in Tutorial 2, using theshell to examine the behavior of a method, or running theapplication and entering data to check how it behaves.

What’s different in automated tests is that the testing work is done foryou by the system. You create a set of tests once, and then as you make changesto your app, you can check that your code still works as you originallyintended, without having to perform time consuming manual testing.

Why you need to create tests

So why create tests, and why now?

You may feel that you have quite enough on your plate just learningPython/Django, and having yet another thing to learn and do may seemoverwhelming and perhaps unnecessary. After all, our polls application isworking quite happily now; going through the trouble of creating automatedtests is not going to make it work any better. If creating the pollsapplication is the last bit of Django programming you will ever do, then true,you don’t need to know how to create automated tests. But, if that’s not thecase, now is an excellent time to learn.

Tests will save you time

Up to a certain point, ‘checking that it seems to work’ will be a satisfactorytest. In a more sophisticated application, you might have dozens of complexinteractions between components.

A change in any of those components could have unexpected consequences on theapplication’s behavior. Checking that it still ‘seems to work’ could meanrunning through your code’s functionality with twenty different variations ofyour test data to make sure you haven’t broken something - not a good useof your time.

That’s especially true when automated tests could do this for you in seconds.If something’s gone wrong, tests will also assist in identifying the codethat’s causing the unexpected behavior.

Sometimes it may seem a chore to tear yourself away from your productive,creative programming work to face the unglamorous and unexciting businessof writing tests, particularly when you know your code is working properly.

However, the task of writing tests is a lot more fulfilling than spending hourstesting your application manually or trying to identify the cause of anewly-introduced problem.

Tests don’t just identify problems, they prevent them

It’s a mistake to think of tests merely as a negative aspect of development.

Without tests, the purpose or intended behavior of an application might berather opaque. Even when it’s your own code, you will sometimes find yourselfpoking around in it trying to find out what exactly it’s doing.

Tests change that; they light up your code from the inside, and when somethinggoes wrong, they focus light on the part that has gone wrong - even if youhadn’t even realized it had gone wrong.

Tests make your code more attractive

You might have created a brilliant piece of software, but you will find thatmany other developers will refuse to look at it because it lacks tests; withouttests, they won’t trust it. Jacob Kaplan-Moss, one of Django’s originaldevelopers, says “Code without tests is broken by design.”

That other developers want to see tests in your software before they take itseriously is yet another reason for you to start writing tests.

Tests help teams work together

The previous points are written from the point of view of a single developermaintaining an application. Complex applications will be maintained by teams.Tests guarantee that colleagues don’t inadvertently break your code (and thatyou don’t break theirs without knowing). If you want to make a living as aDjango programmer, you must be good at writing tests!

Basic testing strategies

There are many ways to approach writing tests.

Some programmers follow a discipline called “test-driven development”; theyactually write their tests before they write their code. This might seemcounter-intuitive, but in fact it’s similar to what most people will often doanyway: they describe a problem, then create some code to solve it. Test-drivendevelopment formalizes the problem in a Python test case.

More often, a newcomer to testing will create some code and later decide thatit should have some tests. Perhaps it would have been better to write sometests earlier, but it’s never too late to get started.

Sometimes it’s difficult to figure out where to get started with writing tests.If you have written several thousand lines of Python, choosing something totest might not be easy. In such a case, it’s fruitful to write your first testthe next time you make a change, either when you add a new feature or fix a bug.

So let’s do that right away.

Writing our first test

We identify a bug

Fortunately, there’s a little bug in the polls application for us to fixright away: the Question.was_published_recently() method returns True ifthe Question was published within the last day (which is correct) but also ifthe Question’s pub_date field is in the future (which certainly isn’t).

Confirm the bug by using the shell to check the method on a questionwhose date lies in the future:

  1. $ python manage.py shell
  1. ...\> py manage.py shell
  1. >>> import datetime
  2. >>> from django.utils import timezone
  3. >>> from polls.models import Question
  4. >>> # create a Question instance with pub_date 30 days in the future
  5. >>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
  6. >>> # was it published recently?
  7. >>> future_question.was_published_recently()
  8. True

Since things in the future are not ‘recent’, this is clearly wrong.

Create a test to expose the bug

What we’ve just done in the shell to test for the problem is exactlywhat we can do in an automated test, so let’s turn that into an automated test.

A conventional place for an application’s tests is in the application’stests.py file; the testing system will automatically find tests in any filewhose name begins with test.

Put the following in the tests.py file in the polls application:

polls/tests.py

  1. import datetime
  2.  
  3. from django.test import TestCase
  4. from django.utils import timezone
  5.  
  6. from .models import Question
  7.  
  8.  
  9. class QuestionModelTests(TestCase):
  10.  
  11. def test_was_published_recently_with_future_question(self):
  12. """
  13. was_published_recently() returns False for questions whose pub_date
  14. is in the future.
  15. """
  16. time = timezone.now() + datetime.timedelta(days=30)
  17. future_question = Question(pub_date=time)
  18. self.assertIs(future_question.was_published_recently(), False)

Here we have created a django.test.TestCase subclass with a method thatcreates a Question instance with a pubdate in the future. We then checkthe output of was_published_recently() - which _ought to be False.

Running tests

In the terminal, we can run our test:

  1. $ python manage.py test polls
  1. ...\> py manage.py test polls

and you’ll see something like:

  1. Creating test database for alias 'default'...
  2. System check identified no issues (0 silenced).
  3. F
  4. ======================================================================
  5. FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
  6. ----------------------------------------------------------------------
  7. Traceback (most recent call last):
  8. File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
  9. self.assertIs(future_question.was_published_recently(), False)
  10. AssertionError: True is not False
  11.  
  12. ----------------------------------------------------------------------
  13. Ran 1 test in 0.001s
  14.  
  15. FAILED (failures=1)
  16. Destroying test database for alias 'default'...

What happened is this:

  • manage.py test polls looked for tests in the polls application
  • it found a subclass of the django.test.TestCase class
  • it created a special database for the purpose of testing
  • it looked for test methods - ones whose names begin with test
  • in test_was_published_recently_with_future_question it created a Questioninstance whose pub_date field is 30 days in the future
  • … and using the assertIs() method, it discovered that itswas_published_recently() returns True, though we wanted it to returnFalseThe test informs us which test failed and even the line on which the failureoccurred.

Fixing the bug

We already know what the problem is: Question.was_published_recently() shouldreturn False if its pub_date is in the future. Amend the method inmodels.py, so that it will only return True if the date is also in thepast:

polls/models.py

  1. def was_published_recently(self):
  2. now = timezone.now()
  3. return now - datetime.timedelta(days=1) <= self.pub_date <= now

and run the test again:

  1. Creating test database for alias 'default'...
  2. System check identified no issues (0 silenced).
  3. .
  4. ----------------------------------------------------------------------
  5. Ran 1 test in 0.001s
  6.  
  7. OK
  8. Destroying test database for alias 'default'...

After identifying a bug, we wrote a test that exposes it and corrected the bugin the code so our test passes.

Many other things might go wrong with our application in the future, but we canbe sure that we won’t inadvertently reintroduce this bug, because running thetest will warn us immediately. We can consider this little portion of theapplication pinned down safely forever.

More comprehensive tests

While we’re here, we can further pin down the was_published_recently()method; in fact, it would be positively embarrassing if in fixing one bug we hadintroduced another.

Add two more test methods to the same class, to test the behavior of the methodmore comprehensively:

polls/tests.py

  1. def test_was_published_recently_with_old_question(self):
  2. """
  3. was_published_recently() returns False for questions whose pub_date
  4. is older than 1 day.
  5. """
  6. time = timezone.now() - datetime.timedelta(days=1, seconds=1)
  7. old_question = Question(pub_date=time)
  8. self.assertIs(old_question.was_published_recently(), False)
  9.  
  10. def test_was_published_recently_with_recent_question(self):
  11. """
  12. was_published_recently() returns True for questions whose pub_date
  13. is within the last day.
  14. """
  15. time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
  16. recent_question = Question(pub_date=time)
  17. self.assertIs(recent_question.was_published_recently(), True)

And now we have three tests that confirm that Question.was_published_recently()returns sensible values for past, recent, and future questions.

Again, polls is a minimal application, but however complex it grows in thefuture and whatever other code it interacts with, we now have some guaranteethat the method we have written tests for will behave in expected ways.

Test a view

The polls application is fairly undiscriminating: it will publish any question,including ones whose pub_date field lies in the future. We should improvethis. Setting a pub_date in the future should mean that the Question ispublished at that moment, but invisible until then.

A test for a view

When we fixed the bug above, we wrote the test first and then the code to fixit. In fact that was an example of test-driven development, but it doesn’treally matter in which order we do the work.

In our first test, we focused closely on the internal behavior of the code. Forthis test, we want to check its behavior as it would be experienced by a userthrough a web browser.

Before we try to fix anything, let’s have a look at the tools at our disposal.

The Django test client

Django provides a test Client to simulate a userinteracting with the code at the view level. We can use it in tests.pyor even in the shell.

We will start again with the shell, where we need to do a couple ofthings that won’t be necessary in tests.py. The first is to set up the testenvironment in the shell:

  1. $ python manage.py shell
  1. ...\> py manage.py shell
  1. >>> from django.test.utils import setup_test_environment
  2. >>> setup_test_environment()

setup_test_environment() installs a template rendererwhich will allow us to examine some additional attributes on responses such asresponse.context that otherwise wouldn’t be available. Note that thismethod does not setup a test database, so the following will be run againstthe existing database and the output may differ slightly depending on whatquestions you already created. You might get unexpected results if yourTIME_ZONE in settings.py isn’t correct. If you don’t remember settingit earlier, check it before continuing.

Next we need to import the test client class (later in tests.py we will usethe django.test.TestCase class, which comes with its own client, sothis won’t be required):

  1. >>> from django.test import Client
  2. >>> # create an instance of the client for our use
  3. >>> client = Client()

With that ready, we can ask the client to do some work for us:

  1. >>> # get a response from '/'
  2. >>> response = client.get('/')
  3. Not Found: /
  4. >>> # we should expect a 404 from that address; if you instead see an
  5. >>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
  6. >>> # omitted the setup_test_environment() call described earlier.
  7. >>> response.status_code
  8. 404
  9. >>> # on the other hand we should expect to find something at '/polls/'
  10. >>> # we'll use 'reverse()' rather than a hardcoded URL
  11. >>> from django.urls import reverse
  12. >>> response = client.get(reverse('polls:index'))
  13. >>> response.status_code
  14. 200
  15. >>> response.content
  16. b'\n <ul>\n \n <li><a href="/polls/1/">What&#x27;s up?</a></li>\n \n </ul>\n\n'
  17. >>> response.context['latest_question_list']
  18. <QuerySet [<Question: What's up?>]>

Improving our view

The list of polls shows polls that aren’t published yet (i.e. those that have apub_date in the future). Let’s fix that.

In Tutorial 4 we introduced a class-based view,based on ListView:

polls/views.py

  1. class IndexView(generic.ListView):
  2. template_name = 'polls/index.html'
  3. context_object_name = 'latest_question_list'
  4.  
  5. def get_queryset(self):
  6. """Return the last five published questions."""
  7. return Question.objects.order_by('-pub_date')[:5]

We need to amend the get_queryset() method and change it so that it alsochecks the date by comparing it with timezone.now(). First we need to addan import:

polls/views.py

  1. from django.utils import timezone

and then we must amend the get_queryset method like so:

polls/views.py

  1. def get_queryset(self):
  2. """
  3. Return the last five published questions (not including those set to be
  4. published in the future).
  5. """
  6. return Question.objects.filter(
  7. pub_date__lte=timezone.now()
  8. ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now()) returns a querysetcontaining Questions whose pub_date is less than or equal to - thatis, earlier than or equal to - timezone.now.

Testing our new view

Now you can satisfy yourself that this behaves as expected by firing uprunserver, loading the site in your browser, creating Questions withdates in the past and future, and checking that only those that have beenpublished are listed. You don’t want to have to do that every single time youmake any change that might affect this - so let’s also create a test, based onour shell session above.

Add the following to polls/tests.py:

polls/tests.py

  1. from django.urls import reverse

and we’ll create a shortcut function to create questions as well as a new testclass:

polls/tests.py

  1. def create_question(question_text, days):
  2. """
  3. Create a question with the given `question_text` and published the
  4. given number of `days` offset to now (negative for questions published
  5. in the past, positive for questions that have yet to be published).
  6. """
  7. time = timezone.now() + datetime.timedelta(days=days)
  8. return Question.objects.create(question_text=question_text, pub_date=time)
  9.  
  10.  
  11. class QuestionIndexViewTests(TestCase):
  12. def test_no_questions(self):
  13. """
  14. If no questions exist, an appropriate message is displayed.
  15. """
  16. response = self.client.get(reverse('polls:index'))
  17. self.assertEqual(response.status_code, 200)
  18. self.assertContains(response, "No polls are available.")
  19. self.assertQuerysetEqual(response.context['latest_question_list'], [])
  20.  
  21. def test_past_question(self):
  22. """
  23. Questions with a pub_date in the past are displayed on the
  24. index page.
  25. """
  26. create_question(question_text="Past question.", days=-30)
  27. response = self.client.get(reverse('polls:index'))
  28. self.assertQuerysetEqual(
  29. response.context['latest_question_list'],
  30. ['<Question: Past question.>']
  31. )
  32.  
  33. def test_future_question(self):
  34. """
  35. Questions with a pub_date in the future aren't displayed on
  36. the index page.
  37. """
  38. create_question(question_text="Future question.", days=30)
  39. response = self.client.get(reverse('polls:index'))
  40. self.assertContains(response, "No polls are available.")
  41. self.assertQuerysetEqual(response.context['latest_question_list'], [])
  42.  
  43. def test_future_question_and_past_question(self):
  44. """
  45. Even if both past and future questions exist, only past questions
  46. are displayed.
  47. """
  48. create_question(question_text="Past question.", days=-30)
  49. create_question(question_text="Future question.", days=30)
  50. response = self.client.get(reverse('polls:index'))
  51. self.assertQuerysetEqual(
  52. response.context['latest_question_list'],
  53. ['<Question: Past question.>']
  54. )
  55.  
  56. def test_two_past_questions(self):
  57. """
  58. The questions index page may display multiple questions.
  59. """
  60. create_question(question_text="Past question 1.", days=-30)
  61. create_question(question_text="Past question 2.", days=-5)
  62. response = self.client.get(reverse('polls:index'))
  63. self.assertQuerysetEqual(
  64. response.context['latest_question_list'],
  65. ['<Question: Past question 2.>', '<Question: Past question 1.>']
  66. )

Let’s look at some of these more closely.

First is a question shortcut function, create_question, to take somerepetition out of the process of creating questions.

test_no_questions doesn’t create any questions, but checks the message:“No polls are available.” and verifies the latest_question_list is empty.Note that the django.test.TestCase class provides some additionalassertion methods. In these examples, we useassertContains() andassertQuerysetEqual().

In test_past_question, we create a question and verify that it appears inthe list.

In test_future_question, we create a question with a pub_date in thefuture. The database is reset for each test method, so the first question is nolonger there, and so again the index shouldn’t have any questions in it.

And so on. In effect, we are using the tests to tell a story of admin inputand user experience on the site, and checking that at every state and for everynew change in the state of the system, the expected results are published.

Testing the DetailView

What we have works well; however, even though future questions don’t appear inthe index, users can still reach them if they know or guess the right URL. Sowe need to add a similar constraint to DetailView:

polls/views.py

  1. class DetailView(generic.DetailView):
  2. ...
  3. def get_queryset(self):
  4. """
  5. Excludes any questions that aren't published yet.
  6. """
  7. return Question.objects.filter(pub_date__lte=timezone.now())

And of course, we will add some tests, to check that a Question whosepub_date is in the past can be displayed, and that one with a pub_datein the future is not:

polls/tests.py

  1. class QuestionDetailViewTests(TestCase):
  2. def test_future_question(self):
  3. """
  4. The detail view of a question with a pub_date in the future
  5. returns a 404 not found.
  6. """
  7. future_question = create_question(question_text='Future question.', days=5)
  8. url = reverse('polls:detail', args=(future_question.id,))
  9. response = self.client.get(url)
  10. self.assertEqual(response.status_code, 404)
  11.  
  12. def test_past_question(self):
  13. """
  14. The detail view of a question with a pub_date in the past
  15. displays the question's text.
  16. """
  17. past_question = create_question(question_text='Past Question.', days=-5)
  18. url = reverse('polls:detail', args=(past_question.id,))
  19. response = self.client.get(url)
  20. self.assertContains(response, past_question.question_text)

Ideas for more tests

We ought to add a similar get_queryset method to ResultsView andcreate a new test class for that view. It’ll be very similar to what we havejust created; in fact there will be a lot of repetition.

We could also improve our application in other ways, adding tests along theway. For example, it’s silly that Questions can be published on the sitethat have no Choices. So, our views could check for this, and exclude suchQuestions. Our tests would create a Question without Choices andthen test that it’s not published, as well as create a similar Questionwith Choices, and test that it is published.

Perhaps logged-in admin users should be allowed to see unpublishedQuestions, but not ordinary visitors. Again: whatever needs to be added tothe software to accomplish this should be accompanied by a test, whether youwrite the test first and then make the code pass the test, or work out thelogic in your code first and then write a test to prove it.

At a certain point you are bound to look at your tests and wonder whether yourcode is suffering from test bloat, which brings us to:

When testing, more is better

It might seem that our tests are growing out of control. At this rate there willsoon be more code in our tests than in our application, and the repetitionis unaesthetic, compared to the elegant conciseness of the rest of our code.

It doesn’t matter. Let them grow. For the most part, you can write a testonce and then forget about it. It will continue performing its useful functionas you continue to develop your program.

Sometimes tests will need to be updated. Suppose that we amend our views so thatonly Questions with Choices are published. In that case, many of ourexisting tests will fail - telling us exactly which tests need to be amended tobring them up to date, so to that extent tests help look after themselves.

At worst, as you continue developing, you might find that you have some teststhat are now redundant. Even that’s not a problem; in testing redundancy isa good thing.

As long as your tests are sensibly arranged, they won’t become unmanageable.Good rules-of-thumb include having:

  • a separate TestClass for each model or view
  • a separate test method for each set of conditions you want to test
  • test method names that describe their function

Further testing

This tutorial only introduces some of the basics of testing. There’s a greatdeal more you can do, and a number of very useful tools at your disposal toachieve some very clever things.

For example, while our tests here have covered some of the internal logic of amodel and the way our views publish information, you can use an “in-browser”framework such as Selenium to test the way your HTML actually renders in abrowser. These tools allow you to check not just the behavior of your Djangocode, but also, for example, of your JavaScript. It’s quite something to seethe tests launch a browser, and start interacting with your site, as if a humanbeing were driving it! Django includes LiveServerTestCaseto facilitate integration with tools like Selenium.

If you have a complex application, you may want to run tests automaticallywith every commit for the purposes of continuous integration, so thatquality control is itself - at least partially - automated.

A good way to spot untested parts of your application is to check codecoverage. This also helps identify fragile or even dead code. If you can’t testa piece of code, it usually means that code should be refactored or removed.Coverage will help to identify dead code. SeeIntegration with coverage.py for details.

Testing in Django has comprehensiveinformation about testing.

What’s next?

For full details on testing, see Testing in Django.

When you’re comfortable with testing Django views, readpart 6 of this tutorial to learn aboutstatic files management.