撰寫你的第一個 Django 應用程式,第五部分

本教學課程接續 教學課程 4 的進度。我們已經建置了一個網路投票應用程式,現在將為其建立一些自動化測試。

何處取得協助

如果您在學習本教學課程時遇到問題,請前往 FAQ 的 取得協助 章節。

自動化測試簡介

什麼是自動化測試?

測試是檢查程式碼運作狀況的常式。

測試在不同的層級運作。有些測試可能適用於微小的細節(特定模型方法是否傳回預期的值?),而其他測試則檢查軟體的整體運作狀況(網站上的一連串使用者輸入是否產生預期的結果?)。這與您在 教學課程 2 中使用 shell 來檢查方法的行為,或執行應用程式並輸入資料以檢查其行為的方式沒有什麼不同。

自動化測試的不同之處在於,測試工作是由系統為您完成的。您建立一組測試一次,然後在您對應用程式進行變更時,可以檢查您的程式碼是否仍然按照您最初的意圖運作,而無需執行耗時的手動測試。

為什麼您需要建立測試

那麼,為什麼要建立測試,而且為什麼是現在?

您可能會覺得您已經有足夠的 Python/Django 學習內容了,而又要學習和做另一件事似乎讓人不知所措,而且可能沒有必要。畢竟,我們的投票應用程式現在運作得很好;費力建立自動化測試並不會讓它運作得更好。如果建立投票應用程式是您最後一次進行 Django 程式設計,那麼確實,您不需要知道如何建立自動化測試。但是,如果不是這種情況,那麼現在是學習的絕佳時機。

測試將節省您的時間

在某個程度上,「檢查它似乎是否正常運作」將是一個令人滿意的測試。在更複雜的應用程式中,您可能會有數十個元件之間的複雜互動。

任何這些元件的變更都可能會對應用程式的行為產生意想不到的後果。檢查它是否仍然「似乎正常運作」可能表示要使用 20 種不同的測試資料變體執行程式碼的功能,以確保您沒有破壞任何東西 - 這並不是善用您的時間。

當自動化測試可以在幾秒鐘內為您完成此操作時,情況尤其如此。如果出現問題,測試也將協助識別導致意外行為的程式碼。

有時,當您知道您的程式碼運作正常時,將自己從富有成效的創造性程式設計工作中抽離出來,面對撰寫測試這種不吸引人且不令人興奮的工作,似乎是一種苦差事。

然而,撰寫測試的工作比花費數小時手動測試您的應用程式或試圖找出新引入問題的原因要令人滿足得多。

測試不僅會識別問題,還會預防問題

將測試僅視為開發的負面面向是錯誤的。

沒有測試,應用程式的目的或預期行為可能會相當模糊。即使是您自己的程式碼,您有時也會發現自己在其中摸索,試圖找出它到底在做什麼。

測試改變了這種情況;它們從內部照亮您的程式碼,當出現問題時,它們會將光線聚焦在出現問題的部分 - 即使您甚至沒有意識到它出了問題

測試讓您的程式碼更具吸引力

您可能已經建立了一個出色的軟體,但您會發現許多其他開發人員會拒絕查看它,因為它缺少測試;沒有測試,他們將不會信任它。Django 的原始開發人員之一 Jacob Kaplan-Moss 說:「沒有測試的程式碼在設計上就是壞的。」

其他開發人員希望在您的軟體中看到測試,然後才會認真看待它,這也是您開始撰寫測試的另一個原因。

測試有助於團隊合作

前面的觀點是從單一開發人員維護應用程式的角度撰寫的。複雜的應用程式將由團隊維護。測試保證同事不會在不知不覺中破壞您的程式碼(並且您不會在不知情的情況下破壞他們的程式碼)。如果您想以 Django 程式設計師為生,您必須擅長撰寫測試!

基本測試策略

有很多方法可以處理撰寫測試。

一些程式設計師遵循一種稱為「測試驅動開發」的學科;他們實際上是在撰寫程式碼之前撰寫測試。這可能看起來違反直覺,但實際上它與大多數人通常會做的事情類似:他們描述一個問題,然後建立一些程式碼來解決它。測試驅動開發在 Python 測試案例中正式化問題。

更常見的是,測試的新手會建立一些程式碼,然後決定它應該有一些測試。也許早點撰寫一些測試會更好,但開始永遠不會太晚。

