1. 程式人生 > 其它 >Django 1.10中文文件-第一個應用Part5-測試

Django 1.10中文文件-第一個應用Part5-測試

目錄[-]

本教程上接教程Part4。 前面已經建立一個網頁投票應用,現在將為它建立一些自動化測試。

自動化測試簡介

什麼是自動化測試

測試是檢查你的程式碼是否正常執行的行為。測試也分為不同的級別。有些測試可能是用於某個細節操作(比如特定的模型方法是否返回預期的值),而有些測試是檢查軟體的整體操作(比如站點上的一系列使用者輸入是否產生所需的結果)。這和Part2中的測試是一樣的,使用shell來檢查方法的行為,或者執行應用程式並輸入資料來檢查它的行為。

自動化測試的不同之處就在於這些測試會由系統來幫你完成。你只需要建立一組測試一次,即便以後對應用進行了更改,您仍可以使用這組測試程式碼檢查應用是否按照預期的方式工作,而無需執行耗時的手動測試。

為什麼需要自動化測試

那麼為什麼現在要自動化測試?你可能感覺學習Python/Django已經足夠,再去學習其他的東西也許需要付出巨大的努力而且沒有必要,畢竟我們的投票應用已經愉快地執行起來了。與其花時間去做自動化測試還不如改進現在的應用。如果你學習Django就是僅僅是為了建立一個小小投票應用,那麼涉足自動化測試顯然沒有必要。 但如果不是這樣,現在是一個很好的學習機會。

  • 測試可以節約開發時間

某種程度上,“檢查並發現工作正常”似乎是種比較滿意的測試結果。但在一些複雜的應用中,你會發現元件之間存在各種各樣複雜的互動關係。

這些元件有任何小的的更改都有可能會對應用程式的行為產生意想不到的後果。要得出“似乎工作正常”的結果,可能意味著你需要使用二十種不同的測試資料來測試你的程式碼,而這僅僅是為了確保你沒有做錯某些事,這種方法效率低下。然而,自動化測試只需要數秒就可以完成以上的任務。如果出現了錯誤,還能夠幫助找出引發這個異常行為的程式碼。

有時候你可能會覺得編寫測試程式相比起有價值的、創造性的程式設計工作顯得單調乏味、無趣,尤其是當你的程式碼工作正常時。但是,比起用幾個小時的時間來手動測試你的程式,或者試圖找出程式碼中一個新生問題的原因,編寫自動化測試程式的價效比還是很高的。

  • 測試可以發現並防止問題

將測試看做只是開發中消極的一面是錯誤的,沒有測試,應用程式的目的或預期行為可能是相當不透明的。即使這是你自己的程式碼,你也會發現自己正在都不知道它在做什麼。測試可以改變這一情況; 它們使你的程式碼內部變得明晰,當錯誤出現後,它們會明確地指出哪部分程式碼出了問題——甚至你自己都不會料到問題會出現在那裡。

  • 測試使您的程式碼更受歡迎

你可能已經建立了一個堪稱輝煌的軟體,但是你會發現許多其他的開發者會由於它缺少測試程式而拒絕檢視它一眼;沒有測試程式,他們不會信任它。 Jacob Kaplan-Moss,Django最初的幾個開發者之一,說過“不具有測試程式的程式碼是設計上的錯誤”。你需要開始編寫測試的另一個原因就是其他的開發者在他們認真研讀你的程式碼前可能想要檢視一下它有沒有測試。

  • 測試有助於團隊合作

之前的觀點是從單個開發人員來維護一個程式這個方向來闡述的。 複雜的應用將會被一個團隊來維護。 測試能夠減少同事在無意間破壞你的程式碼的情況(和你在不知情的情況下破壞別人的程式碼的情況)。 如果你想在團隊中做一個好的Django開發者,你必須擅長測試!

基本的測試策略

編寫測試程式有很多種方法。一些程式設計師遵循一種叫做“測試驅動開發”的規則,他們在編寫程式碼前會先編好測試程式。看起來似乎有點反人類,但實際上這種方法與大多數人經常的做法很相似:先描述一個問題,然後編寫程式碼來解決這個問題。測試驅動開發可以簡單地用Python測試用例將問題格式化。

