第一个Django应用[part5]
介绍自动测试
一、什么是自动测试?
就是设置好一套测试条件,当对应用做了调整后,可以自动检查功能是不是还能满足初始设定,而不需要手动的进一逐一测试。
二、为什么需要创建测试?
1、测试会节省你的时间
测试可以保证应用能正常工作,在复杂的应用中经常时数十个复杂的组件进行交互。在这些组件中做一点点的修改都有可能产生未知的连锁反应。在使用数十种不同的测试数据进行测试,以保证应用的正常运行,这不正是节省了时间。如果出现了问题,测试还可以帮助识别导致意外行为的代码。
2、测试不仅仅可以指出问题,还可以防止问题发生
没有经过测试,应用的行为和目的让人迷惑,即使这是你自己写的程序,你也要花费一些时间去理解它要干什么。
3、测试使代码更有吸引力
你可能开发出了一个很棒的软件,但你会发现许多其他开发人员拒绝查看它,因为它缺乏测试;没有测试,他们不会相信它。其他开发人员在认真对待软件之前希望看到软件中的测试,这也是您开始编写测试的另一个原因。
4、测试可以让团队协作
前面的几点是从维护应用程序的单个开发人员的角度出发的。复杂的应用程序将由团队维护。测试可以保证同事不会无意中破坏你的代码(你也不会在不知情的情况下破坏他们的代码)。如果你想成为Django程序员,就必须擅长编写测试!
基本测试策略
有些程序员遵循“测试驱动开发”(test-driven development)的原则;他们实际上是在写代码之前写测试。这可能看起来违反直觉,但实际上这类似于大多数人经常做的事情:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发用Python测试用例将问题形式化。
写第一个测试
$ python manage.py 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
建立一个测试暴露问题
通常,测试放在应用程序的tests.py文件中;测试系统会自动在任何文件名以test开头的文件中查找tests。写测试文件polls/tests.py:
import datetime
from django.test import TestCase
from django.utils import timezone
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)
这创建了一个django.test.TestCase子类,其包含了一个pub_date日期在未来的Question的实例。我们检查was_published_recently()的输出——它会输出False.
运行测试
在终端里输入:
$ python manage.py test polls
之后会看到类似的输出:
Found 1 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). F ====================================================================== F ====================================================================== FAIL: test_was_published_recently_with_futuren_question (polls.tests.QuestionModelTests) ---------------------------------------------------------------------- Traceback (most recent call last): File "E:\practices\python\Django_test\mysite\polls\tests.py", line 10, in test_was_published_recently_with_futuren_question self.assertIs(future_question.was_published_recently(),False) AssertionError: True is not False ---------------------------------------------------------------------- Ran 1 test in 0.002s FAILED (failures=1) Destroying test database for alias 'default'...
其间发生了什么:
- manage.py test polls在polls应用中查找测试。
- 它找到一个django.test.TestCase类的子类。
- 它创建一个特定的数据库用于测试目的。
- 查找测试方法——以test开头命名的方法。
- 在test_was_published_recently_with_future_question中它建立一个Question实例,这个实例的pub_date字段是30天后。
- 使用assertIs()方法,它发现was_published_recently()返回 True,虽然我们希望它返回False。
修复问题
现在已经知道问题发生在Question.was_published_recently(),当pub_date字段的日期在未来时应该返回False。修改models.py中的方法。所以:
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
在未来,我们的应用程序可能会出现许多其他问题,但我们可以肯定不会无意中再次引入这个错误,因为运行测试会立即发出警告。我们可以认为应用程序的这一小部分永远是安全的。
更全面的测试
实际上在修复一个BUG的同时很有可能会建立另一个BUG。添加更多的测试方法到同一个类中,为了更全面的测试这个方法:
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)
现在对于Question模型方法已经有三条测试方案了,随着应用功能的增加会逐渐加入新的测试方案。
测试视图
在我们的第一个测试中,我们密切关注代码的内部行为。对于这个测试,我们希望检查它的行为,就像用户通过web浏览器体验到它一样。在我们尝试解决任何问题之前,让我们看看我们可以使用的工具。
Django测试客户端
Django提供了一个仿真程序Client用于模拟用户在视图层的交互式访问。我们可以使用它在tests.py中,甚至在shell中进行测试。
再次从shell开始,在那里我们需要做一些在tests.py中不是必须做的事情——设置测试环境:
$ python manage.py shell >>> from django.test.utils import setup_test_environment >>> setup_test_environment()
setup_test_environment()安装一个模版渲染器,它允许我们检查响应上的一些额外属性,如:response.context,如果没有模版渲染器,就没办法做这些检查。注意,这个方法并不设置测试数据库,所以接下来的操作依赖于现存的数据库,并且输出会依赖于Question库中已经建立的记录。如果settings.py中的TIME_ZONE没有设置正确,可能会有未知结果。
下面导入test client类(在之后的tests.py将使用django.test.TestCase类,它有自己的视图层客户端,所以这里并不需要):
>>> from django.test import Client >>> # create an instance of the client for our use >>> client = Client()
现在已经准备好了,可以让client为我们做一些事情了:
>>> # 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?>]>
改进我们的视图
polls的列表显示了尚未公布的名单,我们要对get_queryset()方法进行改进,让它也对比timezone.now(),首先在polls/views.py添加头文件:
from django.utils import timezone
然后修改get_queryset()方法:
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())返回一个查询集,它包含 了Question表中pub_date小于等于timezone.now()的记录,也就是早于或等于timezone.now()的时间。
测试我们的视图
现在可以打开浏览器查看我们修改的结果,但是如果不想每次都打开浏览器验证的话,那就需要我们的测试程序了,修改polls/tests.py:
from django.urls import reverse
然后我们创建一个快捷函数来建立question记录,还有一个新的测试类:
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.
"""
question = create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[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.
"""
question = 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],
)
def test_two_past_questions(self):
"""
The questions index page may display multiple questions.
"""
question1 = create_question(question_text="Past question 1.", days=-30)
question2 = create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[question2, question1],
)
现在看一下上面的测试过程:
首先是question的快捷函数:create_question,用于在创建question时减少一些重复。
test_no_questions并不创建任何question,但是检查消息“No polls are available.”并验证latest_question_list是空。注意:django.test.TestCase类提供额外的断言方法。在上面的例子中使用了assertContains()和assertQuerySetEqual().
在test_past_question中,创建一个question并验证它是否出现在列表中。
在test_future_question中,以未来时间创建一个question。每个测试方法都会重置数据库,因此第一个问题不再存在,因此索引中也不应该有任何问题。
实际上,我们用这些测试来讲述一个管理员输入和网站用户体验的故事,并检查在系统状态的每一个新变化时,是否发布了预期的结果。
测试DetailView
之前的工作没什么问题;然而,即使未来的question不出现在索引中,用户仍然可以访问它们,只要他们知道或猜对了URL。所以我们需要给DetailView添加一个类似的约束:
polls/views.py
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为未来的Question:
poll/tests.py
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)