Django 教程第 10 部分:测试 Django Web 应用程序

随着网站的增长,手动测试变得越来越困难。不仅要测试的内容更多,而且由于组件之间的交互变得更加复杂,一个领域中的微小更改可能会影响其他领域,因此需要进行更多更改以确保一切正常运行,并且随着更多更改的进行不会引入错误。解决这些问题的一种方法是编写自动化测试,这些测试可以在每次更改时轻松可靠地运行。本教程展示了如何使用 Django 的测试框架自动执行网站的单元测试

预备知识 完成所有以前的教程主题,包括Django 教程第 9 部分:使用表单
目标 了解如何为基于 Django 的网站编写单元测试。

概述

本地图书馆目前有页面显示所有书籍和作者的列表,BookAuthor 项目的详细视图,一个页面用于续借 BookInstance 项目,以及用于创建、更新和删除 Author 项目(以及 Book 记录,如果您完成了表单教程中的挑战)。即使是这个相对较小的网站,手动导航到每个页面并粗略检查一切是否按预期工作也可能需要几分钟。随着我们进行更改和扩展网站,手动检查一切是否“正常”所需的时间只会增加。如果我们继续这样下去,最终我们将把大部分时间花在测试上,而很少花时间改进代码。

自动化测试确实可以帮助解决这个问题!显而易见的好处是它们比手动测试运行得快得多,可以测试到更低的细节级别,并且每次都测试完全相同的功能(人类测试人员远没有那么可靠!)由于它们速度快,自动化测试可以更定期地执行,如果测试失败,它们会准确指出代码未按预期执行的位置。

此外,自动化测试可以作为您代码的第一个真实世界的“用户”,迫使您严格定义和记录您的网站应该如何表现。通常,它们是您代码示例和文档的基础。由于这些原因,一些软件开发过程从测试定义和实现开始,然后编写代码以匹配所需的行为(例如,测试驱动行为驱动开发)。

本教程展示了如何通过向 LocalLibrary 网站添加许多测试来为 Django 编写自动化测试。

测试类型

测试和测试方法有多种类型、级别和分类。最重要的自动化测试是

单元测试

验证单个组件的功能行为,通常达到类和函数级别。

回归测试

重现历史错误的测试。每个测试最初运行以验证错误已修复,然后重新运行以确保在后续代码更改后未重新引入。

集成测试

验证组件组合在一起时如何工作。集成测试知道组件之间所需的交互,但不一定知道每个组件的内部操作。它们可以涵盖简单的组件分组直到整个网站。

注意: 其他常见的测试类型包括黑盒、白盒、手动、自动化、金丝雀、冒烟、一致性、验收、功能、系统、性能、负载和压力测试。查找它们以获取更多信息。

Django 为测试提供了什么?

测试网站是一项复杂的任务,因为它由多层逻辑组成——从 HTTP 级别的请求处理,到模型查询,再到表单验证和处理,以及模板渲染。

Django 提供了一个测试框架,其中包含一个小型类层次结构,该层次结构建立在 Python 标准 unittest 库之上。尽管有这个名称,但这个测试框架适用于单元测试和集成测试。Django 框架添加了 API 方法和工具来帮助测试 Web 和 Django 特定的行为。这些允许您模拟请求、插入测试数据和检查应用程序的输出。Django 还提供了一个 API (LiveServerTestCase) 和工具,用于使用不同的测试框架,例如,您可以与流行的 Selenium 框架集成,以模拟用户与实时浏览器交互。

要编写测试,您需要从任何 Django(或 unittest)测试基类(SimpleTestCaseTransactionTestCaseTestCaseLiveServerTestCase)派生,然后编写单独的方法来检查特定功能是否按预期工作(测试使用“assert”方法来测试表达式是否产生 TrueFalse 值,或者两个值是否相等,等等)。当您启动测试运行时,框架会执行您派生类中选择的测试方法。测试方法独立运行,在类中定义了常见的设置和/或拆卸行为,如下所示。

python
class YourTestClass(TestCase):
    def setUp(self):
        # Setup run before every test method.
        pass

    def tearDown(self):
        # Clean up run after every test method.
        pass

    def test_something_that_will_pass(self):
        self.assertFalse(False)

    def test_something_that_will_fail(self):
        self.assertTrue(False)

大多数测试的最佳基类是 django.test.TestCase。此测试类在运行测试之前创建一个干净的数据库,并在其自己的事务中运行每个测试函数。该类还拥有一个测试 Client,您可以使用它来模拟用户在视图级别与代码交互。在以下部分中,我们将专注于使用此 TestCase 基类创建的单元测试。

注意: django.test.TestCase 类非常方便,但可能会导致某些测试比所需时间更长(并非每个测试都需要设置自己的数据库或模拟视图交互)。一旦您熟悉了该类的功能,您可能希望用可用的更简单的测试类替换一些测试。

您应该测试什么?

您应该测试您自己代码的所有方面,而不是作为 Python 或 Django 一部分提供的任何库或功能。

