Skip to main content

编写您的第一个Django应用程序,第5部分

本教程从 教程4 离开时开始。我们已经构建了一个Web-poll应用程序,我们现在将为它创建一些自动化测试。

引入自动化测试

什么是自动化测试?

测试是检查代码操作的简单例程。

测试在不同级别运行。一些测试可能适用于微小细节(一个特定的模型方法返回值是否符合预期?),而其他测试可能会检查软件(站点上的用户输入序列是否产生所需的结果?)的整体操作。这与以前在 教程2 中进行的测试类型没有什么不同,使用 shell 来检查方法的行为,或者运行应用程序并输入数据来检查它的行为。

automated 测试的不同之处在于测试工作是由系统为您完成的。您创建一组测试一次,然后在更改应用程序时,您可以检查您的代码是否仍按原来的方式工作,而无需执行耗时的手动测试。

为什么需要创建测试

那么为什么要创建测试,为什么现在呢?

你可能觉得你有足够的在你的板子只是学习Python/Django,有另一件事要学习和做可能似乎压倒性的,也许是不必要的。毕竟,我们的民意调查申请现在工作相当愉快;经历创建自动化测试的麻烦不会使它工作任何更好。如果创建polls应用程序是Django编程的最后一点,你将永远做,然后true,你不需要知道如何创建自动化测试。但是,如果不是这样,现在是一个很好的时间学习。

测试将为您节省时间

到某一点,“检查它似乎工作”将是一个令人满意的测试。在更复杂的应用程序中,组件之间可能有许多复杂的交互。

任何这些组件的更改可能会对应用程序的行为产生意外的后果。检查它仍然’似乎工作’可能意味着运行你的代码的功能,有二十种不同的测试数据的变化,以确保你没有坏事 - 不是一个很好的利用你的时间。

当自动化测试可以在几秒钟内为你完成这一点时尤其如此。如果出现错误,测试还将帮助识别导致意外行为的代码。

有时,它可能似乎是一个琐事,撕裂自己从生产性,创造性的编程工作,以面对写作测试的无趣和不寻常的业务,特别是当你知道你的代码正常工作。

然而,编写测试的任务比花费数小时手动测试应用程序或尝试确定新引入的问题的原因更加实用。

测试不只是识别问题,而是阻止它们

把考试看成是发展的一个消极方面是一个错误。

没有测试,应用程序的目的或预期的行为可能是相当不透明的。即使它是你自己的代码,你有时会发现自己在探索它试图找出它究竟在做什么。

测试改变了;他们从内部点亮你的代码,当出现问题时,他们将光线集中在出错的部分 - 即使你甚至没有意识到它出了错

测试使您的代码更具吸引力

你可能创造了一个辉煌的软件,但你会发现,许多其他开发人员将只是拒绝看它,因为它缺乏测试;没有测试,他们不会信任它。 Django的原始开发人员之一Jacob Kaplan-Moss说“没有测试的代码被设计打破了。

其他开发人员想要在他们认真考虑之前在软件中看到测试是您开始编写测试的另一个原因。

测试帮助团队一起工作

前面几点是从维护应用程序的单个开发人员的角度编写的。复杂的应用程序将由团队维护。测试保证同事不会无意中破坏您的代码(并且您不会在不知情的情况下打破他们的代码)。如果你想做一个Django程序员,你必须擅长写测试!

基本测试策略

有很多方法来处理写测试。

一些程序员遵循一个名为“ test-driven development ”的学科;他们实际上在写他们的代码之前写他们的测试。这可能看起来反直觉,但实际上它类似于大多数人通常会做的:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发只是在Python测试用例中将问题正式化。

更多的时候,测试的新手将创建一些代码,然后决定它应该有一些测试。也许它会更好地早些时候写一些测试,但它从来没有太晚开始。

有时很难找出在哪里开始写测试。如果你写了几千行Python,选择测试可能不容易。在这种情况下,下一次进行更改时编写第一个测试是有效的,无论是添加新功能还是修复错误。

所以,让我们马上做。

写你的第一个测试

我们识别一个错误

幸运的是,在 polls 应用程序中有一个小错误,我们立即修复:Question.was_published_recently() 方法返回 True 如果 Question 在最后一天内发布(这是正确的),但是如果 Questionpub_date 字段是在未来(当然不是)。