很多時候,剛接觸測試的人會先編寫一些程式碼後才編寫測試程式。事實上,在之前就編寫一些測試會好一點,但不管怎麼說什麼時候開始都不算晚。

有時候你很難決定從什麼時候開始編寫測試。如果你已經編寫了數千行Python程式碼,挑選它們中的一些來進行測試是不太容易的。這種情況下,在下次你對程式碼進行變更,新增一個新功能或者修復一個bug之時,編寫你的第一個測試,效果會非常好。下面,讓我們來編寫一個測試。

編寫第一個測試

發現bug

很巧,在我們的投票應用中有一個小bug需要修改:在Question.was_published_recently()方法的返回值中,當Qeustion在最近的一天釋出的時候返回True(這是正確的),然而當Question在未來的日期內釋出的時候也返回True(這是錯誤的)。

要檢查該bug是否真的存在,使用Admin建立一個未來的日期,並使用shell檢查:

>>>python manage.py shell

In [1]: import datetime
In [2]: from django.utils import timezone
In [3]: from polls.models import Question
# 建立一個pub_date在30天之後的Question例項
In [4]: future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
# 檢查was_published_recently返回值
In [5]: future_question.was_published_recently()
Out[5]: True

由於“將來”不等於“最近”,因此這顯然是個bug。

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

剛才我們是在shell中測試了這個bug,那如何通過自動化測試來發現這個bug呢?

通常,我們會把測試程式碼放在應用的tests.py檔案中;測試系統將自動地從任何名字以test開頭的檔案中查詢測試程式。

將下面的程式碼輸入投票應用的tests.py檔案中:

# polls/tests.py

import datetime

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


class QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        在未來發布的問卷應該返回False
        """
        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'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
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應用中的測試程式
  • 發現一個django.test.TestCase的子類
  • 它為測試建立了一個特定的資料庫
  • 查詢函式名以test開頭的測試方法
  • test_was_published_recently_with_future_question方法中,建立一個Question例項,該例項的pub_data欄位的值是30天后的未來日期
  • 然後利用assertIs()方法,它發現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'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

在找出一個bug之後,編寫一個測試來驗證這個錯誤,然後在程式碼中更正這個錯誤讓我們的測試通過。未來,在應用中可能會出許多其它未知的錯誤,但是我們可以保證不會無意中再次引入這個錯誤,因為簡單地執行一下這個測試就會立即提醒我們。 我們可以認為這個應用的這一小部分會永遠安全了。

更全面的測試

我們可以使was_published_recently()方法更加可靠,事實上,在修復一個錯誤的同時又引入一個新的錯誤將是一件很令人尷尬的事。下面,我們在同一個測試類中再額外新增兩個其它的方法,來更加全面地進行測試:

# polls/tests.py

def test_was_published_recently_with_old_question(self):
    """
    日期超過1天的將返回False。這裡建立了一個30天前釋出的例項。
    """
    time = timezone.now() - datetime.timedelta(days=30)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)


def test_was_published_recently_with_recent_question(self):
    """
    最近一天內的將返回True。這裡建立了一個1小時內釋出的例項。
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

現在我們有三個測試來保證無論釋出時間是在過去、現在還是未來Question.was_published_recently()都將返回正確的結果。最後,polls應用雖然簡單,但是無論它今後會變得多麼複雜以及會和多少其它的應用產生相互作用,我們都能保證Question.was_published_recently()會按照預期的那樣工作。

測試檢視

這個投票應用沒有辨別能力:它將會發布任何的Question,包括pub_date欄位是未來的。我們應該改進這一點。讓pub_date是將來時間的Question應該在未來發布,但是一直不可見,直到那個時間點才會變得可見。

什麼是檢視測試

當我們修復上面的錯誤時,我們先寫測試,然後修改程式碼來修復它。 事實上,這是測試驅動開發的一個簡單的例子,但做的順序並不真的重要。在我們的第一個測試中,我們專注於程式碼內部的行為。 在這個測試中,我們想要通過瀏覽器從使用者的角度來檢查它的行為。在我們試著修復任何事情之前,讓我們先檢視一下我們能用到的工具。

