编写你的第一个 Django app,第五部分(Page 10)转载请注明链接地址
我们继续建设我们的 Web-poll 应用,本节我们会为它创建一些自动测试。
介绍自动测试
什么是自动测试
测试是简单代码可用性的一个常规操作。
测试分为不同的级别。有一些此时适用于一些很小的细节(特定的模型方法是否返回预期的值),而另外一些则覆盖整个软件操作(用户在站点行输入的一个序列是否会产生预期的结果)。这和你之前在第二节(part 2,page7)中做的测试没有什么区别,使用shell
(这里少一个链接)去检查方法的行为,或者运行应用并输入数据来检查他的行为是怎样的。
自动测试的不同之处在于测试工作是由系统完成的。一组测试你只需创建一次,然后对你的app做一些更改,可以检查你的代码是否像你预期的那样运行,而无需执行耗时的手动测试。
为什么你需要创建测试
那么为什么要创建测试呢,为什么要现在呢?
你可能觉得已经对Python/Django有了足够的了解,在去学习其他的东西会既费时费力又没有必要。毕竟,我们的polls应用现在已经很好的工作了;去创建自动测试并不会让它的更好的工作。如果你学习django只是为了创建polls应用,那么你不需要知道如何创建自动测试。但如果不是这样,现在就是一个很好的额学习机会,
测试会节省你的时间
在某种程度上,“确认程序是否在工作”是一件让人满意的事情。在更复杂的应用中,你可能会遇到几十个组件间复杂的通信。
这些组件的任何一点修改都可能导致程序行为出现不可预料的结果。确认“程序是否工作” 可能意味着你的代码的功能在使用测试数据中二十种不同的变量时可以运行通过,而这只是确保你没有造成破坏 —— 不能好好利用你的时间。
尤其是自动化测试可以在几秒内为你做到这些。如果有些东西被破坏,测试也会帮助找导致异常行为的代码。
有时候,你可能会觉得将自己从富有成效的创造性编程工作中带到去面对单调乏味的测试编写工作是一件苦差事,特别是你知道你的代码工作正常时。
然而,相比花费数小时时间手动测试你的程序或者尝试找出新引进的问题的原因,编写测试任务还是令人更愉悦的。
测试不只是发现问题,他们还防止发生问题
认为测试是开发工作中消极的部分是不对的。
没有测试,应用程序的目的或意图可能会变得模糊。即使是你自己的代码,有时你也会不知道代码真正做了什么。
测试改变了这个情况;它让你的代码内部变得清晰,当遇到错误的时,它可以明确的指出那一部分代码出现了错误 —— 即使你没有意识到代码出错了。
测试让你的代码更有吸引力
你可能已经编写过大名鼎鼎的软件,但是你会发现许多其他的开发者会因为它缺少测试而拒绝去看它; 没有测试,他们就不会相信它。Jacob Kaplan-Moss, Django的最早开发者之一,说过“没有测试的代码是设计上的错误” 。
另一个你要编写测试的原因是。其他的开发者在研究你的代码前想在你的软件中看看相关的测试。
测试有助于团队合作
前面的观点是从单个开发人员来维护一个程序的角度来写的。复杂的应用会由团队来维护。测试可以保证你的同事不是无意中破坏你的代码(并且在不知情的情况下,你不会破坏他们代码)。如果你想以Django程序员的身份谋生,那么你必须擅长编写测试。
基本的测试策略
有很多种编写测试的方法。
一些程序员遵循 “test-driven development”(测试驱动开发)
(这里少一个链接)的规则。 实际上他们是在编写代码之前编写测试,这似乎和直觉相反,但事实上,它与大多数人通常做的事情类似:他们先描述一个问题,然后写一些代码去解决它。测试驱动开发可以用Python测试用例简单的形式化这个问题。
更多时候,一个测试新手会先创建一些代码,之后再决定应有的一些测试。也许这样比早些测试要好一些,但只要开始就绝不算晚。
有时候很难决定要从哪里开始编写测试。如果你已经写了几千行Python代码,选出一些代码去做测试可能不是很容易。这种情况下,在你下一次要做一些更改,添加一个新特性或修复一个bug时,去写你的第一个测试会有很有成效。
所以我们马上开始做吧。
编写我们的第一个测试
找出一个bug
幸好,在polls应用中有一个我们马上可以修复的小bug: 如果Question
在最近一天发布,那么Question.was_published_recently()
返回 True
(这是正确的),但如果Question
的 pub_date
字段是未来的时间,返回结果还是True,那么肯定就不对了。
检查这个bug是否真的存在,使用Admin创建一个日期是未来的 Question,并在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
未来的事情不是最近发生的,这显然是错误的。
创建一个测试来暴露这个bug
我们在shell(这里少一个链接)中针对这个问题做的测试正说明我们在可以在自动测试中做些什么,所以让我们把它变成一个自动测试。
一个应用的测试用例按照惯例会放在应用的tests.py
文件中;测试系统会自动在任何以test开头的文件中找到测试用例。
把下面的内容放入polls
应用中的 test.py
文件:
# polls/tests.py
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)
我们这里做的就是创建了一个 django.test.TestCase
(这里少一个链接)子类,它有一个创建了有未来 pub_date
的 Question
实例的方法。然后我们检查was_published_recently()
的输出 —— 输出应该是False。
运行测试
我们可以在终端中运行我们的测试用例:
$ python manage.py test polls
你会看到类似下面的内容:
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'...
这里发生了什么:
-
python manage.py test polls
在polls 应用中找到测试用例 - 它找到了一个
django.test.TestCase
(这里少一个链接)类的子类 - 它为测试创建了一个特定的数据库
- 它查找测试方法 —— 一个以
test
开头的方法 - 在
test_was_published_recently_with_future_question
中,它创建一个pub_date
字段是未来的30天的Question
实例。 - …… 使用
assertIs()
方法,它发现was_published_recently()
返回 True,尽管我们想要它返回False。
测试用例通知我们那个测试失败了,甚至是导致失败的是哪一行。
修复bug
我们已经知道问题出在哪里:如果pub_date
是未来的时间,那么Question.was_published_recently()
应该返回False
。修改 models.py
中的方法,这样只有pub_date
是过去的时间才会返回 True
。
# polls/models.py
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
再次运行测试用例:
polls/models.py
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
找到一个bug后,我们写了一个测试用例来揭露它,并在代码中纠正它,所以我们的测试通过了。
以后,我们的应用可能会出现很多错误,但是我们可以确认,我们不会再出现这个bug,因为简单的运行一下这个测试用例就会马上提醒我们。我们可以认为应用的这一小部分永远安全了。
更全面的测试
到了这里,我们可以进一步确认was_published_recently()
方法;事实上,如果我们在修复一个bug的额时候引入了其他的bug,这会非常的尴尬。
在同一个类中添加两个其他的测试方法,来更全面的测试方法的行为:
polls/tests.py
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.was_published_recently()
返回正确的值,无论时间是过去、最近还是未来。
再说一次,polls是个简单的应用,但是不管将来它变得多复杂,与它有交互的其他代码有多少,我们现在可以保证,我们为它编写测试的那个方法会按我们的预期工作。
测试一个视图
polls应用没有区分能力:它会发布任何一个Question,包括pub_date
字段的值未来时间的。我们应该改善这个问题。设置pub_date
为未来时间,应该意味着 Question 在未来发布,但直到那个时间才可以看见。
视图上的一个测试
当我们修复上面的bug时,我们先写测试用例然后编写代码修复它。事实上,这是一个简单的测试驱动开发的简单例子,但是我们做这些工作的顺序并不重要。
我们的第一个测试用例中,我们密切关注代码的内部行为。在这个测试中,我们想去通过浏览器从用户的角度去检查它的行为。
在我们尝试修复bug前,让我们先看看我们可以使用的工具。
Django测试客户端
Django提供了一个测试客户端
()这里少一个链接,来模拟一个用户和代码的交互行为。我们可以在test.py
中使用,甚至是在shell
(这里少一个链接)中:
我们会再次从shell
(这里少一个链接)开始,这里我们需要做许多在test.py
中不需要去做的事情。首先是在shell
(这里少一个链接)中设置测试环境:
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()
(这里少一个链接)会安装一个模板渲染器,让我们可以检查respones中一些额外的属性,比如response.context
,反则是访问不到的。注意,这个方法不会设置数据库,所以,下面的命令会运行在一个已存在的数据库上,并且输出的内容也会因为你创建的问题不同而不同。如果setting.py
中你的TIME_ZONE
设置的不正确,你可能会得到一个意想不到的结果。如果你不记得之前有没有设置它,那么在继续之前先检查一下。
下一步我们需要导入测试客户端类(之后在test.py
,我们会用到[ django.test.TestCase
这里少一个链接)](),它自带客户端,所以不需要导入这个类):
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> 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?>]>
改进我们的视图
投票列表中展示尚未发布的投票(即,这是投票的pub_date
是在未来)。我们来修复它。
在第四部分(Page 9)中,我们介绍了一个基于ListView
类的视图(基于类的视图):
# polls/views.py
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()
做日期比较。首先我们需要一个导入:
# polls/views.py
from django.utils import timezone
然后我们必须像下面一样修改get_queryset
方法:
# polls/views.py
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())
返回一个pub_date
小于或等于timezone.now
的Question
的 queryset(查询集合)
测试我们的新视图
现在你可以对这些自己的符合预期的代码满意了,通过启动服务器、在浏览器加载站点、创建日期在过去或未来的Question
,检查只有被发布的Question
会被展示出来。你不想每次做的任何更改都影响到这一点 —— 因此,让我们创建一个测试用例,基于我们上面的 shell
(这里少一个链接) 会话。
在polls/tests.py
中加入如下内容:
# polls/tests.py
from django.urls import reverse
我们会创建一个用来创建Question的快捷函数以及一个测试类:
# polls/tests.py
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.>']
)
让我们更仔细的看一下上面的一些内容。
首先一个question快捷函数,create_question
,封装了重复创建问题的过程。
test_no_questions
不创建任何问题,但检查消息:“No polls are available.” 和验证latest_question_list
是空的。注意 django.test.TestCase
(这里少一个链接)类提供一些额外的断言方法。在这里的例子中,我们使用 assertContains()
(这里少一个链接) and assertQuerysetEqual()
(这里少一个链接).
在test_past_question
中,我们创建一个问题,并验证它是否出现在列表中。
在test_future_question
中,我们创建一个pub_date
是未来时间的问题。每个测试方法都会重置数据库,所以第一个问题不会在那里,所以索引中不会有任何问题。
以此类推。实际上,我们使用测试用例讲述了站点上admin输入和用户体验的故事,并检查每一个状态和系统状态中的每一个改变,确认发布的结果是预期结果。
测试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
在未来的则不会被显示:
# polls/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)
更多关于测试的想法
我们应该给ResultsView
添加一个类似get_queryset
的方法,并为这个视图添加一个新的测试类。它会和我们刚刚创建的非常相似;事实上,他们会有许多重复。
我们还可以通过其他方式改进我们的应用,沿途添加测试(我理解的是在改进的过程中添加)。例如,站点上的Questions
可以在没有Choice
的情况下被发布,就显得很愚蠢。所以,我们的视图可以针对这个情况做检查,并拒绝这样的Question
。我们的测试用例将创建一个没有Choice
的Question
,然后测试它不会被发布出来,也会创建一个类似的有Choicee
的Question
,然后测试它可以被发布出来。
也许登陆admin的用户应该看到未发布的Question
,但是普通的浏览者不行。在说一次,任何需要添加到软件中来完成这个需求的代码,都要有相应的测试用例。无论你是先编写测试然后编写可以通过测试的代码,或者先解决代码中的逻辑,然后编写测试去解决它。
在某种程度上,你必定会去查看你的测试用例,并思考你的测试代码是否过于臃肿,这带给我们的是:
测试越多越好
这样可能看起来我们的测试代码的增长超出了控制。以这个速度,要不了多久,测试的代码就会超过应用的代码。相比我们其他优雅简洁的代码,重复显得很丑陋。
没有关系。 让他们继续增长。在很大程度上,你可以写一个单次测试然后忘记它。随着你继续开发你的程序,它会继续发挥它有用的功能。
有时候测试用来需要被更新。假设我们改正了我们的视图,这样只有有Choice
的Question
才可以被发布。在这种情况下,许多已有的测试用例会失效 —— 确切的告诉我们那些测试用例需要被修改,以使其保持最新,所以从某种程度上将,测试可以照顾好它们自己。
最坏的情况是,你在开发过程中,可能会发现有的一些测试用例现在变得多余。即使那样也不是个问题;对测试来说,冗余是一件好事情,
只要你的测试安排的合理,他们就不会变得难以管理。从好的经验上来说包括:
- 每个模型或试图有单独的
TestClass
- 每一个你想要测试的条件集合都有一个单独的测试方法
- 测试方法的名字可以描述他们的功能
进一步测试
本节教程仅仅介绍了一些基本的测试知识。你可以做更多的事情,有许多有用的工具可以用来让你来实现一些非常聪明的想法。
例如,虽然我们这里的测试覆盖了一些模型的内部逻辑和视图发布信息的方式,你可以使用类似Selenium
的浏览器框架去测试你的HTML在浏览器中的真实渲染方式。这些工具不仅允许你去检查Django代码的行为,还可以检查其他的,例如 JavaScript的。看到测试用例像人一样运行一个浏览器,并开始和你的站点开始交互,是非常了不起的(It’s quite something)。Django包含的LiveServerTestCase
可以方便整合类似Selenium
的工具。
如果你有一个复杂的应用,有可能为了实现continuous integration(持续集成)
的目的,希望每次提交代码后可以自动测试。这样质量控制本身就是自动化的——至少一部分是。
找出应用中未测试代码的一个好方法是检查测试代码的覆盖率。这也有助于找出脆弱、甚至无效的代码。如果有一段代码你无法测试,它通常意味着这部分代码应该被重构或移除。覆盖率会帮我们识别无效的代码。更多内容请查看Integration with coverage.py
(这里少一个链接)
Testing in Django
(这里少一个链接)中有关于测试的完整内容 。
下一步是什么?
关于测试的完整信息,请查看Testing in Django
(这里少一个链接)
如果你已经熟悉django 视图测试,就可以开始学习下一节内容了:静态文件管理(page 11)