编写你的第一个 Django 应用程序,第 5 部分 — Django 文档

来自菜鸟教程
Django/docs/2.2.x/intro/tutorial05
跳转至:导航、​搜索

编写你的第一个 Django 应用,第 5 部分

本教程从 教程 4 停止的地方开始。 我们已经构建了一个网络投票应用程序,现在我们将为它创建一些自动化测试。

自动化测试简介

自动化测试是什么?

测试代码,是用来检查你的代码能否正常运行的程序。

测试在不同级别进行。 一些测试可能适用于一个微小的细节( 特定模型方法是否按预期返回值?),而其他测试则检查软件的整体操作( 网站上的一系列用户输入是否产生了想要的结果?)。 这与您之前在 教程 2 中所做的测试没有什么不同,使用 :djadmin:`shell` 来检查方法的行为,或运行应用程序并输入数据检查它的行为方式。

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


为什么你需要写测试

但是,为什么需要测试呢?又为什么是现在呢?

您可能会觉得仅仅学习 Python/Django 就已经足够了,而还有另一件事要学习和做,这似乎是压倒性的,也许是不必要的。 毕竟,我们的民意调查应用程序现在运行得很愉快; 经历创建自动化测试的麻烦不会让它工作得更好。 如果创建投票应用程序是您将要做的 Django 编程的最后一点,那么是的,您不需要知道如何创建自动化测试。 但是,如果情况并非如此,现在是学习的绝佳时机。

测试将节约你的时间

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

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

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

有时候你会觉得,和富有创造性和生产力的业务代码比起来,编写枯燥的测试代码实在是太无聊了,特别是当你知道你的代码完全没有问题的时候。

然而,编写测试还是要比花费几个小时手动测试你的应用,或者为了找到某个小错误而胡乱翻看代码要有意义的多。


测试不仅能发现问题,还能预防问题

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

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

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


测试使你的代码更有吸引力

您可能已经创建了一个出色的软件,但是您会发现许多其他开发人员只是拒绝查看它,因为它缺乏测试; 没有测试,他们不会相信它。 Jacob Kaplan-Moss 是 Django 的原始开发人员之一,他说“没有测试的代码被设计破坏了。”

其他的开发者希望在正式使用你的代码前看到它通过了测试,这是你需要写测试的另一个重要原因。


测试有利于团队协作

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


基础测试策略

有好几种不同的方法可以写测试。

一些程序员遵循称为“测试驱动开发”的规则; 他们实际上是在编写代码之前编写测试。 这可能看起来违反直觉,但实际上它类似于大多数人无论如何都会经常做的事情:他们描述一个问题,然后创建一些代码来解决它。 测试驱动开发只是在 Python 测试用例中将问题形式化。

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

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

所以让我们立即这样做。


开始写我们的第一个测试

首先得有个 Bug

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

通过使用 :djadmin:`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

我们刚刚在 :djadmin:`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 实例和一个 pub_date。 然后我们检查 was_published_recently() 的输出 - 其中 应该 是假的。


运行测试

在终端中,我们通过输入以下代码运行测试::

你会看到类似的东西:

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,您可能错过了 第 2 部分 中的一个步骤,我们将 datetimetimezone 的导入添加到 [ X143X]。 从该部分复制导入,然后再次尝试运行您的测试。


发生了什么呢?以下是自动化测试的运行过程:

  • 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

测试系统通知我们哪些测试样例失败了,和造成测试失败的代码所在的行号。


修复这个 bug

我们已经知道问题是什么:如果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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

发现 bug 后,我们编写了能够暴露这个 bug 的自动化测试。在修复 bug 之后,我们的代码顺利的通过了测试。

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


更全面的测试

当我们在这里时,我们可以进一步确定 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 应该意味着该问题在那一刻发布,但在那之前是不可见的。

针对视图的测试

当我们修复上面的错误时,我们先编写测试,然后编写修复它的代码。 事实上,这是测试驱动开发的一个简单示例,但我们以何种顺序进行工作并不重要。

在我们的第一个测试中,我们密切关注代码的内部行为。 对于此测试,我们希望检查其行为,就像用户通过 Web 浏览器体验到的那样。

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


Django 测试工具之 Client

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

我们将再次从 :djadmin:`shell` 开始,在这里我们需要做一些在 tests.py 中不需要的事情。 首先是在:djadmin:`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('/')
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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> 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 并检查只有那些已经已发布。 你不想每次做任何可能影响这个的更改时都这样做 ' - 所以让我们也创建一个测试,基于我们的 :djadmin:`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.
        """
        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.>']
        )

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

首先是一个问题快捷功能,create_question,从创建问题的过程中去除一些重复。

test_no_questions 不会创建任何问题,但会检查消息:“没有可用的投票。” 并验证 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):
        """
        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 方法,并为该视图创建一个新的测试类。 它将与我们刚刚创建的非常相似; 事实上会有很多重复。

我们还可以通过其他方式改进我们的应用程序,在此过程中添加测试。 例如,在没有Choices的网站上发布Questions是愚蠢的。 因此,我们的视图可以对此进行检查,并排除此类 Questions。 我们的测试将创建一个没有 ChoicesQuestion,然后测试它没有发布,以及创建一个类似的 Question Choices ],并测试它 发布。

也许应该允许登录的管理员用户看到未发布的 Questions,但不能看到普通访问者。 再次:无论需要添加到软件中以完成此操作,都应该伴随测试,无论您是先编写测试然后使代码通过测试,还是先解决代码中的逻辑,然后再编写测试证明给我看。

在未来的某个时刻,你一定会去查看测试代码,然后开始怀疑:「这么多的测试不会使代码越来越复杂吗?」。别着急,我们马上就会谈到这一点。


当需要测试的时候,测试用例越多越好

看起来我们的测试正在失控。 按照这个速度,我们的测试中很快就会有比我们的应用程序更多的代码,与我们其余代码的优雅简洁相比,重复是不美观的。

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

有时需要更新测试。 假设我们修改我们的观点,以便只发布 QuestionsChoices。 在这种情况下,我们现有的许多测试都将失败 - 准确地告诉我们哪些测试需要修改以使其保持最新 ,因此在某种程度上,测试有助于自我管理。

最坏的情况是,随着您继续开发,您可能会发现有些测试现在是多余的。 即便如此,这也不是问题; 在测试冗余是 事情。

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

  • 每个模型或视图的单独 TestClass
  • 每个测试方法只测试一个功能
  • 给每个测试方法起个能描述其功能的名字


深入代码测试

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

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

如果您有一个复杂的应用程序,为了 持续集成 ,您可能希望在每次提交时自动运行测试,以便质量控制本身 - 至少部分 - 自动化。

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

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


下一步是什么?

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

如果您对测试 Django 视图感到满意,请阅读本教程的 第 6 部分 以了解静态文件管理。