因此,例如,考虑下面定义的 Author 模型。您无需明确测试 first_namelast_name 是否已正确存储为数据库中的 CharField,因为这是由 Django 定义的(当然,在实践中,您在开发过程中不可避免地会测试此功能)。您也无需测试 date_of_birth 是否已验证为日期字段,因为这同样是在 Django 中实现的。

但是,您应该检查用于标签的文本(名字、姓氏、出生日期、逝世)以及为文本分配的字段大小(100 个字符),因为这些是您设计的一部分,并且将来可能会被破坏/更改。

python
class Author(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    date_of_birth = models.DateField(null=True, blank=True)
    date_of_death = models.DateField('Died', null=True, blank=True)

    def get_absolute_url(self):
        return reverse('author-detail', args=[str(self.id)])

    def __str__(self):
        return '%s, %s' % (self.last_name, self.first_name)

同样,您应该检查自定义方法 get_absolute_url()__str__() 是否按要求运行,因为它们是您的代码/业务逻辑。在 get_absolute_url() 的情况下,您可以相信 Django 的 reverse() 方法已正确实现,因此您测试的是关联视图实际上已定义。

注意: 敏锐的读者可能会注意到,我们还需要将出生日期和死亡日期限制在合理的值范围内,并检查死亡日期是否在出生日期之后。在 Django 中,此限制将添加到您的表单类中(尽管您可以为模型字段和模型验证器定义验证器,但如果模型调用了 clean() 方法,则这些验证器仅在表单级别使用。这需要 ModelForm,或者需要专门调用模型的 clean() 方法。)

考虑到这一点,让我们开始研究如何定义和运行测试。

测试结构概述

在我们深入探讨“测试什么”的细节之前,让我们首先简要了解一下测试的位置方式

Django 使用 unittest 模块的内置测试发现,它将在当前工作目录中发现任何名为 test*.py 的文件中的测试。只要您适当命名文件,您可以使用任何您喜欢的结构。我们建议您为测试代码创建一个模块,并为模型、视图、表单和您需要测试的任何其他类型的代码创建单独的文件。例如

catalog/
  /tests/
    __init__.py
    test_models.py
    test_forms.py
    test_views.py

在您的 LocalLibrary 项目中创建如上所示的文件结构。__init__.py 应该是一个空文件(这告诉 Python 该目录是一个包)。您可以通过复制和重命名骨架测试文件 /catalog/tests.py 来创建这三个测试文件。

注意: 骨架测试文件 /catalog/tests.py 是我们在构建 Django 骨架网站时自动创建的。将所有测试放入其中是完全“合法”的,但如果您正确测试,您很快就会得到一个非常大且难以管理的测试文件。

删除骨架文件,因为我们不需要它。

打开 /catalog/tests/test_models.py。该文件应导入 django.test.TestCase,如下所示

python
from django.test import TestCase

# Create your tests here.

通常,您会为要测试的每个模型/视图/表单添加一个测试类,并为测试特定功能提供单独的方法。在其他情况下,您可能希望有一个单独的类用于测试特定用例,并包含测试该用例各个方面的单独测试函数(例如,一个类用于测试模型字段是否已正确验证,并包含测试每个可能失败情况的函数)。同样,结构非常取决于您,但最好保持一致。

将下面的测试类添加到文件底部。该类演示了如何通过从 TestCase 派生来构造测试用例类。

python
class YourTestClass(TestCase):
    @classmethod
    def setUpTestData(cls):
        print("setUpTestData: Run once to set up non-modified data for all class methods.")
        pass

    def setUp(self):
        print("setUp: Run once for every test method to set up clean data.")
        pass

    def test_false_is_false(self):
        print("Method: test_false_is_false.")
        self.assertFalse(False)

    def test_false_is_true(self):
        print("Method: test_false_is_true.")
        self.assertTrue(False)

    def test_one_plus_one_equals_two(self):
        print("Method: test_one_plus_one_equals_two.")
        self.assertEqual(1 + 1, 2)

新类定义了两个可用于测试前配置的方法(例如,创建测试所需的任何模型或其他对象)

  • setUpTestData() 在测试运行开始时为类级别设置调用一次。您将使用它来创建在任何测试方法中都不会被修改或更改的对象。
  • setUp() 在每个测试函数之前调用,以设置可能被测试修改的任何对象(每个测试函数都将获得这些对象的“新鲜”版本)。

注意: 测试类还有一个我们未使用的 tearDown() 方法。此方法对于数据库测试不是特别有用,因为 TestCase 基类会为您处理数据库拆卸。

在这些下面,我们有许多测试方法,它们使用 Assert 函数来测试条件是否为 true、false 或相等(AssertTrueAssertFalseAssertEqual)。如果条件未按预期评估,则测试将失败并将错误报告到您的控制台。

AssertTrueAssertFalseAssertEqualunittest 提供的标准断言。框架中还有其他标准断言,以及Django 特定断言,用于测试视图是否重定向(assertRedirects),测试是否使用了特定模板(assertTemplateUsed)等。

注意: 通常不应在测试中包含 print() 函数,如上所示。我们这样做只是为了让您可以在控制台中看到设置函数的调用顺序(在下一节中)。

如何运行测试

运行所有测试的最简单方法是使用命令

bash
python3 manage.py test

这将在当前目录下发现所有名为 test*.py 的文件,并运行使用适当基类定义的所有测试(这里我们有许多测试文件,但只有 /catalog/tests/test_models.py 目前包含任何测试)。默认情况下,测试将单独仅报告测试失败,然后是测试摘要。

注意: 如果您收到类似 ValueError: Missing staticfiles manifest entry... 的错误,这可能是因为测试默认不运行 collectstatic,并且您的应用程序正在使用需要它的存储类(有关更多信息,请参阅manifest_strict)。有多种方法可以克服这个问题——最简单的方法是在运行测试之前运行 collectstatic

bash
python3 manage.py collectstatic

LocalLibrary 的根目录中运行测试。您应该会看到如下所示的输出。

bash
> python3 manage.py test

Creating test database for alias 'default'...
setUpTestData: Run once to set up non-modified data for all class methods.
setUp: Run once for every test method to set up clean data.
Method: test_false_is_false.
setUp: Run once for every test method to set up clean data.
Method: test_false_is_true.
setUp: Run once for every test method to set up clean data.
Method: test_one_plus_one_equals_two.
.
======================================================================
FAIL: test_false_is_true (catalog.tests.tests_models.YourTestClass)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\GitHub\django_tmp\library_w_t_2\locallibrary\catalog\tests\tests_models.py", line 22, in test_false_is_true
    self.assertTrue(False)
AssertionError: False is not true

----------------------------------------------------------------------
Ran 3 tests in 0.075s

FAILED (failures=1)
Destroying test database for alias 'default'...

在这里,我们看到我们有一个测试失败,我们可以准确地看到哪个函数失败以及为什么(这个失败是预期的,因为 False 不是 True!)。

注意: 从上面的测试输出中学习的最重要的事情是,如果您对对象和方法使用描述性/信息性名称,则它会更有价值。

print() 函数的输出显示了 setUpTestData() 方法是如何为类调用一次的,以及 setUp() 是在每个方法之前调用的。再次强调,请记住通常您不会在测试中添加此类 print()

接下来的部分将展示如何运行特定测试以及如何控制测试显示的信息量。

显示更多测试信息

如果您想获取有关测试运行的更多信息,可以更改详细程度。例如,要列出测试成功和失败(以及有关测试数据库设置的大量信息),您可以将详细程度设置为“2”,如下所示

bash
python3 manage.py test --verbosity 2

允许的详细程度级别为 0、1、2 和 3,默认值为“1”。

加速

如果您的测试是独立的,在多处理器机器上,您可以通过并行运行它们来显著加快速度。下面使用 --parallel auto 为每个可用核心运行一个测试进程。auto 是可选的,您还可以指定要使用的特定核心数。

bash
python3 manage.py test --parallel auto

有关更多信息,包括如果您的测试不独立该怎么办,请参阅DJANGO_TEST_PROCESSES

运行特定测试

如果您想运行测试的子集,可以通过指定包、模块、TestCase 子类或方法的完整点路径来实现

bash
# Run the specified module
python3 manage.py test catalog.tests

# Run the specified module
python3 manage.py test catalog.tests.test_models

# Run the specified class
python3 manage.py test catalog.tests.test_models.YourTestClass

# Run the specified method
python3 manage.py test catalog.tests.test_models.YourTestClass.test_one_plus_one_equals_two

其他测试运行器选项

测试运行器提供了许多其他选项,包括打乱测试(--shuffle)、在调试模式下运行它们(--debug-mode)以及使用 Python 记录器捕获结果的功能。有关更多信息,请参阅 Django 测试运行器文档。

LocalLibrary 测试

现在我们知道如何运行测试以及需要测试哪些类型的东西,让我们看一些实际的例子。

注意: 我们不会编写所有可能的测试,但这应该让您了解测试的工作原理以及您可以做更多的事情。

模型

如上所述,我们应该测试设计的一部分或我们编写的代码定义的所有内容,但不能测试 Django 或 Python 开发团队已经测试过的库/代码的行为。

例如,考虑下面的 Author 模型。在这里,我们应该测试所有字段的标签,因为即使我们没有明确指定大部分标签,我们也有一个设计说明这些值应该是什么。如果我们不测试这些值,那么我们不知道字段标签是否具有其预期值。同样,虽然我们相信 Django 会创建指定长度的字段,但为该长度指定测试以确保它按计划实现是值得的。

python
class Author(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    date_of_birth = models.DateField(null=True, blank=True)
    date_of_death = models.DateField('Died', null=True, blank=True)

    def get_absolute_url(self):
        return reverse('author-detail', args=[str(self.id)])

    def __str__(self):
        return f'{self.last_name}, {self.first_name}'

打开我们的 /catalog/tests/test_models.py,并将任何现有代码替换为以下用于 Author 模型的测试代码。

在这里您会看到我们首先导入 TestCase 并从中派生我们的测试类 (AuthorModelTest),使用一个描述性名称,以便我们可以在测试输出中轻松识别任何失败的测试。然后我们调用 setUpTestData() 来创建一个作者对象,我们将在任何测试中使用但不会修改它。

python
from django.test import TestCase

from catalog.models import Author

class AuthorModelTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Set up non-modified objects used by all test methods
        Author.objects.create(first_name='Big', last_name='Bob')

    def test_first_name_label(self):
        author = Author.objects.get(id=1)
        field_label = author._meta.get_field('first_name').verbose_name
        self.assertEqual(field_label, 'first name')

    def test_date_of_death_label(self):
        author = Author.objects.get(id=1)
        field_label = author._meta.get_field('date_of_death').verbose_name
        self.assertEqual(field_label, 'died')

    def test_first_name_max_length(self):
        author = Author.objects.get(id=1)
        max_length = author._meta.get_field('first_name').max_length
        self.assertEqual(max_length, 100)

    def test_object_name_is_last_name_comma_first_name(self):
        author = Author.objects.get(id=1)
        expected_object_name = f'{author.last_name}, {author.first_name}'
        self.assertEqual(str(author), expected_object_name)

    def test_get_absolute_url(self):
        author = Author.objects.get(id=1)
        # This will also fail if the URLConf is not defined.
        self.assertEqual(author.get_absolute_url(), '/catalog/author/1')

字段测试检查字段标签 (verbose_name) 的值以及字符字段的大小是否符合预期。这些方法都具有描述性名称,并遵循相同的模式

python
# Get an author object to test
author = Author.objects.get(id=1)

# Get the metadata for the required field and use it to query the required field data
field_label = author._meta.get_field('first_name').verbose_name

# Compare the value to the expected result
self.assertEqual(field_label, 'first name')

值得注意的有趣之处在于

  • 我们无法直接使用 author.first_name.verbose_name 获取 verbose_name,因为 author.first_name 是一个字符串(而不是我们可以用来访问其属性的 first_name 对象的句柄)。相反,我们需要使用作者的 _meta 属性来获取字段实例,并使用它来查询附加信息。
  • 我们选择使用 assertEqual(field_label,'first name') 而不是 assertTrue(field_label == 'first name')。这样做的原因是,如果测试失败,前者会告诉您标签实际上是什么,这使得调试问题变得更容易一些。

注意: last_namedate_of_birth 标签的测试,以及 last_name 字段长度的测试已省略。现在按照上面所示的命名约定和方法添加您自己的版本。

我们还需要测试我们的自定义方法。这些基本上只是检查对象名称是否按预期以“姓氏”、“名字”格式构建,以及我们为 Author 项目获得的 URL 是否符合预期。

python
def test_object_name_is_last_name_comma_first_name(self):
    author = Author.objects.get(id=1)
    expected_object_name = f'{author.last_name}, {author.first_name}'
    self.assertEqual(str(author), expected_object_name)

def test_get_absolute_url(self):
    author = Author.objects.get(id=1)
    # This will also fail if the URLConf is not defined.
    self.assertEqual(author.get_absolute_url(), '/catalog/author/1')

现在运行测试。如果您按照模型教程中的描述创建了 Author 模型,则很可能会收到 date_of_death 标签的错误,如下所示。测试失败是因为它期望标签定义遵循 Django 不将标签首字母大写的约定(Django 会为您完成此操作)。

bash
======================================================================
FAIL: test_date_of_death_label (catalog.tests.test_models.AuthorModelTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\...\locallibrary\catalog\tests\test_models.py", line 32, in test_date_of_death_label
    self.assertEqual(field_label,'died')
AssertionError: 'Died' != 'died'
- Died
? ^
+ died
? ^

这是一个非常小的错误,但它确实强调了编写测试如何更彻底地检查您可能做出的任何假设。

注意:date_of_death 字段的标签 (/catalog/models.py) 更改为“died”并重新运行测试。

测试其他模型的模式是相似的,所以我们不会再进一步讨论这些。您可以随意为我们的其他模型创建自己的测试。

表单

测试表单的理念与测试模型的理念相同;您需要测试您已编码或您的设计指定的所有内容,而不是底层框架和其他第三方库的行为。

通常,这意味着您应该测试表单是否具有您想要的字段,并且这些字段是否显示有适当的标签和帮助文本。您不需要验证 Django 是否正确验证了字段类型(除非您创建了自己的自定义字段和验证)——即,您不需要测试电子邮件字段是否只接受电子邮件。但是,您需要测试您期望对字段执行的任何额外验证以及您的代码将为错误生成的任何消息。

考虑我们用于续借书籍的表单。它只有一个续借日期字段,该字段将有一个我们需要验证的标签和帮助文本。

python
class RenewBookForm(forms.Form):
    """Form for a librarian to renew books."""
    renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

    def clean_renewal_date(self):
        data = self.cleaned_data['renewal_date']

        # Check if a date is not in the past.
        if data < datetime.date.today():
            raise ValidationError(_('Invalid date - renewal in past'))

        # Check if date is in the allowed range (+4 weeks from today).
        if data > datetime.date.today() + datetime.timedelta(weeks=4):
            raise ValidationError(_('Invalid date - renewal more than 4 weeks ahead'))

        # Remember to always return the cleaned data.
        return data

打开我们的 /catalog/tests/test_forms.py 文件,并将任何现有代码替换为以下用于 RenewBookForm 表单的测试代码。我们首先导入我们的表单以及一些 Python 和 Django 库,以帮助测试时间相关的功能。然后,我们以与模型相同的方式声明我们的表单测试类,为我们派生自 TestCase 的测试类使用描述性名称。

python
import datetime

from django.test import TestCase
from django.utils import timezone

from catalog.forms import RenewBookForm

class RenewBookFormTest(TestCase):
    def test_renew_form_date_field_label(self):
        form = RenewBookForm()
        self.assertTrue(form.fields['renewal_date'].label is None or form.fields['renewal_date'].label == 'renewal date')

    def test_renew_form_date_field_help_text(self):
        form = RenewBookForm()
        self.assertEqual(form.fields['renewal_date'].help_text, 'Enter a date between now and 4 weeks (default 3).')

    def test_renew_form_date_in_past(self):
        date = datetime.date.today() - datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_too_far_in_future(self):
        date = datetime.date.today() + datetime.timedelta(weeks=4) + datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_today(self):
        date = datetime.date.today()
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())

    def test_renew_form_date_max(self):
        date = timezone.localtime() + datetime.timedelta(weeks=4)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())

前两个函数测试字段的 labelhelp_text 是否符合预期。我们必须使用字段字典访问字段(例如,form.fields['renewal_date'])。请注意,在这里我们还必须测试标签值是否为 None,因为即使 Django 会渲染正确的标签,如果未明确设置值,它也会返回 None

其余函数测试表单对于可接受范围内的续借日期是否有效,对于范围外的值是否无效。请注意我们如何使用 datetime.timedelta()(在这种情况下指定天数或周数)围绕我们当前的日期 (datetime.date.today()) 构建测试日期值。然后我们只需创建表单,传入我们的数据,并测试它是否有效。

注意: 在这里我们实际上不使用数据库或测试客户端。考虑修改这些测试以使用 SimpleTestCase

我们还需要验证如果表单无效是否会引发正确的错误,但是这通常作为视图处理的一部分完成,因此我们将在下一节中处理它。

警告: 如果您使用 ModelFormRenewBookModelForm(forms.ModelForm) 而不是类 RenewBookForm(forms.Form),那么表单字段名称将是 'due_back' 而不是 'renewal_date'

表单就这些了;我们还有其他一些表单,但它们是由我们基于类的通用编辑视图自动创建的,应该在那里进行测试!运行测试并确认我们的代码仍然通过!

视图

为了验证我们的视图行为,我们使用 Django 测试 Client。这个类就像一个虚拟的网页浏览器,我们可以用它来模拟对 URL 的 GETPOST 请求并观察响应。我们可以看到响应的几乎所有内容,从低级 HTTP(结果头和状态码)到我们用于渲染 HTML 的模板以及我们传递给它的上下文数据。我们还可以看到重定向链(如果有),并检查每一步的 URL 和状态码。这允许我们验证每个视图是否按预期工作。

让我们从我们最简单的视图之一开始,它提供所有作者的列表。这显示在 URL /catalog/authors/ (URL 配置中名为“authors”的 URL)上。

python
class AuthorListView(generic.ListView):
    model = Author
    paginate_by = 10

由于这是一个通用列表视图,几乎所有事情都由 Django 为我们完成。可以说,如果您信任 Django,那么您唯一需要测试的就是该视图可以在正确的 URL 访问,并且可以使用其名称访问。但是,如果您正在使用测试驱动开发过程,您将首先编写测试,以确认该视图显示所有作者,并以 10 个为一组进行分页。

打开 /catalog/tests/test_views.py 文件,并将任何现有文本替换为以下用于 AuthorListView 的测试代码。和以前一样,我们导入我们的模型和一些有用的类。在 setUpTestData() 方法中,我们设置了一些 Author 对象,以便我们可以测试我们的分页。

python
from django.test import TestCase
from django.urls import reverse

from catalog.models import Author

class AuthorListViewTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Create 13 authors for pagination tests
        number_of_authors = 13

        for author_id in range(number_of_authors):
            Author.objects.create(
                first_name=f'Dominique {author_id}',
                last_name=f'Surname {author_id}',
            )

    def test_view_url_exists_at_desired_location(self):
        response = self.client.get('/catalog/authors/')
        self.assertEqual(response.status_code, 200)

    def test_view_url_accessible_by_name(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)

    def test_view_uses_correct_template(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'catalog/author_list.html')

    def test_pagination_is_ten(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertEqual(len(response.context['author_list']), 10)

    def test_lists_all_authors(self):
        # Get second page and confirm it has (exactly) remaining 3 items
        response = self.client.get(reverse('authors')+'?page=2')
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertEqual(len(response.context['author_list']), 3)

所有测试都使用客户端(属于我们 TestCase 的派生类)来模拟 GET 请求并获取响应。第一个版本检查一个特定的 URL(注意,只有特定的路径,没有域),而第二个版本从其在 URL 配置中的名称生成 URL。

python
response = self.client.get('/catalog/authors/')
response = self.client.get(reverse('authors'))

一旦我们有了响应,我们就会查询它的状态码、使用的模板、响应是否已分页、返回的项目数以及项目总数。

注意: 如果您在 /catalog/views.py 文件中将 paginate_by 变量设置为 10 以外的数字,请务必更新测试分页模板中显示正确项目数的行,包括在下面的部分中。例如,如果您将作者列表页面的变量设置为 5,请将上面的行更新为

python
self.assertTrue(len(response.context['author_list']) == 5)

我们上面演示的最有趣的变量是 response.context,它是视图传递给模板的上下文变量。这对于测试来说非常有用,因为它允许我们确认我们的模板正在获取所需的所有数据。换句话说,我们可以检查我们正在使用预期的模板以及模板正在获取哪些数据,这对于验证任何渲染问题仅是由于模板原因有很大帮助。

仅限于登录用户的视图

在某些情况下,您需要测试仅限于登录用户的视图。例如,我们的 LoanedBooksByUserListView 与我们之前的视图非常相似,但仅适用于登录用户,并且仅显示当前用户借阅的、状态为“已借出”且按“最旧优先”排序的 BookInstance 记录。

python
from django.contrib.auth.mixins import LoginRequiredMixin

class LoanedBooksByUserListView(LoginRequiredMixin, generic.ListView):
    """Generic class-based view listing books on loan to current user."""
    model = BookInstance
    template_name ='catalog/bookinstance_list_borrowed_user.html'
    paginate_by = 10

    def get_queryset(self):
        return BookInstance.objects.filter(borrower=self.request.user).filter(status__exact='o').order_by('due_back')

将以下测试代码添加到 /catalog/tests/test_views.py。在这里,我们首先使用 SetUp() 创建一些用户登录帐户和 BookInstance 对象(以及它们的关联书籍和其他记录),我们将在稍后的测试中使用它们。一半的书籍由每个测试用户借阅,但我们最初将所有书籍的状态设置为“维护”。我们使用 SetUp() 而不是 setUpTestData(),因为我们稍后会修改其中一些对象。

注意: 下面的 setUp() 代码创建了一本指定 Language 的书籍,但您的代码可能不包含 Language 模型,因为这是作为挑战创建的。如果是这种情况,请注释掉创建或导入 Language 对象的部分代码。您还应该在随后的 RenewBookInstancesViewTest 部分中这样做。

python
import datetime

from django.utils import timezone

# Get user model from settings
from django.contrib.auth import get_user_model
User = get_user_model()

from catalog.models import BookInstance, Book, Genre, Language

class LoanedBookInstancesByUserListViewTest(TestCase):
    def setUp(self):
        # Create two users
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')

        test_user1.save()
        test_user2.save()

        # Create a book
        test_author = Author.objects.create(first_name='Dominique', last_name='Rousseau')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )

        # Create genre as a post-step
        genre_objects_for_book = Genre.objects.all()
        test_book.genre.set(genre_objects_for_book) # Direct assignment of many-to-many types not allowed.
        test_book.save()

        # Create 30 BookInstance objects
        number_of_book_copies = 30
        for book_copy in range(number_of_book_copies):
            return_date = timezone.localtime() + datetime.timedelta(days=book_copy%5)
            the_borrower = test_user1 if book_copy % 2 else test_user2
            status = 'm'
            BookInstance.objects.create(
                book=test_book,
                imprint='Unlikely Imprint, 2016',
                due_back=return_date,
                borrower=the_borrower,
                status=status,
            )

    def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('my-borrowed'))
        self.assertRedirects(response, '/accounts/login/?next=/catalog/mybooks/')

    def test_logged_in_uses_correct_template(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)

        # Check we used correct template
        self.assertTemplateUsed(response, 'catalog/bookinstance_list_borrowed_user.html')