Django的測試客戶端

Django提供了一個測試客戶端用來模擬使用者和程式碼的互動。我們可以在tests.py甚至shell中使用它。先介紹使用shell的情況,這種方式下,需要做很多在tests.py中不必做的事。首先是設定測試環境:

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

setup_test_environment()會安裝一個模板渲染器,它使我們可以檢查一些額外的屬性比如response.context,這些屬性通常情況下是訪問不到的。請注意,這種方法不會建立一個測試資料庫,所以以下命令將執行在現有的資料庫上,輸出的內容也會根據你已經建立的Question的不同而稍有不同。如果你當前settings.py中的的TIME_ZONE不正確,那麼你或許得不到預期的結果。在進行下一步之前,請確保時區設定正確。

下面我們需要匯入測試客戶端類(在之後的tests.py中,我們將使用django.test.TestCase類,它具有自己的客戶端,不需要匯入這個類):

>>> from django.test import Client
>>> # 建立一個Client例項
>>> client = Client()

下面是具體的一些使用操作:

>>> # 從'/'獲取響應
>>> response = client.get('/')
>>> # 這個地址應該返回的是404頁面
>>> response.status_code
404
>>> # 另一方面我們希望在'/polls/'獲取一些內容
>>> # 通過使用'reverse()'方法,而不是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>nn'
>>> # 如果下面的操作沒有正常執行,有可能是你前面忘了安裝測試環境--setup_test_environment() 
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

改進檢視

投票的列表會顯示還沒有釋出的問卷(即pub_date在未來的問卷)。讓我們來修復它。在Part4中,我們介紹了一個繼承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()。首先匯入timezone模組,然後修改get_queryset()方法,如下:

# polls/views.py

from django.utils import timezone

def get_queryset(self):
    """
    返回最近5個釋出的Question但不包括未來的
    """
    return Question.objects.filter(
    pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now())返回一個查詢集,包含pub_date小於等於timezone.now的Question。

測試新檢視

現在,您可以通過啟動執行伺服器,在瀏覽器中載入站點,建立過去和將來的日期的問題,並檢查僅列出已釋出的站點,從而滿足您的需求。如果你不想每次修改可能與這相關的程式碼時都重複這樣做———所以我們還要根據上面的shell會話建立一個測試。將下面的程式碼新增到polls/tests.py:

# polls/tests.py

from django.core.urlresolvers import reverse

def create_question(question_text, days):
    """
    2個引數,一個是問卷的文字內容,另外一個是當前時間的偏移天數,負值表示釋出日期在過去,正值表示釋出日期在將來。
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        如果問卷不存在,給出相應的提示。
        """
        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_index_view_with_a_past_question(self):
        """
        釋出日期在過去的問卷將在index頁面顯示。
        """
        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_index_view_with_a_future_question(self):
        """
        釋出日期在將來的問卷不會在index頁面顯示
        """
        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_index_view_with_future_question_and_past_question(self):
        """
        即使同時存在過去和將來的問卷,也只有過去的問卷會被顯示。
        """
        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_index_view_with_two_past_questions(self):
        """
        index頁面可以同時顯示多個問卷。
        """
        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.>']
        )

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

第一個是Question的快捷函式create_question,功能是將建立Question的過程封裝起來。

test_index_view_with_no_questions不建立任何Question,但會檢查訊息“No polls are available.” 並驗證latest_question_list為空。注意django.test.TestCase類提供一些額外的斷言方法。在這些例子中,我們使用了assertContains()assertQuerysetEqual()

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

test_index_view_with_a_future_question中,我們建立一個pub_date在未來的Question。資料庫會為每一個測試方法進行重置,所以第一個Question已經不在那裡,因此index頁面裡不應該有任何Question。

諸如此類,事實上,我們是在用測試,模擬站點上的管理員輸入和使用者體驗,檢查系統的每一個狀態變化,釋出的是預期的結果。

測試DetailView

然而,即使未來發布的Question不會出現在index中,如果使用者知道或者猜出正確的URL依然可以訪問它們。所以我們需要給DetailView檢視新增一個這樣的約束:

# polls/views.py

class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        確認Question不是在未來發布的.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

同樣,我們將增加一些測試來檢驗pub_date在過去的Question可以顯示出來,而pub_date在未來的不可以

# polls/tests.py

class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        訪問釋出時間在將來的detail頁面將返回404.
        """
        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_detail_view_with_a_past_question(self):
        """
        訪問釋出時間在過去的detail頁面將返回詳細問卷內容。
        """
        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)

其他測試思路

我們應該新增一個類似get_queryset的方法到ResultsView併為該檢視建立一個新的類。這將與我們上面的範例非常類似,實際上也有許多重複。

還可以在其它方面改進我們的應用,並隨之不斷地增加測試。例如,釋出一個沒有Choices的Questions就顯得極不合理。所以,我們的檢視應該檢查這點並排除這些Questions。我們的測試會建立一個不帶Choices的Question然後測試它不會發布出來,同時建立一個類似的帶有Choices的Question並確保它會發布出來。

也許登陸的管理員使用者應該被允許檢視還沒釋出的Questions,但普通訪問者則不行。最重要的是:無論新增什麼程式碼來完成這個要求,都需要提供相應的測試程式碼,不管你是先編寫測試程式然後讓這些程式碼通過測試,還是先用程式碼解決其中的邏輯再編寫測試程式來檢驗它。

從某種程度上來說,你一定會檢視你的測試程式碼,然後想知道你的測試程式是否過於臃腫,我們接著看下面的內容:

測試越多越好

看起來我們的測試程式碼正在逐漸失去控制。以這樣的速度,測試的程式碼量將很快超過我們的實際應用程式程式碼量,對比其它簡潔優雅的程式碼,測試程式碼既重複又毫無美感。沒關係!隨它去!大多數情況下,你可以完一個測試程式,然後忘了它。當你繼續開發你的程式時,它將始終執行有效的測試功能。有時,測試程式需要更新。假設我們讓只有具有Choices的Questions才會釋出,在這種情況下,許多已經存在的測試都將失敗:這會告訴我們哪些測試需要被修改,使得它們保持最新,所以從某種程度上講,測試可以自己測試自己。在最壞的情況下,在你的開發過程中,你會發現許多測試變得多餘。其實,這不是問題,對測試來說,冗餘是一件好事。只要你的測試被合理地組織,它們就不會變得難以管理。 從經驗上來說,好的做法是:

  • 為每個模型或檢視建立一個專屬的TestClass
  • 為你想測試的每一種情況建立一個單獨的測試方法
  • 為測試方法命名時最好從字面上能大概看出它們的功能

進一步測試

本教程僅介紹一些測試的基礎知識。其實還有很多工作可以做,還有一些非常有用的工具可用於實現一些非常聰明的事情。例如,雖然我們的測試覆蓋了模型的內部邏輯和檢視釋出資訊的方式,但你還可以使用一個“基於瀏覽器”的框架例如Selenium來測試你的HTML檔案真實渲染的樣子。這些工具不僅可以讓你檢查你的Django程式碼的行為,還能夠檢查JavaScript的行為。它會啟動一個瀏覽器,與你的網站進行互動,就像有一個人在操縱一樣!Django包含一個LiveServerTestCase來幫助與Selenium 這樣的工具整合。

如果你有一個複雜的應用,你可能為了實現持續整合,想在每次提交程式碼前對程式碼進行自動化測試,讓程式碼自動至少是部分自動地來控制它的質量。

發現你應用中未經測試的程式碼的一個好方法是檢查測試程式碼的覆蓋率。 這也有助於識別脆弱的甚至死程式碼。 如果你不能測試一段程式碼,這通常意味著這些程式碼需要被重構或者移除。 Coverage將幫助我們識別死程式碼。 檢視Integration with coverage.py來了解更多細節。

Testing in Django有關於測試更加全面的資訊。

下一步

關於測試的完整細節,請檢視Testing in Django

當你對Django 檢視的測試感到滿意後,請閱讀本教程的第6部分來了解靜態檔案的管理。