1. 程式人生 > >第一個 Django 應用(第 5 部分)

第一個 Django 應用(第 5 部分)

第一個 Django 應用,第 5 部分 (測試)

這一篇從 教程第 4 部分 結尾的地方繼續講起。我們在前幾章成功的構建了一個線上投票應用,在這一部分裡我們將為它建立一些自動化測試。

自動化測試簡介

自動化測試是什麼?

測試,是用來檢查程式碼正確性的一些簡單的程式。

測試在不同的層次中都存在。有些測試只關注某個很小的細節(某個模型的某個方法的返回值是否滿足預期?),而另一些測試可能檢查對某個軟體的一系列操作(某一使用者輸入序列是否造成了預期的結果?)。其實這和我們在 教程第 2 部分,裡做的並沒有什麼不同,我們使用 shell 來測試某一方法的功能,或者執行某個應用並輸入資料來檢查它的行為。

真正不同的地方在於,自動化 測試是由某個系統幫你自動完成的。當你建立好了一系列測試,每次修改應用程式碼後,就可以自動檢查出修改後的程式碼是否還像你曾經預期的那樣正常工作。你不需要花費大量時間來進行手動測試。

為什麼你需要寫測試

但是,為什麼需要測試呢?又為什麼是現在呢?

你可能覺得學 Python/Django 對你來說已經很滿足了,再學一些新東西的話看起來有點負擔過重並且沒什麼必要。畢竟,我們的投票應用看起來已經完美工作了。寫一些自動測試並不能讓它工作的更好。如果寫一個投票應用是你想用 Django 完成的唯一工作,那你確實沒必要學寫測試。但是如果你還想寫更復雜的專案,現在就是學習測試寫法的最好時機了。

測試將節約你的時間

在某種程度上,能夠「判斷出程式碼是否正常工作」的測試,就稱得上是個令人滿意的了。在更復雜的應用程式中,元件之間可能會有數十個複雜的互動。

在更加複雜的應用中,各種元件之間的互動可能會及其的複雜。改變其中某一元件的行為,也有可能會造成意想不到的結果。判斷「程式碼是否正常工作」意味著你需要用大量的資料來完整的測試全部程式碼的功能,以確保你的小修改沒有對應用整體造成破壞——這太費時間了。

尤其是當你發現自動化測試能在幾秒鐘之內幫你完成這件事時,就更會覺得手動測試實在是太浪費時間了。當某人寫出錯誤的程式碼時,自動化測試還能幫助你定位錯誤程式碼的位置。

有時候你會覺得,和富有創造性和生產力的業務程式碼比起來,編寫枯燥的測試程式碼實在是太無聊了,特別是當你知道你的程式碼完全沒有問題的時候。

然而,編寫測試還是要比花費幾個小時手動測試你的應用,或者為了找到某個小錯誤而胡亂翻看程式碼要有意義的多。

測試不僅能發現錯誤,而且能預防錯誤

「測試是開發的對立面」,這種思想是不對的。

如果沒有測試,整個應用的行為意圖會變得更加的不清晰。甚至當你在看自己寫的程式碼時也是這樣,有時候你需要仔細研讀一段程式碼才能搞清楚它有什麼用。

而測試的出現改變了這種情況。測試就好像是從內部仔細檢查你的程式碼,當有些地方出錯時,這些地方將會變得很顯眼——就算你自己沒有意識到那裡寫錯了

測試使你的程式碼更有吸引力

你也許遇到過這種情況:你編寫了一個絕讚的軟體,但是其他開發者看都不看它一眼,因為它缺少測試。沒有測試的程式碼不值得信任。 Django 最初開發者之一的 Jacob Kaplan-Moss 說過:“專案規劃時沒有包含測試是不科學的。”

其他的開發者希望在正式使用你的程式碼前看到它通過了測試,這是你需要寫測試的另一個重要原因。

測試有利於團隊協作

前面的幾點都是從單人開發的角度來說的。複雜的應用可能由團隊維護。測試的存在保證了協作者不會不小心破壞了了你的程式碼(也保證你不會不小心弄壞他們的)。如果你想作為一個 Django 程式設計師謀生的話,你必須擅長編寫測試!

基礎測試策略

有好幾種不同的方法可以寫測試。

一些開發者遵循 “測試驅動” 的開發原則,他們在寫程式碼之前先寫測試。這種方法看起來有點反直覺,但事實上,這和大多數人日常的做法是相吻合的。我們會先描述一個問題,然後寫程式碼來解決它。「測試驅動」的開發方法只是將問題的描述抽象為了 Python 的測試樣例。

更普遍的情況是,一個剛接觸自動化測試的新手更傾向於先寫程式碼,然後再寫測試。雖然提前寫測試可能更好,但是晚點寫起碼也比沒有強。

有時候很難決定從哪裡開始下手寫測試。如果你才寫了幾千行 Python 程式碼,選擇從哪裡開始寫測試確實不怎麼簡單。如果是這種情況,那麼在你下次修改程式碼(比如加新功能,或者修復 Bug)之前寫個測試是比較合理且有效的。