要验证如果用户未登录,视图是否会重定向到登录页面,我们使用 assertRedirects,如 test_redirect_if_not_logged_in() 中所示。要验证页面是否为登录用户显示,我们首先登录我们的测试用户,然后再次访问页面并检查我们是否获得 200 (成功) 的 status_code

其余测试验证我们的视图仅返回借给当前借阅者的书籍。复制以下代码并将其粘贴到上面的测试类末尾。

python
    def test_only_borrowed_books_in_list(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)

        # Check that initially we don't have any books in list (none on loan)
        self.assertTrue('bookinstance_list' in response.context)
        self.assertEqual(len(response.context['bookinstance_list']), 0)

        # Now change all books to be on loan
        books = BookInstance.objects.all()[:10]

        for book in books:
            book.status = 'o'
            book.save()

        # Check that now we have borrowed books in the list
        response = self.client.get(reverse('my-borrowed'))
        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)

        self.assertTrue('bookinstance_list' in response.context)

        # Confirm all books belong to testuser1 and are on loan
        for book_item in response.context['bookinstance_list']:
            self.assertEqual(response.context['user'], book_item.borrower)
            self.assertEqual(book_item.status, 'o')

    def test_pages_ordered_by_due_date(self):
        # Change all books to be on loan
        for book in BookInstance.objects.all():
            book.status='o'
            book.save()

        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))

        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)

        # Confirm that of the items, only 10 are displayed due to pagination.
        self.assertEqual(len(response.context['bookinstance_list']), 10)

        last_date = 0
        for book in response.context['bookinstance_list']:
            if last_date == 0:
                last_date = book.due_back
            else:
                self.assertTrue(last_date <= book.due_back)
                last_date = book.due_back