有時很難弄清楚從哪裡開始撰寫測試。如果您已經撰寫了數千行 Python 程式碼,則選擇要測試的內容可能並不容易。在這種情況下,在您下次進行變更時撰寫您的第一個測試會很有成效,無論是當您新增新功能或修正錯誤時。

那麼,讓我們馬上這樣做。

撰寫我們的第一個測試

我們發現一個錯誤

幸運的是,polls 應用程式中存在一個小錯誤,讓我們立即修正:如果 Question 在過去一天內發佈(這是正確的),則 Question.was_published_recently() 方法會傳回 True,但如果 Questionpub_date 欄位在未來(這當然不是)也會傳回 True

使用 shell 檢查日期在未來的問題的方法,來確認錯誤

$ python manage.py shell
...\> py manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

由於未來的內容不是「最近的」,這顯然是錯誤的。

建立一個測試來暴露錯誤

我們剛剛在 shell 中所做的測試問題的操作,正是我們可以在自動化測試中執行的操作,因此讓我們將其轉換為自動化測試。

應用程式測試的傳統位置在應用程式的 tests.py 檔案中;測試系統會自動在任何名稱以 test 開頭的檔案中尋找測試。

將以下內容放入 polls 應用程式中的 tests.py 檔案中

polls/tests.py
import datetime

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

from .models import Question


class QuestionModelTests(TestCase):
    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns False for questions whose pub_date
        is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

在這裡,我們建立了一個 django.test.TestCase 子類別,其中包含一個方法,該方法會建立一個 pub_date 在未來的 Question 實例。然後,我們檢查 was_published_recently() 的輸出 - 應該 為 False。

執行測試

在終端機中,我們可以執行我們的測試

$ python manage.py test polls
...\> py manage.py test polls

您會看到類似以下的內容

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/djangotutorial/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

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

不同的錯誤?

如果這裡出現 NameError,您可能在第二部分中遺漏了一個步驟,該步驟我們將 datetimetimezone 的導入添加到 polls/models.py。請從該部分複製導入語句,然後再次執行測試。

發生了以下狀況:

  • manage.py test pollspolls 應用程式中尋找測試

  • 它找到了一個 django.test.TestCase 類別的子類別

  • 它建立了一個專門用於測試的資料庫

  • 它尋找測試方法 — 名稱以 test 開頭的方法

  • test_was_published_recently_with_future_question 中,它建立了一個 Question 實例,其 pub_date 欄位是未來 30 天

  • ... 然後使用 assertIs() 方法,它發現其 was_published_recently() 回傳 True,但我們希望它回傳 False

測試會通知我們哪個測試失敗,甚至是發生失敗的行號。

修正錯誤

我們已經知道問題是什麼:如果 Question.was_published_recently()pub_date 在未來,則應該回傳 False。修改 models.py 中的方法,使其僅在日期也在過去時才回傳 True

polls/models.py
def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

然後再次執行測試

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

在識別出錯誤後,我們編寫了一個測試來暴露它,並在程式碼中修正了該錯誤,以便我們的測試通過。

未來我們的應用程式可能會發生許多其他錯誤,但我們可以確定我們不會在無意中重新引入這個錯誤,因為執行測試會立即警告我們。我們可以認為應用程式的這個小部分已永遠安全地固定下來。

更全面的測試

當我們在這裡時,我們可以進一步固定 was_published_recently() 方法;事實上,如果在修正一個錯誤時引入了另一個錯誤,那將會非常尷尬。

將另外兩個測試方法添加到同一個類別中,以更全面地測試該方法的行為

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() returns False for questions whose pub_date
    is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)


def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

現在我們有三個測試可以確認 Question.was_published_recently() 對過去、最近和未來的問題回傳合理的數值。

再次,polls 是一個最小的應用程式,但無論未來它變得多麼複雜,以及它與其他哪些程式碼互動,我們現在都有一些保證,我們為其編寫測試的方法將以預期的方式運作。

測試視圖

投票應用程式相當不加區分:它將發布任何問題,包括 pub_date 欄位位於未來的問題。我們應該改進這一點。在未來設定 pub_date 應該表示問題在該時刻發布,但在那之前都是不可見的。

視圖的測試

當我們修正上述錯誤時,我們首先編寫測試,然後編寫程式碼來修正它。事實上,這是一個測試驅動開發的範例,但我們以什麼順序完成工作並不重要。