所以,我們現在就開始寫吧。

開始寫我們的第一個測試

首先得有個 Bug

幸運的是,我們的 polls 應用現在就有一個小 bug 需要被修復:我們的要求是如果 Question 是在一天之內釋出的, Question.was_published_recently() 方法將會返回 True ,然而現在這個方法在 Questionpub_date 欄位比當前時間還晚時也會返回 True(這是個 Bug)。

你能從管理頁面確認這個 bug 真實存在。在 shell 建立一個釋出日期是未來某天的投票,在投票列表裡你會看到它被標明為最近釋出(published recently):

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

因為將來發生的是肯定不是最近發生的,所以程式碼明顯是錯誤的。

建立一個測試來暴露這個 bug

我們剛剛在 shell 裡做的測試也就是自動化測試應該做的工作。所以我們來把它改寫成自動化的吧。

按照慣例,Django 應用的測試應該寫在應用的 tests.py 檔案裡。測試系統會自動的在所有以 tests 開頭的檔案裡尋找並執行測試程式碼。

將下面的程式碼寫入 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

你將會看到執行結果:

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/mysite/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'...

發生了什麼呢?以下是自動化測試的執行過程:

  • python manage.py test polls 將會尋找 polls 應用裡的測試程式碼
  • 它建立一個特殊的資料庫供測試使用
  • 它在類中尋找測試方法——以 test 開頭的方法。
  • test_was_published_recently_with_future_question 方法中,它建立了一個 pub_date 值為 30 天后的 Question 例項。
  • 接著使用 assertls() 方法,發現 was_published_recently() 返回了 True,而我們期望它返回 False

測試系統通知我們哪些測試樣例失敗了,和造成測試失敗的程式碼所在的行號。

修復這個 bug

我們早已知道,當 pub_date 為未來某天時, Question.was_published_recently() 應該返回 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'...

發現 bug 後,我們編寫了能夠暴露這個 bug 的自動化測試。在修復 bug 之後,我們的程式碼順利的通過了測試。

將來,我們的應用可能會出現其他的問題,但是我們可以肯定的是,一定不會再次出現這個 bug,因為只要簡單的執行一遍測試,就會立刻收到警告。我們可以認為應用的這一小部分程式碼永遠是安全的。

更全面的測試

我們已經搞定一小部分了,現在可以考慮全面的測試 was_published_recently() 這個方法以確定它的安全性,然後就可以把這個方法穩定下來了。事實上,在修復一個 bug 時不小心引入另一個 bug 會是非常令人尷尬的。

我們在上次寫的類裡再增加兩個測試,來更全面的測試這個方法:

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 設定為未來某天,這應該被解釋為這個問題將在所填寫的時間點才被髮布,而在之前是不可見的。

針對檢視的測試

為了修復上述 bug ,我們這次先編寫測試,然後再去改程式碼。事實上,這是一個簡單的「測試驅動」開發模式的例項,但其實這兩者的順序不太重要。

在我們的第一個測試中,我們關注程式碼的內部行為。我們通過模擬使用者使用瀏覽器訪問被測試的應用來檢查程式碼行為是否符合預期。

在我們動手之前,先看看需要用到的工具們。

Django 測試工具之 Client

Django 提供了一個供測試使用的 Client 來模擬使用者和檢視層程式碼的互動。我們能在 tests.py 甚至是 shell 中使用它。

我們依照慣例從 shell 開始,首先我們要做一些在 tests.py 裡不是必須的準備工作。第一步是在 shell 中配置測試環境:

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() 提供了一個模板渲染器,允許我們為 responses 新增一些額外的屬性,例如 response.context,未安裝此 app 無法使用此功能。注意,這個方法並 不會 配置測試資料庫,所以接下來的程式碼將會在當前存在的資料庫上執行,輸出的內容可能由於資料庫內容的不同而不同。如果你的 settings.py 中關於 TIME_ZONE 的設定不對,你可能無法獲取到期望的結果。如果你之前忘了設定,在繼續之前檢查一下。

然後我們需要匯入 django.test.TestCase 類(在後續 tests.py 的例項中我們將會使用 django.test.TestCase 類,這個類裡包含了自己的 client 例項,所以不需要這一步):

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = 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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

改善檢視程式碼

現在的投票列表會顯示將來的投票( pub_date 值是未來的某天)。我們來修復這個問題。

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() 方法,讓他它能通過將 Question 的 pub_data 屬性與 timezone.now() 相比較來判斷是否應該顯示此 Question。首先我們需要一行 import 語句:

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()) returns a queryset containing Questions whose pub_date is less than or equal to - that is, earlier than or equal to - timezone.now.

測試新檢視

