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

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

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

概述

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

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

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

本教程展示了如何为 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 中实现的内容。

但是,你应该检查用于标签的文本(First name, Last name, Date of birth, Died)以及为文本分配的字段大小(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

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

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

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

打开/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函数来测试条件是否为真、假或相等(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() 添加到您的测试中。

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

显示更多测试信息

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

bash
python3 manage.py test --verbosity 2

允许的verbosity 级别为 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。此类充当一个虚拟 Web 浏览器,我们可以使用它来模拟 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='John', last_name='Smith')
        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 bookitem in response.context['bookinstance_list']:
            self.assertEqual(response.context['user'], bookitem.borrower)
            self.assertEqual(bookitem.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='John', last_name='Smith')
        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数据。发布数据是发布函数的第二个参数,并作为键/值字典指定。

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

警告:所有借阅视图是作为挑战添加的,您的代码可能会重定向到主页 '/'。如果是这样,请修改测试代码的最后两行,使其类似于下面的代码。请求中的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 的测试中没有针对测试 HTML 输出是否按预期渲染的特定 API 支持。

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 网站。

另请参阅