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

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

一、自动化测试简介

1.什么是自动化测试?

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

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

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

 

2.为什么需要创建测试?

你可能会觉得你已经足够多了学习Python/Django,还有另一件事要学习和做,这似乎让人不知所措,也许是不必要的。毕竟,我们的民意调查应用程序现在运行得非常愉快;经历创建自动化测试的麻烦不会让它工作得更好。如果创建民意调查应用程序是你将要做的 Django 编程的最后一点,那么确实,你不需要知道如何创建自动化测试。但是,如果不是这样,现在是学习的绝佳时机。

测试将节省您的时间。

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

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

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

有时,将自己从富有成效、创造性的编程工作中抽离出来,面对编写测试的平淡无奇和乏味的业务,这似乎是一件苦差事,尤其是当您知道自己的代码工作正常时。

但是,编写测试的任务比花费数小时手动测试应用程序或尝试确定新引入的问题的原因要充实得多。

 

3.测试不仅可以识别问题,还可以防止问题

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

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

测试改变了这一点;它们从内部点亮你的代码,当出现问题时,它们会把注意力集中在出错的部分——即使你甚至没有意识到它已经出错了。

 

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

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

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

 

5.测试帮助团队协同工作

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

 

二、基本测试策略

有很多方法可以进行写作测试。

一些程序员遵循一门名为“ 测试驱动开发 ”;他们实际上在编写代码之前编写测试。这似乎有悖常理,但实际上它类似于大多数人经常做的事情:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在 Python 测试用例中形式化问题。

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

有时很难弄清楚从哪里开始编写测试。如果你已经写了几千行Python,选择测试一些东西可能并不容易。

在这种情况下,在下次进行更改时编写第一个测试是富有成效的,无论是在添加新功能还是修复错误时。

因此,让我们立即执行此操作。

 

三、编写我们的第一个测试

1. 我们识别了一个错误。

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

通过使用 shell 检查日期为将来的问题的方法来确认错误:

$ python 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

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

 

2.创建一个测试来暴露错误

我们刚刚在 shell 中为测试问题所做的正是我们在自动测试中可以做的事情,因此让我们将其转换为自动测试。

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

将以下内容放在 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 子类,该方法在将来创建一个带有 pub_date 的 Question 实例。

然后我们检查 was_published_recently() 的输出 - 它应该是 False。

3. 运行测试 

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


$ 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 "/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'...

发生的事情是这样的:

  • 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

测试会通知我们哪个测试失败,甚至通知发生故障的行。

4.  修复错误

我们已经知道问题是什么:如果将来有 pub_date , Question.was_published_recently() 应该返回 False 。

修改 models.py 中的方法,以便仅当日期也是过去时返回 True :

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'...

确定错误后,我们编写了一个测试来公开它并更正代码中的错误,以便我们的测试通过。

将来我们的应用程序可能会出现许多其他问题,但我们可以确定我们不会无意中重新引入此错误,因为运行测试会立即警告我们。

我们可以认为应用程序的这一小部分永远安全地固定下来。

5. 更全面的测试

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

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

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

6. 测试视图

民意调查应用程序相当无差别:它将发布任何问题,包括将来 pub_date 字段所在的问题。我们应该改善这一点。将来设置 pub_date 应该意味着问题在那一刻发布,但在此之前不可见。

7. 视图测试 

当我们修复上面的错误时,我们首先编写了测试,然后编写了修复它的代码。事实上,这是测试驱动开发的一个例子,但我们以什么顺序完成工作并不重要。

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

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

8. Django 测试客户端 

Django 提供了一个测试 Client 来模拟用户在视图级别与代码交互。我们可以在 tests.py 甚至 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()

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

>>> # 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?>]>

9. 改进我们的视图

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

在教程 4 中,我们引入了基于 ListView 的基于类的视图:

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



然后我们必须像这样修改 get_queryset 方法:

from django.utils import timezone

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 。

10. 测试我们的新视图

现在,您可以通过启动 runserver ,在浏览器中加载站点,使用过去和将来的日期创建 Questions ,并检查是否仅列出已发布的日期,从而使自己的行为符合预期。

您不希望每次进行可能影响此操作的任何更改时都必须这样做 - 因此,让我们也根据上面的 shell 会话创建一个测试。

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

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

from django.urls import reverse

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 的问题。数据库会针对每个测试方法重置,因此第一个问题不再存在,因此索引中不应包含任何问题。

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

11. 测试 DetailView 

我们所拥有的运作良好;但是,即使将来的问题不会出现在索引中,如果用户知道或猜到了正确的网址,他们仍然可以联系到这些问题。

所以我们需要向 DetailView 添加一个类似的约束:

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 的 pub_date 的测试:

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)

 

12. 更多测试的想法 

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

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

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

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

13. 测试时,越多越好

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

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

有时测试需要更新。假设我们修改视图,以便仅发布带有 Choices 的 Questions 。在这种情况下,我们现有的许多测试都会失败 - 确切地告诉我们哪些测试需要修改以使其更新,因此在某种程度上测试有助于照顾自己。

在最坏的情况下,随着您继续开发,您可能会发现您有一些现在多余的测试。即使这不是问题;在测试冗余是一件好事。

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

  • 每个模型或视图的单独 TestClass
  • 要测试的每组条件的单独测试方法
  • 描述其功能的测试方法名称

14. 进一步测试

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

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

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

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

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

15. 下一步是什么?

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

当你熟悉测试 Django 视图时,请阅读本教程的第 6 部分以了解静态文件管理。

--------------------------------end--------------------------------
 
posted @ 2023-04-20 10:29  PandaCode辉  阅读(21)  评论(0编辑  收藏  举报