This tutorial begins where Tutorial 4 left off. We’ve built a Web-poll application, and we’ll now create some automated tests for it.
测试是检查你的代码能否正常工作的简单程序
Testing operates at different levels. Some tests might apply to a tiny detail
(does a particular model method return values as expected?) while others
examine the overall operation of the software (does a sequence of user inputs
on the site produce the desired result?). That’s no different from the kind of
testing you did earlier in Tutorial 2, using the
shell
to examine the behavior of a method, or running the
application and entering data to check how it behaves.
What’s different in automated tests is that the testing work is done for you by the system. You create a set of tests once, and then as you make changes to your app, you can check that your code still works as you originally intended, without having to perform time consuming manual testing.
So why create tests, and why now?
你可能觉得自己刚刚学习Python / Django已经足够了,再去学习其他的东西也需要付出巨大的努力而且没有必要 毕竟,我们的民意调查应用程序现在已经活蹦乱跳了,将时间花在自动化测试,还不如改进它,让他更好的功能工作 If creating the polls application 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 the case, now is an excellent time to learn.
在某种程度上,“检查看起来似乎正常工作”将是一个令人满意的测试。 In a more sophisticated application, you might have dozens of complex interactions between components.
A change in any of those components could have unexpected consequences on the application’s behavior. Checking that it still ‘seems to work’ could mean running through your code’s functionality with twenty different variations of your test data just to make sure you haven’t broken something - not a good use of 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 code that’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 business of writing tests, particularly when you know your code is working properly.
However, the task of writing tests is a lot more fulfilling than spending hours testing your application manually or trying to identify the cause of a newly-introduced problem.
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 be rather opaque. Even when it’s your own code, you will sometimes find yourself poking around in it trying to find out what exactly it’s doing.
测试改变了这一点;它们使你的代码内部变得明晰,当出现问题时,他们把注意力集中在出错的部分 - 即使你甚至没有意识到出错了。
You might have created a brilliant piece of software, but you will find that many other developers will simply refuse to look at it because it lacks tests; without tests, they won’t trust it. Jacob Kaplan-Moss, one of Django’s original developers, says “Code without tests is broken by design.”
That other developers want to see tests in your software before they take it seriously is yet another reason for you to start writing tests.
The previous points are written from the point of view of a single developer maintaining an application. Complex applications will be maintained by teams. Tests guarantee that colleagues don’t inadvertently break your code (and that you don’t break theirs without knowing). If you want to make a living as a Django programmer, you must be good at writing tests!
编写测试有很多方法。
一些程序员遵循“测试驱动开发”的规定;他们在编写代码之前先去编写测试。 这看起来可能与直觉相反,但实际上它就是大多数所做的:人们先描述一个问题,然后创建一些代码来解决问题。 测试驱动的开可以用Python测试用例将这个问题形式化。
More often, a newcomer to testing will create some code and later decide that it should have some tests. 也许早些时候写一些测试会更好,但是什么时候开始都不晚。
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 to test might not be easy. In such a case, it’s fruitful to write your first test the next time you make a change, either when you add a new feature or fix a bug.
所以我们现在就去做。
幸运的是polls
应用中有一个小bug让我们可以马上修复他 : 如果Question.是在最后一天发布的,Question.was_published_recently()
函数返回True
(实际是对的),但是如果 Question
是未来几天后发布的 Question
的pub_date
,它还是返回True(实际并不应该)
To check if the bug really exists, using the Admin create a question whose date
lies in the future and check the method using the shell
:
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True
Since things in the future are not ‘recent’, this is clearly wrong.
What we’ve just done in the shell
to test for the problem is exactly
what 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’s
tests.py
file; the testing system will automatically find tests in any file
whose name begins with test
.
Put the following in the tests.py
file in the polls
application:
import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Question
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
was_published_recently() returns False for questions whose pub_date
is in the future.
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
What we have done here is created a django.test.TestCase
subclass
with a method that creates a Question
instance with a pub_date
in the
future. We then check the output of was_published_recently()
- which
ought to be False.
In the terminal, we can run our test:
$ python manage.py test polls
and you’ll see something like:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
What happened is this:
python manage.py test polls
looked for tests in the polls
applicationdjango.test.TestCase
classtest
test_was_published_recently_with_future_question
it created a Question
instance whose pub_date
field is 30 days in the futureassertIs()
method, it discovered that its
was_published_recently()
returns True
, though we wanted it to return
False
The test informs us which test failed and even the line on which the failure occurred.
We already know what the problem is: Question.was_published_recently()
should
return False
if its pub_date
is in the future. Amend the method in
models.py
, so that it will only return True
if the date is also in the
past:
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
and run the test again:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
After identifying a bug, we wrote a test that exposes it and corrected the bug in the code so our test passes.
Many other things might go wrong with our application in the future, but we can be sure that we won’t inadvertently reintroduce this bug, because simply running the test will warn us immediately. We can consider this little portion of the application pinned down safely forever.
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 had
introduced another.
Add two more test methods to the same class, to test the behavior of the method more comprehensively:
def test_was_published_recently_with_old_question(self):
"""
was_published_recently() returns False for questions whose pub_date
is older than 1 day.
"""
time = timezone.now() - datetime.timedelta(days=1, seconds=1)
old_question = Question(pub_date=time)
self.assertIs(old_question.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
was_published_recently() returns True for questions whose pub_date
is within the last day.
"""
time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
recent_question = Question(pub_date=time)
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 simple application, but however complex it grows in the
future and whatever other code it interacts with, we now have some guarantee
that the method we have written tests for will behave in expected ways.
The polls application is fairly undiscriminating: it will publish any question,
including ones whose pub_date
field lies in the future. We should improve
this. Setting a pub_date
in the future should mean that the Question is
published at that moment, but invisible until then.
When we fixed the bug above, we wrote the test first and then the code to fix it. In fact that was a simple example of test-driven development, but it doesn’t really matter in which order we do the work.
In our first test, we focused closely on the internal behavior of the code. For this test, we want to check its behavior as it would be experienced by a user through a web browser.
Before we try to fix anything, let’s have a look at the tools at our disposal.
Django provides a test Client
to simulate a user
interacting with the code at the view level. We can use it in tests.py
or even in the shell
.
We will start again with the shell
, where we need to do a couple of
things that won’t be necessary in tests.py
. The first is to set up the test
environment in the shell
:
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()
installs a template renderer
which will allow us to examine some additional attributes on responses such as
response.context
that otherwise wouldn’t be available. Note that this
method does not setup a test database, so the following will be run against
the existing database and the output may differ slightly depending on what
questions you already created. You might get unexpected results if your
TIME_ZONE
in settings.py
isn’t correct. If you don’t remember setting
it earlier, check it before continuing.
Next we need to import the test client class (later in tests.py
we will use
the django.test.TestCase
class, which comes with its own client, so
this won’t be required):
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
With that ready, we can ask the client to do some work for us:
>>> # get a response from '/'
>>> response = client.get('/')
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n <ul>\n \n <li><a href="/polls/1/">What's up?</a></li>\n \n </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>
民意调查列表显示了尚未发布的民意调查(即pub_date
是未来的民意调查)。 Let’s fix that.
In Tutorial 4 we introduced a class-based view,
based on ListView
:
class IndexView(generic.ListView):
template_name = 'polls/index.html'
context_object_name = 'latest_question_list'
def get_queryset(self):
"""Return the last five published questions."""
return Question.objects.order_by('-pub_date')[:5]
我们需要修改get_queryset()
方法,以便通过将其与timezone.now()
进行比较来检查日期。 First we need to add
an import:
from django.utils import timezone
and then we must amend the get_queryset
method like so:
def get_queryset(self):
"""
Return the last five published questions (not including those set to be
published in the future).
"""
return Question.objects.filter(
pub_date__lte=timezone.now()
).order_by('-pub_date')[:5]
Question.objects.filter(pub_date__lte=timezone.now())
r返回一个查询集,包括pub_date
小于或者等于timezone.now
的Question
Now you can satisfy yourself that this behaves as expected by firing up the
runserver, loading the site in your browser, creating Questions
with dates
in the past and future, and checking that only those that have been published
are listed. 你不肯定不想每次修改可能与之相关的代码都要这样做 -所以让我们基于以上 shell
会话内容,再编写一个测试.
Add the following to polls/tests.py
:
from django.urls import reverse
and we’ll create a shortcut function to create questions as well as a new test class:
def create_question(question_text, days):
"""
Create a question with the given `question_text` and published the
given number of `days` offset to now (negative for questions published
in the past, positive for questions that have yet to be published).
"""
time = timezone.now() + datetime.timedelta(days=days)
return Question.objects.create(question_text=question_text, pub_date=time)
class QuestionIndexViewTests(TestCase):
def test_no_questions(self):
"""
If no questions exist, an appropriate message is displayed.
"""
response = self.client.get(reverse('polls:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_past_question(self):
"""
Questions with a pub_date in the past are displayed on the
index page.
"""
create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_future_question(self):
"""
Questions with a pub_date in the future aren't displayed on
the index page.
"""
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_future_question_and_past_question(self):
"""
Even if both past and future questions exist, only past questions
are displayed.
"""
create_question(question_text="Past question.", days=-30)
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_two_past_questions(self):
"""
The questions index page may display multiple questions.
"""
create_question(question_text="Past question 1.", days=-30)
create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question 2.>', '<Question: Past question 1.>']
)
Let’s look at some of these more closely.
第一个是Question的快捷函数 create_question
, 将重复创建Question的过程封装在一起
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 additional
assertion methods. In these examples, we use
assertContains()
and
assertQuerysetEqual()
.
In test_past_question
, we create a question and verify that it appears in
the list.
In test_future_question
, we create a question with a pub_date
in the
future. The database is reset for each test method, so the first question is no
longer 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 input and user experience on the site, and checking that at every state and for every new change in the state of the system, the expected results are published.
DetailView
¶一切都运行很好;然而,即使未来的问题没有出现在索引中,如果用户知道或猜测了正确的URL,用户仍然可以访问它们。 所以我们要给DetailView
,添加一个约束:
class DetailView(generic.DetailView):
...
def get_queryset(self):
"""
Excludes any questions that aren't published yet.
"""
return Question.objects.filter(pub_date__lte=timezone.now())
当然,我们将添加一些测试,以检查pub_date
是过去的Question
可以显示,以及pub_date
在未来不显示
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
"""
The detail view of a question with a pub_date in the future
returns a 404 not found.
"""
future_question = create_question(question_text='Future question.', days=5)
url = reverse('polls:detail', args=(future_question.id,))
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_past_question(self):
"""
The detail view of a question with a pub_date in the past
displays the question's text.
"""
past_question = create_question(question_text='Past Question.', days=-5)
url = reverse('polls:detail', args=(past_question.id,))
response = self.client.get(url)
self.assertContains(response, past_question.question_text)
我们应该为ResultsView
添加一个类似get_queryset
方法,并为该视图创建一个新的测试类。 It’ll be very similar to what we have
just created; in fact there will be a lot of repetition.
We could also improve our application in other ways, adding tests along the
way. For example, it’s silly that Questions
can be published on the site
that have no Choices
. So, our views could check for this, and exclude such
Questions
. Our tests would create a Question
without Choices
and
then test that it’s not published, as well as create a similar Question
with Choices
, and test that it is published.
Perhaps logged-in admin users should be allowed to see unpublished
Questions
, but not ordinary visitors. Again: whatever needs to be added to
the software to accomplish this should be accompanied by a test, whether you
write the test first and then make the code pass the test, or work out the
logic 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 your code is suffering from test bloat, which brings us to:
It might seem that our tests are growing out of control. At this rate there will soon be more code in our tests than in our application, and the repetition is 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 test once and then forget about it. It will continue performing its useful function as you continue to develop your program.
Sometimes tests will need to be updated. Suppose that we amend our views so that
only Questions
with Choices
are published. In that case, many of our
existing tests will fail - telling us exactly which tests need to be amended to
bring 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 tests that are now redundant. Even that’s not a problem; in testing redundancy is a good thing.
As long as your tests are sensibly arranged, they won’t become unmanageable. Good rules-of-thumb include having:
TestClass
for each model or viewThis tutorial only introduces some of the basics of testing. There’s a great deal more you can do, and a number of very useful tools at your disposal to achieve some very clever things.
For example, while our tests here have covered some of the internal logic of a
model 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 a
browser. These tools allow you to check not just the behavior of your Django
code, but also, for example, of your JavaScript. It’s quite something to see
the tests launch a browser, and start interacting with your site, as if a human
being were driving it! Django includes LiveServerTestCase
to facilitate integration with tools like Selenium.
If you have a complex application, you may want to run tests automatically with every commit for the purposes of continuous integration, so that quality control is itself - at least partially - automated.
A good way to spot untested parts of your application is to check code coverage. This also helps identify fragile or even dead code. If you can’t test a piece of code, it usually means that code should be refactored or removed. Coverage will help to identify dead code. See Integration with coverage.py for details.
Testing in Django has comprehensive information about testing.
For full details on testing, see Testing in Django.
When you’re comfortable with testing Django views, read part 6 of this tutorial to learn about static files management.
Jan 17, 2018