开始Django之旅-part5_自动测试你的代码
前一篇:开始Django之旅-part4_使用表格简化你的代码
后一篇:开始Django之旅-part6_静态文件
介绍自动测试
什么是自动测试呢?
测试是检查你的代码的常规操作。测试操作有不同的级别,一些测试可能用于一些小细节(执行一个特定的模型方法返回一个期待的值),一些可能是检查整个软件的操作(在某一地址执行一系列的用户输入,生成想要的结果)。
而自动测试的特点就是测试工作完全由系统独立执行,一旦你创建了一系列的测试,当你修改你的代码时,你可以检查你的代码是否像先前那样工作,不用浪费手动测试的时间。
为什么你需要建立一些测试实例?
你可能觉得仅仅学习python/Django就已经足够了,还要学另一样东西,会有点压力大或者没必要。毕竟,现在我们的投票app工作正常,通过这些自动测试的麻烦不会让它工作更好。如果创建的投票app只有一点点Django程序,确实,你不需要了解如何去创建自动测试,但是,如果不是这种情况,现在就是最好的时间去学习它。
测试会节省你的时间
确切的说,检验它是否工作会是一个满意度测试。在一些更精密的app中,在组件中可能有几十个复杂的交互。
任何组件的修改都可能会引发异常的后果。检查它是否工作可能意味着使用20正不同的测试数据去测试你的代码函数是否能正常运行,确保你没有破坏某些东西,这会浪费很多时间。
自动测试却能够在几秒内完成这些工作,如果检查出了错误,测试也会协助找到引发异常的代码。
有时候,它就是像是一个令人厌烦的杂事,打击了你的积极性,创造性的编码工作面临着如此不优雅和讨厌的测试,特别是当你知道你的代码能够正常工作时。
然而,写测试的任务是比起花费数小时手工的测试应用或者尝试确认问题的原因更有价值。
测试不仅是查找问题,还预防问题
认为测试时开发消极的一面,大错特错。没有测试,app的行为目的可能十分隐晦,不透明。尽管这是你自己写的代码,你会发现,有时候你会尝试到处查找它到底做了什么。
测试改变了这一问题,当一些东西出错了,它们会焦距错误的部分,即使你没有意识到这里会出错。
测试会使你的代码更有吸引力
你可能做一个非常精彩的软件,但是你会发现,许多开发者不想查看它,因为它缺少测试;没有测试,他们就不相信它。
其他的开发者在他们开始认真对待这个软件之前想要看它的测试,这会是你开始写测试的另一个原因。
测试有助于团队一起工作
简单的app可以有一个开发者维护,但是复杂的app会由团队一起维护。测试保证了你的同事不会无意的破坏你的代码。如果你想要作为Django开发者来谋生,你必须擅长写测试。
基本的测试策略
这里有很多方法写测试
一些程序员遵循了一个叫做“测试驱动开发”的原则,实际上,他们在写他们的代码之前就写了测试。这好像不符常理,但是事实上,这就类似于很多人常做的那样:描述一个问题,然后编码解决它。测试驱动正式化了Python测试样例的问题。
有时候,很难决定从哪里开始写测试。如果你已经写了几千行Python代码,不太容易确定从哪里开始。那么,就从一下一次修改代码的地方开始写,或者当你添加新功能或者修理bug。
写第一个测试
我们测试一个bug,幸运的是,投票app有很少的bug。如果Question是昨天发布的,且Question的pub_data是在以后,Question.was_published_rently()方法返回true。
使用shell确定bug,检查question的方法:
...\> py 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
因为现在之后的东西并不代表最近,很明显这就错了。
创建一个测试修理bug
我们刚刚在shell测试了我们可以在自动测试中做的问题,所以,切换到自动测试中。
应用测试常用的位置是在tests.py文件中,测试系统会自动的查询每个以test开头命名的文件。
赋值下面的代码到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.TestCast的子类,包含一个方法,它创建了一个Question对象,pub_data是未来的时间。我们检查was_published_recently()的结果,它应该是False。
运行测试
在cmd中运行:
...\> py 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'...
如果,你得到一个NameError错误,你可能在part2中polls/models.py中导入datatime和timezon.重新导入,再运行一次。
程序做了什么?
- manage.py test polls:查找polls app下的所有测试文件。
- 它发现一个django.test.TestCase的子类
- 它为测试创建了一个特别的数据库
- 它开始查找以test为前缀的测试方法。
- 在test_was_published_recently_with_future_question中创建了一个pub_date字段时候为以后30天内Question对象。
- 调用assertIs()方法,它调用了它的was_published_recently(),返回了True,尽管我们想要它返回False。
这个测试通知我们哪个测试失败了,甚至是错误地方的行数。
修理bug
我们已经知道问题了:Question.was_published_recently()如果它的pub_date时间在以后,应该返回False。在model.ps中修改该方法。
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
在运行一次测试后:
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'...
发现bug后,我们写了一个测试暴露了它,然后修正,所以我们的测试通过了。
之后,我们的程序可能会出很多错误,但是我们可以确定我们不会再无意的引进这种bug,因为运行测试文件可以即时警告我们,我们可以认为,程序的这一部分已经清晰了。
更综合的测试
尽管我们以后可以理解was_published_recently()方法,事实上,如果我们在修理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.was_published_recently(),对于过去,最近和未来返回合理的值。
此外,polls是一个小型的应用,但是无论将来它多么复杂,无论它和什么代码交互,我们现在能保证我们写的测试会一直保证它将来的行为方式。
测试View
polls应用没有鉴别能力:它可以发布任何问题,包括时间是以后的问题。我们应该提升的一点是:设置pub_date在之后,就意味着问题应该在那时才展示。
测试view
当我们修理上面的bug时,我们写了一个测试文件,然后修正它的代码。事实上,它是一个测试驱动开发的例子,但是测试和修正的顺序没有什么关系。
在我们的测试中,我们更关注代码的内部行为。对于这个测试,我们先想要检查它的行为就像用户通过浏览器访问时的经历一样。
在开始修正之前,我们先看一下处理工具。
Django测试客户端
Django提供了一个测试Client去模仿用户和view交互,我们可以在tests.py中使用它,甚至在shell中。
我们刚开始会先用shell,这里会做许多在tests.py中不需要做的事。在shell中搭建一个测试环境:
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()安装了一个渲染模板,它允许我们检查一些额外的属性,例如本不可以查看的response.context属性。注意,该方法不会建立一个测试数据库,所以接下来的运行依靠现存的数据库,根据你创建不同的数据库,输出可以有轻微的不同。如果你settings.py的TIME_ZONE设置不正确,可能得到一个异常的结果。运行之前在检查一遍。
接下来我们需要去导入测试client类:
>>> 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?>]>
修正view
投票的列表显示了还未公布的数据,让我们修改一下。
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()方法,让他可以检char数据的时间,现在我们导入:
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()) 返回一个pub_data小于等于现在的时间的查询集。
测试新view
现在你可能重新运行服务器,加载你浏览器的地址,创建时间为过去和未来的Questions,检查是否只有已经发布的问题才被列出来,可能会得到满意的结果。如果你不想要以后的改变影响它,所以,让我们建立一些测试。
添加下面的代码到polls/tests.py
from django.urls import reverse
先创建一个简短的函数用来创造question对象,再建立一个测试类:
def create_question(question_text, days):
"""根据给出的questtion_test创建一个问题,发布日期为根据给出的days对现在偏移的日期
(负数表示问题已经被发布,正数表示问题还没有被发布)
"""
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):
"""
数据不存在,显示提示信息
"""
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):
"""
只显示已公布的数据
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):
"""
未来的数据不会被显示
"""
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):
"""
即使两种数据都存在,只显示过去的数据
"""
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):
"""
显示混合的数据
"""
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.>']
)
让我们仔细的分析一些:
首先是一个简短的函数,creat_question,就是从创建question对象的过程中抽取的重复部分。
test_noquestion没有创建任何question对象,但是控制信息“No polls are available”的输出和验证latest_question_list是否为空。注意django.test.TestCase类提供了一些额外的assertion方法。在这些例子中,我们使用了assertContains()和assertQuerysetEqual().
在test_past_question中,我们创建了一个问题对象,并且验证它是否出现在列表中。
在test_future_question中,我们创建了一个未公布的问题,数据库为每一个测试方法重置了,所以第一个question对象不在这里存在,所以index页面不会存在任何数据。
诸如此类,事实上,我们正用这些测试去讲述管理员输入数据的的过程和用户在网址上的体验,并且检查在任何新变化下的状态,期待已公布的结果。
测试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对象是否被显示,而在未来的对象则不会被显示。
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的方法,然后给该view创建一个新的测试。它和我们刚刚做的非常类似,事实上,这里会有很多冗余代码。
我们应该提升我们的应用,添加一些测试。比如说,如果一个问题都没有Choice就可以被公布,就非常傻。所以,我们的view应该能检查这个,把这类的Questions对象刷选掉。我们的测试会创建一个没有选项的问题对象,然后测试它不能被发布,同时创建一个有选项的问题对象,测试它能够被发布。
或许对于管理员用户应当被允许查看为发布的Question对象。同样的,添加到软件上的为完成某样功能的代码,都应该伴随着测试。无论你先写,还是写通代码逻辑过后再写。
确定的一点是,你受限于检查你的测试,好奇你的代码是否正遭受过多的测试的折磨,请看这里:
当测试时,多就是好
看起来,好像我们的测试多的不受控制。按照这种速度,测试代码越来越多,甚至远超过我们的程序代码。此时,冗余对比起其余代码的优雅简洁显得毫无美感。
没关系,让它们增长。更重要的是,你一旦写了测试,然后就可以忘掉它,当你继续开发你的程序时,它就继续发挥它的作用。
有时候测试需要更新。假设我们修改我们的view,以至于只有Questions和Choices被发布。在这种情况下,我们现存的许多测试就会失效,它会确切的告诉我们哪一个测试需要修改,然后去更新他们。所以测试会自己照顾好他们自己。
更糟糕的是,当你继续开发,你可能会发线,有一些测试时冗余的。即使那不是个问题;在测试中冗余是一件好事。
只要你的测试会合理的安排,它们就不会不可管理。好的经验规则包括:
- 每一个model和view都要有自己的TestClass
- 每一种测试条件都要有自己的测试方法
- 测试方法的名字要描述它们的功能。
进一步测试
这篇文章只介绍了一些测试的基本常识。这里有一些额外的事可以做,很多工具都可以供你使用。
例如,当我们的测试包含了model的一些内部逻辑和我们发布信息的方式,你可以使用‘内部浏览器’框架,例如Selenium去测试你的html在浏览器中渲染的方式。这些工具不仅可以让你检查你Django代码的行为,还可以检查JavaScript。看测试加载一个浏览器,开始和你的网址交互,好像一个人在操作,这是非同寻常的。Django包含了LiveServerTestCase,可以帮助集成工具,像Selenium。
一个去定位你程序中未检测代码的方式就是去检查代码的覆盖率。这也能确定不牢固甚至死的代码。如果你不能测试某一块代码,它通常意味着该代码需要修改或者移除。Coverage会帮助你确定这些代码。more details:Intergration with coverage.py
下一步
想要看完整的测试细节,点这里。