要检查错误是否真的存在,使用管理员创建一个日期在未来的问题,并使用 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

由于未来的事情不是“最近的”,这显然是错误的。

创建测试以暴露错误

我们在 shell 中刚刚完成的测试问题正是我们在自动化测试中可以做的,所以让我们把它变成自动测试。

应用程序测试的常规位置在应用程序的 tests.py 文件中;测试系统将自动在名称以 test 开头的任何文件中查找测试。

将以下内容放在 polls 应用程序中的 tests.py 文件中:

polls/tests.py
import datetime

from django.utils import timezone
from django.test import TestCase

from .models import Question


class QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() should return 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_dateQuestion 实例。然后我们检查 was_published_recently() 的输出 - 其中 ought 为假。

运行测试

在终端,我们可以运行我们的测试:

$ python manage.py test polls

你会看到类似的东西:

Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
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 pollspolls 应用程序中查找测试

  • 它发现了 django.test.TestCase 类的一个子类

  • 它创建了一个用于测试目的的特殊数据库

  • 它寻找测试方法 - 名称以 test 开头的测试方法

  • test_was_published_recently_with_future_question 中,它创建了一个 Question 实例,其 pub_date 字段是将来的30天

  • ...并且使用 assertIs() 方法,它发现它的 was_published_recently() 返回 True,虽然我们想要它返回 False

测试通知我们哪个测试失败,甚至是发生故障的线路。

修复错误

我们已经知道问题是什么:如果 pub_date 在未来,Question.was_published_recently() 应该返回 False。修改 models.py 中的方法,以便如果日期也是过去,它将只返回 True

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'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

在识别一个错误后,我们编写了一个测试,暴露它并纠正了代码中的错误,所以我们的测试通过。

许多其他的东西可能会在我们的应用程序在将来出错,但我们可以确定,我们不会无意再重新介绍这个错误,因为只是运行测试将立即警告我们。我们可以认为应用程序的这一小部分被永远安全下载。

更全面的测试

当我们在这里,我们可以进一步确定 was_published_recently() 方法;事实上,如果在修复一个bug,我们已经介绍了另一个,这将是积极的尴尬。

将另外两个测试方法添加到同一个类,以更全面地测试方法的行为:

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() should return False for questions whose
    pub_date is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=30)
    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() should return True for questions whose
    pub_date is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

现在我们有三个测试,证实 Question.was_published_recently() 对过去,最近和未来的问题返回合理的价值。

再次,polls 是一个简单的应用程序,但是它在未来增长复杂,并且无论其它代码与它交互,我们现在有一些保证,我们编写测试的方法将以预期的方式。

测试视图

投票申请是相当无歧视的:它会发布任何问题,包括那些其 pub_date 字段在未来。我们应该改进这一点。将来设置 pub_date 应该意味着该问题在那一刻发布,但在那之前不可见。

视图的测试

当我们修正上面的bug时,我们首先编写测试,然后编写代码来修复它。事实上,这是一个简单的测试驱动开发的例子,但它并不重要,我们做什么顺序。

在我们的第一个测试中,我们密切关注代码的内部行为。对于此测试,我们要检查其行为,因为它将由用户通过Web浏览器体验。

在我们尝试修复任何东西之前,让我们看看我们可以使用的工具。

Django测试客户端

Django提供了一个测试 Client 来模拟用户在视图级别与代码交互。我们可以在 tests.py 或甚至在 shell 中使用它。

我们将再次从 shell 开始,我们需要做一些在 tests.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()

随着准备好,我们可以要求客户为我们做一些工作:

>>> # get a response from '/'
>>> response = client.get('/')
>>> # we should expect a 404 from that address
>>> 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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

改善我们的观点

投票列表显示尚未发布的投票(即将来有 pub_date 的投票)。让我们解决这个问题。

教程4 中,我们引入了基于类的视图,基于 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()) 返回包含 Question 的查询集,其 pub_date 小于或等于 - 即,早于或等于-timezone.now

测试我们的新视图

现在,您可以通过启动runserver,在浏览器中加载网站,创建包含过去和未来日期的 Questions,并检查是否仅列出已发布的 Questions,来满足自己的需求。你不想要做这个 每次你做出任何可能影响这一点的改变 - 所以让我们还创建一个测试,基于我们上面的 shell 会话。

将以下内容添加到 polls/tests.py

polls/tests.py
from django.urls import reverse