如果您愿意,还可以添加分页测试!

测试带表单的视图

测试带表单的视图比上述情况要复杂一些,因为您需要测试更多的代码路径:初始显示、数据验证失败后的显示以及验证成功后的显示。好消息是,我们使用客户端进行测试的方式与我们对仅显示视图进行测试的方式几乎完全相同。

为了演示,让我们为用于续借书籍的视图 (renew_book_librarian()) 编写一些测试

python
from catalog.forms import RenewBookForm

@permission_required('catalog.can_mark_returned')
def renew_book_librarian(request, pk):
    """View function for renewing a specific BookInstance by librarian."""
    book_instance = get_object_or_404(BookInstance, pk=pk)

    # If this is a POST request then process the Form data
    if request.method == 'POST':

        # Create a form instance and populate it with data from the request (binding):
        book_renewal_form = RenewBookForm(request.POST)

        # Check if the form is valid:
        if form.is_valid():
            # process the data in form.cleaned_data as required (here we just write it to the model due_back field)
            book_instance.due_back = form.cleaned_data['renewal_date']
            book_instance.save()

            # redirect to a new URL:
            return HttpResponseRedirect(reverse('all-borrowed'))

    # If this is a GET (or any other method) create the default form
    else:
        proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
        book_renewal_form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

    context = {
        'book_renewal_form': book_renewal_form,
        'book_instance': book_instance,
    }

    return render(request, 'catalog/book_renew_librarian.html', context)

