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

本教程从以下位置开始 Tutorial 4 停下来了。我们已经构建了一个Web投票应用程序,现在我们将为它创建一些自动化测试。

从哪里获得帮助:

如果您在阅读本教程时遇到困难,请访问 Getting Help 部分的常见问题解答。

引入自动化测试

什么是自动测试?

测试是检查代码操作的例行程序。

测试在不同级别进行。有些测试可能适用于一个微小的细节( does a particular model method return values as expected? )而其他人则检查软件的整体操作( does a sequence of user inputs on the site produce the desired result? ).这与您早些时候进行的测试没有什么不同 Tutorial 2 ,使用 shell 检查方法的行为,或者运行应用程序并输入数据以检查其行为方式。

有什么不同 automated 测试就是系统为您完成测试工作。您只需创建一组测试,然后在对应用程序进行更改时,您可以检查您的代码是否仍按您最初的预期工作,而无需执行耗时的手动测试。

为什么需要创建测试

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

您可能会觉得光是学习Python/Django就已经够多了,还有另一件事要学习和做可能看起来令人不知所措,甚至可能是没有必要的。毕竟,我们的民意调查应用程序现在运行得很顺利;经历创建自动化测试的麻烦并不会让它工作得更好。如果创建民意调查应用程序是您将做的Django编程的最后一步,那么您确实不需要知道如何创建自动化测试。但是,如果情况并非如此,那么现在是学习的绝佳时机。

测试将节省您的时间

在某种程度上,“检查它是否有效”将是一个令人满意的测试。在更复杂的应用程序中,组件之间可能有数十个复杂的交互。

任何这些组件的更改都可能会对应用程序的行为产生意想不到的后果。检查它是否仍然“似乎可以工作”可能意味着使用测试数据的二十种不同变体来运行代码的功能,以确保您没有损坏某些东西--这不是很好地利用您的时间。

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

有时,让自己从高效、创造性的编程工作中解脱出来,去面对编写测试的乏味和无趣的工作,这似乎是一件苦差事,尤其是当您知道您的代码正常工作时。

然而,编写测试的任务比花几个小时手动测试应用程序或尝试识别新引入问题的原因要令人满意得多。

测试不仅可以识别问题,还可以预防问题

将测试仅仅视为开发的消极方面是错误的。

如果没有测试,应用程序的目的或预期行为可能相当不透明。即使这是您自己的代码,您有时也会发现自己在其中四处寻找,试图找出它到底在做什么。

测试改变了这一点;它们从内部照亮你的代码,当出现问题时,它们会聚焦在出现问题的部分上- even if you hadn't even realized it had gone wrong

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

您可能已经创建了一个出色的软件,但您会发现许多其他开发人员会拒绝查看它,因为它缺乏测试;如果没有测试,他们就不会信任它。Django的原始开发人员之一雅各布·卡普兰-莫斯(Jacob Kaplan-Moss)说:“没有测试的代码会被设计破坏。"

其他开发人员希望在认真对待您的软件之前看到测试,这是您开始编写测试的另一个原因。

测试帮助团队合作

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

基本测试策略

编写测试的方法有很多。

一些程序员遵循一种名为“测试驱动开发”的原则;他们实际上在编写代码之前编写测试。这可能看起来违反直觉,但事实上,这与大多数人经常做的事情类似:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在Python测试案例中正式化了问题。

更多情况下,测试新手会创建一些代码,然后决定应该进行一些测试。也许早点写一些测试会更好,但开始永远不会太晚。

有时很难弄清楚从哪里开始编写测试。如果您已经编写了几千行Python,那么选择一些东西来测试可能并不容易。在这种情况下,下次进行更改时(无论是添加新功能还是修复错误时)编写第一个测试都会富有成效。

所以让我们立即这样做。

编写我们的第一个测试

我们发现了一个错误

幸运的是,里面有一个小错误 polls 请我们立即修复的申请: Question.was_published_recently() 方法返回 True 如果 Question 是在最后一天发布的(这是正确的),但如果 Question 张氏 pub_date 该领域是在未来(当然不是)。

使用确认该错误 shell 检查日期在未来的问题的方法:

$ python manage.py shell
...\> py manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> # 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

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

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 实例与 pub_date 在未来然后我们检查的输出 was_published_recently() - 这 ought 是假的。

运行测试

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

$ python manage.py test polls
...\> 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/djangotutorial/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 在这里,您可能错过了一步 Part 2 我们增加了进口 datetimetimezonepolls/models.py .复制该部分的导入,然后尝试再次运行测试。

发生了什么:

  • 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

测试会告诉我们哪个测试失败,甚至告诉我们发生故障的线路。

修复错误

我们已经知道问题出在哪里: Question.was_published_recently() 应该返回 False 如果它的 pub_date 是在未来。修改方法 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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

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

我们的应用程序将来可能会出现许多其他问题,但我们可以确信我们不会无意中重新引入这个错误,因为运行测试会立即警告我们。我们可以认为应用程序的这一小部分永远安全地固定下来。

更全面的测试

当我们在这里时,我们可以进一步确定 was_published_recently() 方法;事实上,如果在修复一个错误时引入了另一个错误,那将是非常尴尬的。

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

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 是一个最小的应用程序,但无论它未来会变得多么复杂,无论它与任何其他代码交互,我们现在可以保证我们为之编写测试的方法将以预期的方式运行。

测试视图

民意调查应用程序相当不歧视:它会发布任何问题,包括那些 pub_date 领域在于未来。我们应该改进这一点。创下 pub_date 在未来应该意味着问题在那时发布,但在那时之前是不可见的。

景观测试