我们将创建一个快捷函数来创建问题以及一个新的测试类:

polls/tests.py
def create_question(question_text, days):
    """
    Creates 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 QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be 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_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be 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_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be 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_index_view_with_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.>']
        )

让我们更仔细地看看其中一些。

首先是一个问题快捷函数,create_question,要从一些重复出来的过程中创建问题。

test_index_view_with_no_questions 不创建任何问题,但检查消息:“没有投票可用。并验证 latest_question_list 是否为空。注意,django.test.TestCase 类提供一些附加的断言方法。在这些实施例中,我们使用 assertContains()assertQuerysetEqual()

test_index_view_with_a_past_question 中,我们创建一个问题并验证它是否出现在列表中。

test_index_view_with_a_future_question 中,我们将来会创建一个带有 pub_date 的问题。数据库针对每个测试方法重置,因此第一个问题不再存在,因此索引也不应该有任何问题。

等等。实际上,我们使用测试来讲述在网站上的管理员输入和用户体验的故事,并检查在每个状态和系统状态的每一个新变化,预期的结果发布。

测试 DetailView

我们的工作很好;然而,即使未来的问题未出现在 index 中,用户仍然可以在他们知道或猜测正确的网址时与他们联系。因此,我们需要向 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_dateQuestion 不是:

polls/tests.py
class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return 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_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display 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 可以发布在没有 Choices 的网站上,这是很愚蠢的。因此,我们的意见可以检查这一点,并排除这样的 Questions。我们的测试将创建一个没有 ChoicesQuestion,然后测试它没有发布,以及创建一个类似的 Question with Choices,并测试它的 is 发布。

也许登录的管理员用户应该可以看到未发布的 Questions,但不是普通的访客。再次:无论需要添加到软件来完成这一任务,都应该伴随着测试,无论您先编写测试,然后使代码通过测试,还是先编写代码中的逻辑,然后写一个测试证明给我看。

在某一点,你必须看看你的测试,并想知道你的代码是否受到测试膨胀,这使我们:

当测试时,更多是更好

看来我们的测试越来越失去控制。以这个速率,我们的测试中将很快会有比我们的应用程序更多的代码,并且重复是不美观的,相比之下,我们的代码的其余部分的优雅简洁。

没关系。让他们成长。在大多数情况下,你可以写一个测试,然后忘记它。它将继续执行其有用的功能,你继续开发您的程序。

有时测试需要更新。假设我们修改我们的意见,使只有 QuestionsChoices 发表。在这种情况下,我们的许多现有测试将失败–告诉我们需要修改哪些测试以使其更新,所以在这个程度上测试帮助照顾自己。

最糟糕的是,当你继续开发,你可能会发现,你有一些测试,现在是冗余的。即使这不是问题;在测试冗余是一个 good 的东西。

只要你的测试合理安排,他们就不会变得难以管理。良好的经验规则包括:

  • 每个模型或视图的单独 TestClass

  • 一个单独的测试方法为您想要测试的每一组条件

  • 描述其功能的测试方法名称

进一步测试

本教程仅介绍一些测试的基础知识。有很多你可以做,和一些非常有用的工具,在您的处置,以实现一些非常聪明的事情。

例如,虽然我们的测试覆盖了模型的一些内部逻辑以及我们的视图发布信息的方式,但是您可以使用“浏览器内”框架(如 Selenium)来测试HTML在浏览器中实际呈现的方式。这些工具不仅可以检查Django代码的行为,而且还可以检查JavaScript的行为。它是相当的东西看到测试启动一个浏览器,并开始与您的网站,如果一个人在驾驶它的网站交互! Django包括 LiveServerTestCase 以方便与Selenium等工具集成。

如果您有一个复杂的应用程序,您可能想要为每个提交自动运行测试为了 continuous integration 的目的,使质量控制本身 - 至少部分自动化。

一个检测应用程序未测试部分的好方法是检查代码覆盖率。这也有助于识别脆弱甚至死代码。如果你不能测试一段代码,通常意味着代码应该重构或删除。覆盖将有助于识别死代码。有关详细信息,请参阅 与 coverage.py 集成

在Django中测试 有关于测试的全面信息。

下一步是什么?

有关测试的完整详细信息,请参阅 在Django中测试

当您熟悉测试Django视图时,请阅读 part 6 of this tutorial 了解静态文件管理。