我们需要测试该视图仅对具有 can_mark_returned 权限的用户可用,并且如果用户尝试续借不存在的 BookInstance,他们将被重定向到 HTTP 404 错误页面。我们应该检查表单的初始值是否在未来三周内设置了日期,并且如果验证成功,我们将被重定向到“所有已借书籍”视图。作为检查验证失败测试的一部分,我们还将检查我们的表单是否发送了适当的错误消息。

将测试类的第一部分(如下所示)添加到 /catalog/tests/test_views.py 的底部。这将创建两个用户和两个图书实例,但只给一个用户访问视图所需的权限。

python
import uuid

from django.contrib.auth.models import Permission # Required to grant the permission needed to set a book as returned.

class RenewBookInstancesViewTest(TestCase):
    def setUp(self):
        # Create a user
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')

        test_user1.save()
        test_user2.save()

        # Give test_user2 permission to renew books.
        permission = Permission.objects.get(name='Set book as returned')
        test_user2.user_permissions.add(permission)
        test_user2.save()

        # Create a book
        test_author = Author.objects.create(first_name='Dominique', last_name='Rousseau')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )

        # Create genre as a post-step
        genre_objects_for_book = Genre.objects.all()
        test_book.genre.set(genre_objects_for_book) # Direct assignment of many-to-many types not allowed.
        test_book.save()

        # Create a BookInstance object for test_user1
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance1 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user1,
            status='o',
        )

        # Create a BookInstance object for test_user2
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance2 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user2,
            status='o',
        )

