编写第一个django应用程序,第5部分

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

从何处获得帮助:

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

介绍自动化测试

什么是自动测试?

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

测试在不同的层次上运行。一些测试可能适用于微小的细节( 特定的模型方法是否按预期返回值? )而其他人则检查软件的整体操作( 站点上的用户输入序列是否产生所需的结果? )这和你之前做的测试没什么不同 Tutorial 2 ,使用 shell 检查方法的行为,或运行应用程序并输入数据以检查其行为。

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

为什么需要创建测试

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

你可能会觉得你已经有足够的时间来学习python/django了,而且还有其他的事情要学习和做,这看起来是势不可挡的,也许是不必要的。毕竟,我们的投票应用程序现在工作得相当愉快;经历了创建自动化测试的麻烦,并不能使它更好地工作。如果创建投票应用程序是您将要做的Django编程的最后一步,那么,如果是这样,您就不需要知道如何创建自动测试。但是,如果不是这样的话,现在是学习的好时机。

测试可以节省你的时间

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

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

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

有时,为了面对编写测试这一乏味而乏味的工作,特别是当您知道代码工作正常时,将自己从富有成效、富有创造性的编程工作中分离出来似乎是件很麻烦的事情。

然而,编写测试的任务要比花几个小时手动测试应用程序或试图找出新引入问题的原因要多得多。

测试不仅能识别问题,还能阻止问题的发生。

把测试仅仅看作是发展的消极方面是错误的。

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

测试改变了这一点;它们从内部激活代码,当出现问题时,它们将重点放在出现错误的部分上。- 即使你还没意识到它出了问题 .

测试使代码更具吸引力

你也许已经创造了一个优秀的软件,但是你会发现许多其他的开发人员会拒绝看它,因为它缺少测试;没有测试,他们就不会信任它。jacobkaplan-Moss是Django最初的开发人员之一,他说“没有测试的代码会被设计破坏。”

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

测试有助于团队协作

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

基本测试策略

有很多方法可以处理写作测试。

一些程序员遵循一个叫做“测试驱动开发”的原则,他们实际上在编写代码之前就编写了测试。这可能看起来有悖常理,但实际上它与大多数人通常会做的事情相似:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在Python测试用例中将问题形式化。

更常见的是,新来的测试人员会创建一些代码,然后决定应该进行一些测试。也许早点写一些测试会更好,但现在开始还为时不晚。

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

所以我们现在就开始吧。

写我们的第一个测试

我们发现了一个错误

幸运的是,在 polls 申请我们立即修复:The Question.was_published_recently() 方法返回 True 如果 Question 在最后一天内发布(正确),但如果 Questionpub_date 领域在未来(当然不是)。

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

$ python manage.py shell
...\> 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

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

创建一个测试以暴露错误

我们刚刚做的 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() -哪个 应该 是假的。

运行试验

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

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

发生的事情是:

  • manage.py test polls 在中查找测试 polls 应用

  • 它发现了 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'...

在识别了一个bug之后,我们编写了一个测试来暴露它,并修正了代码中的bug,这样我们的测试就通过了。

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

更全面的测试

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

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

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测试客户端

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 ,在浏览器中加载网站,创建 Questions 包括过去和将来的日期,并检查是否只列出已发布的日期。你不想这样做 每次你做出任何可能影响这一点的改变 -因此,让我们也创建一个基于 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

我们所拥有的一切都很有效;然而,即使未来的问题不会出现在 指数 ,如果用户知道或猜测正确的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 并为该视图创建一个新的测试类。它将非常类似于我们刚刚创建的;事实上会有很多重复。

我们还可以通过其他方式改进我们的应用程序,一路添加测试。例如,这很愚蠢 Questions 可以在没有 Choices . 因此,我们的视图可以检查这一点,并排除 Questions . 我们的测试将创建一个 Question 没有 Choices 然后测试它是否未发布,并创建一个类似的 Question with Choices 并测试它 is 出版。

也许应该允许登录的管理员用户查看未发布的 Questions 但不是普通游客。再次说明:无论您是先编写测试,然后使代码通过测试,还是先计算代码中的逻辑,然后编写测试来证明这一点,为了完成这一点,需要添加到软件中的任何内容都应该附带一个测试。

在某一点上,您一定会查看您的测试,并怀疑您的代码是否受到测试膨胀的影响,这会使我们:

测试时,越多越好

我们的测试似乎越来越失控了。以这种速度,我们的测试中的代码将很快多于我们的应用程序中的代码,与我们其余代码的优雅简洁相比,重复是不美观的。

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

有时需要更新测试。假设我们修正我们的观点,使 Questions 具有 Choices 出版。在这种情况下,我们现有的许多测试都将失败- 告诉我们需要修改哪些测试以使其更新 因此,在某种程度上,测试有助于照顾自己。

最坏的情况是,当您继续开发时,您可能会发现有些测试现在是多余的。即使这不是问题;在测试冗余时 good 事情。

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

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

  • 要测试的每一组条件的单独测试方法

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

进一步测试

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

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

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

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

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

下一步是什么?

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

当您对测试django视图感到满意时,请阅读 part 6 of this tutorial 了解静态文件管理。