在我們的第一個測試中,我們密切關注了程式碼的內部行為。對於這個測試,我們希望檢查其行為,就像使用者透過網頁瀏覽器體驗到的那樣。

在我們嘗試修正任何問題之前,讓我們看一下我們可用的工具。

Django 測試客戶端

Django 提供了一個測試 Client 來模擬使用者在視圖層級與程式碼互動。我們可以在 tests.py 中或甚至在 shell 中使用它。

我們將再次從 shell 開始,我們需要在 shell 中執行一些在 tests.py 中不需要執行的操作。首先是在 shell 中設定測試環境

$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() 安裝了一個範本渲染器,它將允許我們檢查回應中的一些額外屬性,例如 response.context,否則這些屬性將不可用。請注意,此方法不會設定測試資料庫,因此以下程式碼將針對現有資料庫執行,並且輸出可能會因您已建立的問題而略有不同。如果您的 settings.py 中的 TIME_ZONE 不正確,您可能會得到意外的結果。如果您不記得稍早設定它,請在繼續之前檢查它。

接下來,我們需要匯入測試客戶端類別(稍後在 tests.py 中,我們將使用 django.test.TestCase 類別,它帶有自己的客戶端,因此這將不是必需的)

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

準備好後,我們可以要求客戶端為我們做一些工作