将以下测试添加到测试类的底部。这些测试检查只有具有正确权限的用户(testuser2)才能访问视图。我们检查所有情况:用户未登录时、用户已登录但没有正确权限时、用户有权限但不是借阅者时(应该成功),以及他们尝试访问不存在的 BookInstance 时会发生什么。我们还检查是否使用了正确的模板。

python
   def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        # Manually check redirect (Can't use assertRedirect, because the redirect URL is unpredictable)
        self.assertEqual(response.status_code, 302)
        self.assertTrue(response.url.startswith('/accounts/login/'))

    def test_forbidden_if_logged_in_but_not_correct_permission(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 403)

    def test_logged_in_with_permission_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance2.pk}))

        # Check that it lets us login - this is our book and we have the right permissions.
        self.assertEqual(response.status_code, 200)

    def test_logged_in_with_permission_another_users_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))

        # Check that it lets us login. We're a librarian, so we can view any users book
        self.assertEqual(response.status_code, 200)

    def test_HTTP404_for_invalid_book_if_logged_in(self):
        # unlikely UID to match our bookinstance!
        test_uid = uuid.uuid4()
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk':test_uid}))
        self.assertEqual(response.status_code, 404)

    def test_uses_correct_template(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 200)

        # Check we used correct template
        self.assertTemplateUsed(response, 'catalog/book_renew_librarian.html')

