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

另請參閱