>>> # get a response from '/'
>>> response = client.get("/")
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse("polls:index"))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context["latest_question_list"]
<QuerySet [<Question: What's up?>]>

改進我們的視圖

投票列表顯示尚未發布的投票(即 pub_date 在未來的投票)。讓我們修正這個問題。

教學課程 4中,我們引入了一個基於 ListView 的基於類別的視圖

polls/views.py
class IndexView(generic.ListView):
    template_name = "polls/index.html"
    context_object_name = "latest_question_list"

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by("-pub_date")[:5]

我們需要修改 get_queryset() 方法,並將其變更為透過與 timezone.now() 比較來檢查日期。首先,我們需要新增一個匯入語句

polls/views.py
from django.utils import timezone

然後我們必須像這樣修改 get_queryset 方法

polls/views.py
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(pub_date__lte=timezone.now()).order_by("-pub_date")[
        :5
    ]

Question.objects.filter(pub_date__lte=timezone.now()) 回傳一個包含 Question 的查詢集,其 pub_date 小於或等於 — 也就是說,早於或等於 — timezone.now

測試我們的新視圖

現在您可以自行驗證其行為是否如預期,方法是啟動 runserver、在瀏覽器中載入網站、建立具有過去和未來日期的 Questions,並檢查是否僅列出已發布的 Questions。您不希望每次進行任何可能會影響它的變更時都必須這樣做 - 因此,我們也根據上面我們的 shell 工作階段建立一個測試。

將以下內容新增到 polls/tests.py

polls/tests.py
from django.urls import reverse

然後我們將建立一個捷徑函式來建立問題,以及一個新的測試類別

polls/tests.py
def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is displayed.
        """
        response = self.client.get(reverse("polls:index"))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerySetEqual(response.context["latest_question_list"], [])

    def test_past_question(self):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        question = create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question],
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse("polls:index"))
        self.assertContains(response, "No polls are available.")
        self.assertQuerySetEqual(response.context["latest_question_list"], [])

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        question = create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question],
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        question1 = create_question(question_text="Past question 1.", days=-30)
        question2 = create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [question2, question1],
        )

讓我們更仔細地看一下其中一些。

首先是一個問題捷徑函式 create_question,用於從建立問題的過程中刪除一些重複的操作。

test_no_questions 不會建立任何問題,但會檢查訊息:「目前沒有投票可用。」並驗證 latest_question_list 為空。請注意,django.test.TestCase 類別提供了一些額外的斷言方法。在這些範例中,我們使用 assertContains()assertQuerySetEqual()

test_past_question 中,我們建立一個問題並驗證它是否出現在列表中。

test_future_question 中,我們建立一個 pub_date 設定為未來的問題。每次測試方法都會重置資料庫,因此第一個問題就不會存在,所以索引中也不應該有任何問題。

以此類推。實際上,我們使用測試來描述管理員輸入和使用者在網站上的體驗,並檢查系統的每個狀態以及每個新狀態變更時,是否發布了預期的結果。

測試 DetailView

我們目前的功能運作良好;然而,即使未來的問題不會出現在*索引*中,如果使用者知道或猜到正確的 URL,他們仍然可以存取這些問題。因此,我們需要為 DetailView 加入類似的限制。

polls/views.py
class DetailView(generic.DetailView):
    ...

    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

然後我們應該添加一些測試,以檢查 pub_date 在過去的 Question 是否可以顯示,以及 pub_date 在未來的 Question 是否無法顯示。

polls/tests.py
class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        The detail view of a question with a pub_date in the future
        returns a 404 not found.
        """
        future_question = create_question(question_text="Future question.", days=5)
        url = reverse("polls:detail", args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
        The detail view of a question with a pub_date in the past
        displays the question's text.
        """
        past_question = create_question(question_text="Past Question.", days=-5)
        url = reverse("polls:detail", args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

更多測試的想法

我們應該為 ResultsView 添加類似的 get_queryset 方法,並為該視圖建立一個新的測試類別。它會與我們剛建立的非常相似;事實上,會有許多重複之處。

我們也可以透過其他方式改進我們的應用程式,並在過程中添加測試。例如,Questions 沒有 Choices 卻可以在網站上發布是很荒謬的。因此,我們的視圖可以檢查這一點,並排除此類 Questions。我們的測試會建立一個沒有 ChoicesQuestion,然後測試它是否未發布,並建立一個類似*有* ChoicesQuestion,並測試它是否*已*發布。

或許已登入的管理員使用者應該可以查看未發布的 Questions,但普通訪客則不行。再次強調:無論為了達成此目標需要向軟體新增什麼,都應該伴隨一個測試,無論您是先撰寫測試然後讓程式碼通過測試,還是先在程式碼中找出邏輯然後撰寫測試來證明它。

在某個時間點,您一定會看著您的測試,並想知道您的程式碼是否正在遭受測試膨脹之苦,這就引導我們討論到

測試時,越多越好

我們的測試似乎正在失控增長。照這樣的速度,很快地,我們的測試程式碼會比應用程式的程式碼還多,而且與程式碼其餘部分的優雅簡潔相比,重複性是不美觀的。

這並不重要。讓它們增長。在大多數情況下,您可以撰寫一次測試,然後忘記它。當您繼續開發程式時,它將繼續執行其有用的功能。

有時測試需要更新。假設我們修改視圖,以便僅發布具有 ChoicesQuestions。在這種情況下,我們許多現有的測試會失敗 - *準確地告訴我們需要修改哪些測試才能使它們保持最新*,因此在某種程度上,測試有助於自我維護。

在最壞的情況下,當您繼續開發時,您可能會發現您有一些現在多餘的測試。即使那也不是問題;在測試中,冗餘是*好事*。

只要您的測試安排合理,它們就不會變得難以管理。良好的經驗法則包括:

  • 每個模型或視圖都有一個單獨的 TestClass

  • 針對您想要測試的每組條件,都有一個單獨的測試方法

  • 測試方法名稱描述它們的功能

進一步測試

本教學僅介紹了一些測試基礎知識。您可以做的事情還有很多,並且您可以利用許多非常有用的工具來實現一些非常聰明的事情。

例如,雖然我們這裡的測試涵蓋了模型的一些內部邏輯以及視圖發布資訊的方式,但您可以使用「瀏覽器內」框架(例如 Selenium)來測試 HTML 在瀏覽器中的實際呈現方式。這些工具不僅允許您檢查 Django 程式碼的行為,還可以檢查 JavaScript 的行為。看到測試啟動瀏覽器並開始與您的網站互動,就好像有人在駕駛它一樣,這是一件很了不起的事情!Django 包含 LiveServerTestCase,以促進與 Selenium 等工具的整合。

如果您的應用程式很複雜,您可能希望每次提交時自動執行測試,以進行 持續整合,以便品質管制本身(至少部分)自動化。

找出應用程式中未經測試的部分的一個好方法是檢查程式碼覆蓋率。這也有助於識別脆弱甚至無用的程式碼。如果您無法測試一段程式碼,通常表示該程式碼應該被重構或刪除。覆蓋率將有助於識別無用的程式碼。有關詳細資訊,請參閱 與 coverage.py 整合

Django 中的測試 提供了有關測試的全面資訊。

下一步是什麼?

有關測試的完整詳細資訊,請參閱 Django 中的測試

當您熟悉測試 Django 視圖時,請閱讀本教學的第 6 部分,以了解靜態檔案管理。

返回頂部