添加下一个测试方法,如下所示。这检查表单的初始日期是否是未来三周。请注意我们如何能够访问表单字段初始值的值 (response.context['form'].initial['renewal_date'])

python
    def test_form_renewal_date_initially_has_date_three_weeks_in_future(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 200)

        date_3_weeks_in_future = datetime.date.today() + datetime.timedelta(weeks=3)
        self.assertEqual(response.context['form'].initial['renewal_date'], date_3_weeks_in_future)

下一个测试(也添加到类中)检查如果续借成功,视图是否重定向到所有借阅书籍的列表。这里的不同之处在于,我们第一次展示了如何使用客户端 POST 数据。POST 数据是 post 函数的第二个参数,并指定为键/值字典。

python
    def test_redirects_to_all_borrowed_book_list_on_success(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        valid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=2)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future})
        self.assertRedirects(response, reverse('all-borrowed'))

警告: all-borrowed 视图是作为挑战添加的,您的代码可能会改为重定向到主页 '/'。如果是这样,请将测试代码的最后两行修改为如下所示。请求中的 follow=True 确保请求返回最终目标 URL(因此检查 /catalog/ 而不是 /)。

python
 response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future}, follow=True)
 self.assertRedirects(response, '/catalog/')

将最后两个函数复制到类中,如下所示。这些再次测试 POST 请求,但在这种情况下,续借日期无效。我们使用 assertFormError() 来验证错误消息是否符合预期。

