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

本教程从Tutorial 4中断的地方开始。 我们已经构建了一个Web轮询应用程序,现在我们将为它创建一些自动化测试。

引入自动化测试

什么是自动化测试?

测试是检查你的代码能否正常工作的简单程序

测试在不同的层面上运行。 一些测试可能适用于一个微小的细节一个特定的模型方法是否按照预期返回值?),而其他人检查软件的整体运作(在网站上的一系列用户输入是否产生期望的结果?)。 这与您之前在Tutorial 2中进行的那种测试没有什么不同,使用shell来检查方法的行为,或运行应用程序并输入数据以检查它的行为。

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

为什么你需要创建测试

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

你可能觉得自己刚刚学习Python / Django已经足够了,再去学习其他的东西也需要付出巨大的努力而且没有必要 毕竟,我们的民意调查应用程序现在已经活蹦乱跳了,将时间花在自动化测试,还不如改进它,让他更好的功能工作 如果创建民意测验应用程序是您将要做的Django编程的最后一部分,那么确实如此,您不需要知道如何创建自动化测试。 但是,如果情况并非如此,现在是学习的好时机。

测试会节省您的时间

在某种程度上,“检查看起来似乎正常工作”将是一个令人满意的测试。 在更复杂的应用程序中,组件之间可能有许多复杂的交互。

任何这些组件的变化都可能对应用程序的行为产生意想不到的后果。 检查它仍然“似乎工作”可能意味着运行你的代码的功能与你的测试数据的二十个不同的变化,只是为了确保你没有破的东西 - 不是一个很好的利用你的时间。

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

有时,把自己从创造性的编程工作中解放出来,面对写作测试这样毫无兴趣和无聊的事情,特别是当你知道你的代码工作正常的时候,看起来有点麻烦。

然而,编写测试的任务要比花费数小时手动测试应用程序或试图确定新引入的问题的原因更有成就感。

测试不仅仅是发现问题,而且还会阻止它们

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

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

测试改变了这一点;它们使你的代码内部变得明晰,当出现问题时,他们把注意力集中在出错的部分 - 即使你甚至没有意识到出错了。

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

你可能已经创建了一个精彩的软件,但是你会发现许多其他的开发者会拒绝看它,因为它缺乏测试;没有测试,他们不会相信它。 Django最初的开发人员之一雅各布·卡普兰·莫斯(Jacob Kaplan-Moss)说:“没有测试的代码被设计破坏了。

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

测试帮助团队一起工作

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

基本的测试策略

编写测试有很多方法。

一些程序员遵循“测试驱动开发”的规定;他们在编写代码之前先去编写测试。 这看起来可能与直觉相反,但实际上它就是大多数所做的:人们先描述一个问题,然后创建一些代码来解决问题。 测试驱动的开可以用Python测试用例将这个问题形式化。

更多的时候,测试的新手会创建一些代码,然后决定应该进行一些测试。 也许早些时候写一些测试会更好,但是什么时候开始都不晚。

有时候很难确定从哪里开始编写测试。 如果你已经写了几千行Python,选择一些测试可能并不容易。 在这种情况下,当您下次进行更改时,无论是添加新功能还是修复错误,都可以编写第一个测试。

所以我们现在就去做。

编写我们的第一个测试

我们确定一个bug

幸运的是polls应用中有一个小bug让我们可以马上修复他 : 如果Question.是在最后一天发布的,Question.was_published_recently() 函数返回True (实际是对的),但是如果 Question 是未来几天后发布的 Questionpub_date ,它还是返回True(实际并不应该)

为了检查错误是否真的存在,使用Admin创建一个日期在将来的问题,并使用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

我们刚刚在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 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。

运行测试

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

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

发生了什么是这样的:

  • python manage.py test polls looked for tests in the polls application
  • 它找到了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'...

在确定了一个错误之后,我们写了一个测试,将其公开并纠正了代码中的错误,以便我们的测试通过。

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

更全面的测试

当我们在这里的时候,我们可以进一步把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()为过去,近期和将来的问题返回了合理的值。

Again, polls is a simple application, but however complex it grows in the future and whatever other code it interacts with, we now have some guarantee that the method we have written tests for will behave in expected ways.

测试视图

The polls application is fairly undiscriminating: it will publish any question, including ones whose pub_date field lies in the future. 我们应该改善这一点。 将来设置pub_date应该意味着问题在那个时候发布,但是直到那个时候才能看到。

视图的测试

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

在我们的第一个测试中,我们密切关注代码的内部行为。 对于这个测试,我们要检查它的行为,就像用户通过网络浏览器所经历的那样。

在我们尝试解决任何问题之前,让我们来看看我们可以使用的工具。

Django测试客户端

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

我们将再次从shell开始,在那里我们需要做一些在tests.py中不需要的东西。 首先是在shell中设置测试环境:

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() installs a template renderer which will allow us to examine some additional attributes on responses such as response.context that otherwise wouldn’t be available. 请注意,此方法不会设置测试数据库,因此将针对现有数据库运行以下内容,并且根据您已创建的问题,输出可能略有不同。 如果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是未来的民意调查)。 我们来解决这个问题。

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()) r返回一个查询集,包括pub_date 小于或者等于timezone.nowQuestion

测试我们的新视图

现在,您可以通过启动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.
        """
        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.>']
        )

我们来仔细看看其中的一些。

第一个是Question的快捷函数 create_question, 将重复创建Question的过程封装在一起

test_no_questions不会产生任何问题,但会检查消息:“没有民意调查可用”,并验证latest_question_list是否为空。 请注意,django.test.TestCase类提供了一些额外的断言方法。 In these examples, we use assertContains() and 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 Our tests would create a Question without Choices and then test that it’s not published, as well as create a similar Question with Choices, and test that it is published.

也许登录的管理员用户应该被允许看到未发布的Questions,但不是普通的访问者。 同样,无论需要添加到软件来完成这个任务,都应该伴随着一个测试,不管你先写测试,然后让代码通过测试,或者先编写代码中的逻辑,然后再写一个测试证明给我看。

在某个时候,你一定要看看你的测试,并想知道你的代码是否正在遭受测试膨胀的困扰,这使我们得到:

测试越多越好

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

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

有时测试需要更新。 假设我们修改了我们的视图,使得只有带ChoicesQuestions被发布。 在这种情况下,我们现有的许多测试将会失败 - 确切地告诉我们哪些测试需要修改以使其更新,因此测试可以帮助您自行管理。

最糟糕的是,随着你继续发展,你可能会发现你有一些测试现在是多余的。 Even that’s not a problem; in testing redundancy is a good thing.

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

  • 每个模型或视图都有一个独立的TestClass
  • 针对您要测试的每组条件的单独测试方法
  • 描述其功能的测试方法名称

进一步测试

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

例如,虽然我们的测试已经涵盖了模型的一些内部逻辑以及视图发布信息的方式,但您可以使用“浏览器内”框架(如Selenium)来测试您的HTML实际上在浏览器中呈现。 这些工具可以让你检查你的Django代码的行为,也可以检查你的JavaScript。 看到测试启动一个浏览器,并开始与您的网站互动,就好像一个人正在驾驶它! Django includes LiveServerTestCase to facilitate integration with tools like Selenium.

如果你有一个复杂的应用程序,你可能希望每次提交都会自动运行测试以达到持续集成的目的,所以质量控制本身 - 至少部分是自动的。

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

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

接下来是什么?

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

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