啟動伺服器、在瀏覽器中載入站點、建立一些釋出時間在過去和將來的 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.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past 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.
        """
        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: Past question.>']
        )

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

讓我們更詳細地看下以上這些內容。

首先是一個快捷函式 create_question,它封裝了建立投票的流程,減少了重複程式碼。

test_no_questions 方法裡沒有建立任何投票,它檢查返回的網頁上有沒有 “No polls are available.” 這段訊息和 latest_question_list 是否為空。注意到 django.test.TestCase 類提供了一些額外的 assertion 方法,在這個例子中,我們使用了 assertContains()assertQuerysetEqual()

test_past_question 方法中,我們建立了一個投票並檢查它是否出現在列表中。

test_future_question 中,我們建立 pub_date 在未來某天的投票。資料庫會在每次呼叫測試方法前被重置,所以第一個投票已經沒了,所以主頁中應該沒有任何投票。

剩下的那些也都差不多。實際上,測試就是假裝一些管理員的輸入,然後通過使用者端的表現是否符合預期來判斷新加入的改變是否破壞了原有的系統狀態。

測試 DetailView

我們的工作似乎已經很完美了?不,還有一個問題:就算在釋出日期時未來的那些投票不會在目錄頁 index 裡出現,但是如果使用者知道或者猜到正確的 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 在未來的不可以:

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 方法,並且為它建立測試。這和我們之前乾的差不多,事實上,基本就是重複一遍。

我們還可以從各個方面改進投票應用,但是測試會一直伴隨我們。比方說,在目錄頁上顯示一個沒有選項 Choices 的投票問題就沒什麼意義。我們可以檢查並排除這樣的投票題。測試可以建立一個沒有選項的投票,然後檢查它是否被顯示在目錄上。當然也要建立一個有選項的投票,然後確認它確實被顯示了。

恩,也許你想讓管理員能在目錄上看見未被髮布的那些投票,但是普通使用者看不到。不管怎麼說,如果你想要增加一個新功能,那麼同時一定要為它編寫測試。不過你是先寫程式碼還是先寫測試那就隨你了。

在未來的某個時刻,你一定會去檢視測試程式碼,然後開始懷疑:「這麼多的測試不會使程式碼越來越複雜嗎?」。彆著急,我們馬上就會談到這一點。

當需要測試的時候,測試用例越多越好

貌似我們的測試多的快要失去控制了。按照這樣發展下去,測試程式碼就要變得比應用的實際程式碼還要多了。而且測試程式碼大多都是重複且不優雅的,特別是在和業務程式碼比起來的時候,這種感覺更加明顯。

但是這沒關係! 就讓測試程式碼繼續肆意增長吧。大部分情況下,你寫完一個測試之後就可以忘掉它了。在你繼續開發的過程中,它會一直默默無聞地為你做貢獻的。

但有時測試也需要更新。想象一下如果我們修改了檢視,只顯示有選項的那些投票,那麼只前寫的很多測試就都會失敗。但這也明確地告訴了我們哪些測試需要被更新,所以測試也會測試自己。

最壞的情況是,當你繼續開發的時候,發現之前的一些測試現在看來是多餘的。但是這也不是什麼問題,多做些測試也 不錯

如果你對測試有個整體規劃,那麼它們就幾乎不會變得混亂。下面有幾條好的建議:

  • 對於每個模型和檢視都建立單獨的 TestClass
  • 每個測試方法只測試一個功能
  • 給每個測試方法起個能描述其功能的名字

深入程式碼測試

在本教程中,我們僅僅是瞭解了測試的基礎知識。你能做的還有很多,而且世界上有很多有用的工具來幫你完成這些有意義的事。

舉個例子,在上述的測試中,我們已經從程式碼邏輯和檢視響應的角度檢查了應用的輸出,現在你可以從一個更加 “in-browser” 的角度來檢查最終渲染出的 HTML 是否符合預期,使用 Selenium 可以很輕鬆的完成這件事。這個工具不僅可以測試 Django 框架裡的程式碼,還可以檢查其他部分,比如說你的 JavaScript。它假裝成是一個正在和你站點進行互動的瀏覽器,就好像有個真人在訪問網站一樣!Django 它提供了LiveServerTestCase 來和 Selenium 這樣的工具進行互動。

如果你在開發一個很複雜的應用的話,你也許想在每次提交程式碼時自動執行測試,也就是我們所說的持續整合 continuous integration ,這樣就能實現質量控制的自動化,起碼是部分自動化。

一個找出程式碼中未被測試部分的方法是檢查程式碼覆蓋率。它有助於找出程式碼中的薄弱部分和無用部分。如果你無法測試一段程式碼,通常說明這段程式碼需要被重構或者刪除。想知道程式碼覆蓋率和無用程式碼的詳細資訊,檢視文件 Integration with coverage.py 獲取詳細資訊。

接下來要做什麼?

如果你想深入瞭解測試,就去看 Django 中的測試

當你已經比較熟悉測試 Django 檢視的方法後,就可以繼續閱讀 教程第 6 部分 ,學習靜態檔案管理的相關知識。