python
    def test_form_invalid_renewal_date_past(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        date_in_past = datetime.date.today() - datetime.timedelta(weeks=1)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': date_in_past})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response.context['form'], 'renewal_date', 'Invalid date - renewal in past')

    def test_form_invalid_renewal_date_future(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        invalid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=5)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': invalid_date_in_future})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response.context['form'], 'renewal_date', 'Invalid date - renewal more than 4 weeks ahead')

同样的技术可用于测试其他视图。

模板

Django 提供了测试 API 来检查您的视图是否正在调用正确的模板,并允许您验证是否正在发送正确的信息。然而,Django 中没有专门的 API 支持来测试您的 HTML 输出是否按预期呈现。

Django 的测试框架可以帮助您编写有效的单元测试和集成测试——我们只是触及了底层 unittest 框架功能的冰山一角,更不用说 Django 的附加功能了(例如,查看如何使用 unittest.mock 来修补第三方库,以便更彻底地测试您自己的代码)。

虽然您可以使用许多其他测试工具,但我们只强调两个

  • Coverage:这个 Python 工具报告您的测试实际执行了多少代码。当您刚开始并试图弄清楚您应该测试什么时,它特别有用。
  • Selenium 是一个在真实浏览器中自动化测试的框架。它允许您模拟真实用户与网站交互,并为您的网站进行系统测试(集成测试的下一步)提供了一个很好的框架。

挑战自我

我们还有很多模型和视图可以测试。作为一项挑战,尝试为 AuthorCreate 视图创建一个测试用例。

python
class AuthorCreate(PermissionRequiredMixin, CreateView):
    model = Author
    fields = ['first_name', 'last_name', 'date_of_birth', 'date_of_death']
    initial = {'date_of_death': '11/11/2023'}
    permission_required = 'catalog.add_author'

请记住,您需要检查您指定或设计中包含的所有内容。这包括谁有权访问、初始日期、使用的模板以及视图在成功时重定向到哪里。

您可以使用以下代码设置测试并为用户分配适当的权限

python
class AuthorCreateViewTest(TestCase):
    """Test case for the AuthorCreate view (Created as Challenge)."""

    def setUp(self):
        # Create a user
        test_user = User.objects.create_user(
            username='test_user', password='some_password')

        content_typeAuthor = ContentType.objects.get_for_model(Author)
        permAddAuthor = Permission.objects.get(
            codename="add_author",
            content_type=content_typeAuthor,
        )

        test_user.user_permissions.add(permAddAuthor)
        test_user.save()

总结

编写测试代码既不有趣也不光鲜,因此在创建网站时经常被搁置到最后(或根本不写)。然而,它是确保您的代码在更改后可以安全发布并且维护成本效益高的重要组成部分。

在本教程中,我们向您展示了如何为您的模型、表单和视图编写和运行测试。最重要的是,我们简要总结了您应该测试的内容,这通常是您刚开始时最难弄清楚的事情。还有很多知识需要了解,但即使有了您已经学到的知识,您也应该能够为您的网站创建有效的单元测试。

下一个也是最后一个教程将展示如何部署您的出色(且经过充分测试!)Django 网站。

另见