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

另見