目录
编写第一个Django应用程序,第五部分
我们通过前四部分内容已经构建了一个web投票应用程序,现在我们将为它创建一些自动化测试。
引入自动化测试
什么是自动化测试?
测试是检查代码操作的活动。
测试运行在不同层次上,一些测试可能适用于一个微小的细节(特定的模型方法是否如预期的那样返回值?),而另一些测试则检查软件的整体操作(站点上的一系列用户输入是否产生期望的结果?)。这与第二部分所做的测试没有什么不同,使用shell检查方法的行为,或者运行应用程序并输入数据检查它的行为。
自动化测试的不同之处在于测试工作是由系统为我们完成的。我们只需要创建一组测试,然后当我们对应用程序进行更改时,就可以检查代码是否仍能像最初预期的那样工作,而无需执行耗时的手动测试。
为什么需要创建测试
那么为什么要创建测试,为什么是现在呢?
你可能会觉得仅仅是学习Python/Django就已经够我们忙的了,现在还有其它的事情要学习和做,这看起来是没有必要的。毕竟,我们的投票应用程序现在运行的很好;为它创建自动化测试麻烦不说,而且未必会让我们的程序运行的更好。如果创建投票应用程序是你做的最后一次Django编程,那么,你不需要知道如何创建自动化测试。但是,如果情况不是这样,现在就是学习的机会,毕竟程序经过测试后会更加的健壮。
测试将节省你的时间
在某种程度上,“检查它是否有效”将是一个令人满意的测试。在更复杂的应用程序中,组件之间可能有几十个复杂的交互。
任何组件的变化都可能对应用程序的行为产生意想不到的后果,检查它是否仍然“有效”可能意味着使用20中不同的测试数据来运行代码的功能,比确保改变没有破坏某些东西——这会花费不少时间。而自动化测试可以在几秒钟内就为我们完成这些工作,如果出现了问题,测试还将帮助识别导致意外行为的代码,这是十分有用的。
有时候,把自己从富有成效的、创造性的编程工作中抽离出来,去编写乏味的测试工作,这似乎是一件苦差事,尤其是当你知道自己的代码能够正常运行时;但是,编写测试任务比花费数小时手动测试应用程序或试图确定新引入问题的原因要容易得多。
测试不仅仅是发现问题,而是预防问题
认为测试只是开发的一个消极方面是错误的。如果没有测试,应用程序的目的或预期行为可能相当不透明;即使这是你自己的代码,有时可能也会发现自己还需要好好研究它,试图了解它到底在做什么。这是经常有的事情,自己编写的代码,过一段时间后,自己都忘记了它是要做什么的。
测试改变了这一点,它们从内部点亮你的代码,当出现问题时,它们会把光聚焦在出错的部分——即使你甚至没有意识到它出了问题。
测试有助于团队协同工作
前面几点是从单个开发人员维护应用程序角度写的,复杂的应用程序将由团队维护。测试保证同事不会无意中破坏你的代码(也保证你不会在不知情的情况下破坏他们的代码)。
基本测试策略
编写测试的方法有很多。一些程序员遵循一种叫做“测试驱动开发”的原则,他们实际上在编写代码之前编写测试。这可能看起来违反常规,但实际上这与大多数人经常做的事情相似:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在Python测试用例中将问题形式化。
有时候很难弄清楚从哪里开始编写测试。如果你已经写了几千行Python代码,选择要测试的东西可能并不容易。在这种情况下,下次更改时编写第一个测试是具有成效的,无论是在添加新功能还是修复bug时。
那我们现在就开始吧。
编写第一个测试
我们发现了一个bug
幸运的是,polls应用程序还是有一点bug的:Question.was_published_recently()方法返回True时表明该Question是在最后一天发表的(这是正确的),但是如果这个Question的pub_date字段保存的时间是未来的某一天,那么此时还返回True,这就是不正确的。
通过使用shell检查日期位于未来的问题的方法来确认错误:
python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # 创建一个Question实例,pub_date设置为当前时间的30天后
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # 这是最近发布的问题吗?显然不是
>>> future_question.was_published_recently()
True
既然未来的事情不是“最近的”,那么这显然是一个bug。
创建一个测试来暴露bug
我们刚刚在shell中测试的问题正是我们在自动化测试中可以做的,所以让我们把它变成一个自动化测试。
应用程序的测试通常放在应用程序的tests.py文件中,测试系统将自动在文件中找到名字以test开头的测试函数。接下来我们在polls应用程序的tests.py文件中编写测试:
# 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子类,它的一个方法创建了一个带有未来发布日期的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 "d:\mysite\polls\tests.py", line 17, 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'...
过程是这样的:
- manage.py test polls 在polls应用程序中查找测试
- 它找到了django.test.TestCase类的子类
- 它创建了一个特殊的数据库用于测试
- 它寻找名字以test开头的测试方法
- 在测试方法test_was_published_recently_with_future_question中,创建了一个Question实例,其pub_date字段为未来的30天
- 使用assertIs()方法验证was_published_recently()的返回是否为False,实际上返回的是True。
测试告诉我们哪个测试失败了,以及发生问题的所在行。
修复bug
我们已经知道问题是什么了。如果pub_date是未来的时间,那么Question.was_published_recently()应该返回False。修改models.py中的该方法,只有日期为当前日期或更早的日期才返回Ture:
# polls/models.py
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):
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):
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应用程序是相当一视同仁的:它将发布任何问题,包括pub_date字段位于未来的问题。我们应该改进这一点,如果pub_date设置的是未来的时间,则在该时间到来之前,它应该是不可见的。
测试视图
当我们修复上面的bug时,我们先编写测试,然后再编写代码来修复它。事实上,这就是测试驱动开发的一个例子,但我们以哪种顺序进行工作并不重要。
在我们的第一个测试中,我们密切关注代码的内部行为。对于这个测试,我们想要检查它的行为,就像用户通过web浏览器体验到的那样。在我们尝试修复任何东西之前,让我们看一下我们可以使用的工具。
Django测试客户端
Django提供了一个测试Client来模拟用户在视图级与代码交互。我们可以在tests.py甚至shell中使用它。我们将从shell开始,在shell中我们需要做一些在tests.py中不需要做的事情。首先是在shell中设置测试环境:
python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()安装一个模板渲染器,它将允许我们检查响应上的一些附加属性,例如response.context,如果未设置,那么这些属性将不可用。注意,此方法不设置测试数据库,因此将在现有数据库下运行代码,根据已经创建的问题的不同,输出可能略有不同。如果settings.py中的TIME_ZONE不正确,可能会得到意想不到的结果。如果你不记得之前的设置,在继续之前检查一下。
接下来,我们需要导入测试客户端类(稍后在tests.py中,我们将使用django.test.TestCase类,它自带了自己的客户端,所以不需要这样做):
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
准备好之后,我们可以要求客户为我们做一些工作:
>>> # 访问 '/'
>>> response = client.get("/")
Not Found: /
>>> # 我们应该期待从那个地址得到404;如果你看到的是"Invalid HTTP HOST header"错误和400响应,
>>> # 那么你可能省略了前面描述的setup_test_environment()调用。
>>> response.status_code
404
>>> # 另一方面,我们应该期望在'/polls/'找到一些东西,
>>> # 我们将使用'reverse()'而不是硬编码的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为未来时间的Question),我们来解决这个问题。
在上一篇文章中,我们引入了一个基于类的视图,基于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()方法,使它也通过pub_date与timezone.now()进行比较来检查日期。首先,我们需要添加一个导入:
# polls/views.py
from django.utils import timezone
然后我们这样修改get_queryset()方法:
# polls/views.py
def get_queryset(self):
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的问题的查询集。
测试我们的新视图
现在,通过启动runserver,在浏览器中加载站点,创建带有过去和未来日期的Question,并检查是否只列出了已发布的问题,你就可以知道这是否符合预期。我想你不希望每次进行任何可能影响这一点的更改时都必须这样做——因此,让我们基于上面的shell会话创建一个测试。
在polls/tests.py中添加以下内容:
from django.urls import reverse
我们将创建一个用于创建Question的函数以及一个新的测试类:
def create_question(question_text, 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):
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):
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):
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):
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],
)
让我们来看一下代码中的内容。
首先是一个创建问题的快捷函数create_question,让我们在其它函数中创建问题时可以使用。
test_no_questtions不会创建任何问题,但会检查消息:“No polls are available.”,并验证latest_question_list是否为空。注意django.test.TestCase类提供了一些额外的断言方法。在这些示例中,我们使用了assertContains()和assertQuerySetEqual()。
在test_past_question中,我们创建了一个问题并验证它是否会出现在列表中。
在test_future_question中,我们创建了一个pub_date为未来时间的问题。由于每个测试方法都会重置数据库,所以该问题不应该出现在列表中。
实际上,我们使用测试来模拟网站上管理员的输入和用户浏览的过程,并检查当状态改变时,是否产生了预期的结果。
测试DetailView
我们的应用程序运行良好,然而,用户有可能不通过页面访问来做各种页面操作,而是找到了或猜到了执行程序的URL,那么他们就可以直接访问到应用程序,而绕过页面的一些校验。所以我们需要向DetailView中添加类似的约束:
# polls/views.py
class DetailView(generic.DetailView):
...
def get_queryset(self):
return Question.objects.filter(pub_date__lte=timezone.now())
然后我们应该添加一些测试,以检查Question的pub_date是过去的可以显示,未来的是不能显示的:
# polls/tests.py
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
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):
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)
更多测试的想法
我们应该添加一个类似的get_queryset方法到ResultsView和为该视图创建一个新的测试类,它将和我们刚刚创建的非常相似,事实上确实会有很多重复。
我们还可以通过其它方式改进我们的程序,例如,Question发布时不可以没有Choice。所以,可以在视图中检查这一点,排除这样的Question。
当然,还有很多可以改进的地方,比如普通用户只能投票不能发布投票问题等。这些在这里就不再展开说明了,毕竟可以改进的地方多了,有兴趣的话,你可以将该应用程序按照你的想法继续将它进行改进。
测试时,越多越好
我们的测试正在失控,按照这个速度,我们测试的代码将会很快超过应用程序中的代码,并且有些还是重复的。这其实并不重要,在大多数情况下,我们编写测试后,验证无误后就可以不管它了。随着继续开发程序,它将继续执行其有用的功能。
有时候测试也是需要更新的,这是在我们修改代码时才会做的。不管怎样说,测试的越多,覆盖面越广,那么我们的程序也将会越健壮,所以不要担心测试代码太多的问题。
只要测试安排的合理,它们就不会变得难以管理。好的经验包括:
- 每个模型和视图都应该有一个单独的TestClass
- 针对要测试的每组条件使用单独的测试方法
- 描述其功能的测试方法名
欢迎关注我的公众号