当我们修复上面的错误时,我们先写测试,然后写代码来修复它。事实上,这是测试驱动开发的一个例子,但我们按照哪个顺序进行工作并不重要。

在我们的第一次测试中,我们密切关注代码的内部行为。对于此测试,我们希望检查其行为,因为用户通过网络浏览器体验到的行为。

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

Django测试客户端

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

我们将从 shell ,我们需要做一些不必要的事情 tests.py .首先是在 shell

$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() 安装一个模板呈现器,它将允许我们检查响应的一些附加属性,例如 response.context 否则是不会有的。请注意,此方法 does not 设置一个测试数据库,这样就可以在现有数据库上运行以下内容,并且根据您已经创建的问题,输出可能会略有不同。您可能会得到意想不到的结果,如果 TIME_ZONE 在……里面 settings.py 是不正确的。如果您不记得早些时候设置了它,请在继续之前检查它。

接下来,我们需要导入测试客户端类(稍后介绍 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("/")
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&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context["latest_question_list"]
<QuerySet [<Question: What's up?>]>

改善我们的观点

民意调查列表显示尚未发布的民意调查(即那些具有 pub_date 在未来)。让我们解决这个问题。

在……里面 Tutorial 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 ,在浏览器中加载网站,创建一些 Question 包含过去和未来日期的条目,并检查是否仅列出已发布的条目。你不想这样做 every single time you make any change that might affect this - 因此,让我们也创建一个测试,基于我们的 shell 上面的会议。

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

polls/tests.py
from django.urls import reverse

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

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.
        """
        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],
        )

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

首先是提问快捷功能, create_question ,从创建问题的过程中减少一些重复。

test_no_questions 不会产生任何问题,但会检查消息:“没有可用的投票。”并验证 latest_question_list 是空的。请注意, django.test.TestCase 类提供了一些附加的断言方法。在这些示例中,我们使用 assertContains()assertQuerySetEqual()

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

在……里面 test_future_question ,我们创建一个带有 pub_date 在未来数据库会为每个测试方法重置,因此第一个问题不再存在,因此索引中也不应该有任何问题。

等等。实际上,我们正在使用这些测试来讲述网站上的管理员输入和用户体验的故事,并检查在每个状态以及系统状态的每次新更改时,是否会发布预期结果。

测试 DetailView

我们所拥有的一切效果良好;然而,即使未来的问题没有出现在 index ,如果用户知道或猜到正确的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())

然后我们应该添加一些测试,以检查 Question 谁的 pub_date 是在过去可以显示的,而且那个有一个 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)

更多测试的想法

我们应该添加一个类似的 get_queryset 方法,以 ResultsView 并为该视图创建一个新的测试类。它将与我们刚刚创建的非常相似;事实上会有很多重复。

We could also improve our application in other ways, adding tests along the way. For example, it's pointless that a Question with no related Choice can be published on the site. So, our views could check for this, and exclude such Question objects. Our tests would create a Question without a Choice, and then test that it's not published, as well as create a similar Question with at least one Choice, and test that it is published.

也许应该允许登录的管理员用户查看未发布的 Question 参赛作品,但不是普通访客。再次:为了实现这一目标而需要添加到软件中的任何内容都应该伴随着测试,无论是先写测试然后让代码通过测试,还是先计算出代码中的逻辑然后写测试来证明它。

在某个时候,您一定会查看您的测试并想知道您的代码是否遭受了测试臃肿,这让我们想到:

测试时,越多越好

我们的测试似乎正在失控。按照这个速度,我们的测试中的代码很快就会超过应用程序中的代码,而且与我们其余代码的优雅简洁相比,重复是不美观的。

It doesn't matter .让他们成长。在大多数情况下,您可以编写一次测试,然后忘记它。当您继续开发程序时,它将继续执行其有用的功能。

有时测试需要更新。假设我们修改我们的观点,以便只有 Question 与相关的条目 Choice 实例已发布。如果那样的话,我们现有的很多测试都会失败- telling us exactly which tests need to be amended to bring them up to date ,所以在某种程度上,测试有助于照顾自己。

最坏的情况是,当您继续开发时,您可能会发现一些测试现在是多余的。即便如此,这也不是问题;在测试中,冗余是一个 good

只要您的测试安排合理,它们就不会变得难以管理。好的经验法则包括:

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

  • 针对您想要测试的每套条件采用单独的测试方法

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

进一步测试

本教程仅介绍测试的一些基础知识。您还可以做很多事情,并且可以使用许多非常有用的工具来实现一些非常聪明的事情。

例如,虽然我们这里的测试涵盖了模型的一些内部逻辑以及我们的视图发布信息的方式,但您可以使用“浏览器内”框架,例如 Selenium 测试您的HTML在浏览器中实际呈现的方式。这些工具不仅可以检查Django代码的行为,还可以检查JavaScript等行为。看到测试启动浏览器并开始与您的网站互动,就像人类在驾驶它一样,这真是太棒了!Django包含 LiveServerTestCase 以促进与Selenium等工具的集成。

如果您有一个复杂的应用程序,您可能希望在每次提交时自动运行测试,以便 continuous integration ,因此质量控制本身(至少部分)是自动化的。

发现应用程序未测试部分的一个好方法是检查代码覆盖率。这还有助于识别脆弱甚至死代码。如果您无法测试一段代码,通常意味着应该重构或删除该代码。覆盖范围将有助于识别死代码。看到 与集成 coverage.py 了解更多细节。

Testing in Django 拥有有关测试的全面信息。

下一步是什么?

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

当您愿意测试Django观点时,请阅读 part 6 of this tutorial 了解静态文件管理。