編寫你的第一個Django應用,第5部分
本教程上接教程第4部分。 我們已經建立一個網頁投票應用,現在我們将為它建立一些自動化測試。
自動化測試簡介
什麼是自動化測試?
測試是檢查你的代碼是否正常運作的簡單程式。
測試可以劃分為不同的級别。 一些測試可能專注于小細節(某一個模型的方法是否會傳回預期的值?), 其他的測試可能會檢查軟體的整體運作是否正常(使用者在對網站進行了一系列的操作後,是否傳回了正确的結果?)。這些其實和你早前在教程 1中做的差不多, 使用shell來檢測一個方法的行為,或者運作程式并輸入資料來檢查它的行為方式。
自動化測試的不同之處就在于這些測試會由系統來幫你完成。你建立了一組測試程式,當你修改了你的應用,你就可以用這組測試程式來檢查你的代碼是否仍然同預期的那樣運作,而無需執行耗時的手動測試。
為什麼你需要建立測試
那麼,為什麼要建立測試?而且為什麼是現在?
你可能感覺學習Python/Django已經足夠,再去學習其他的東西也許需要付出巨大的努力而且沒有必要。 畢竟,我們的投票應用已經活蹦亂跳了; 将時間運用在自動化測試上還不如運用在改進我們的應用上。 如果你學習Django就是為了建立一個投票應用,那麼建立自動化測試顯然沒有必要。 但如果不是這樣,現在是一個很好的學習機會。
測試将節省你的時間
在某種程度上, ‘檢查起來似乎正常工作’将是一種令人滿意的測試。 在更複雜的應用中,你可能有幾十種元件之間的複雜的互相作用。
這些元件的任何一個小的變化,都可能對應用的行為産生意想不到的影響。 檢查起來‘似乎正常工作’可能意味着你需要運用二十種不同的測試資料來測試你代碼的功能,僅僅是為了確定你沒有搞砸某些事 —— 這不是對時間的有效利用。
尤其是當自動化測試隻需要數秒就可以完成以上的任務時。 如果出現了錯誤,測試程式還能夠幫助找出引發這個異常行為的代碼。
有時候你可能會覺得編寫測試程式将你從有價值的、創造性的程式設計工作裡帶出,帶到了單調乏味、無趣的編寫測試中,尤其是當你的代碼工作正常時。
然而,比起用幾個小時的時間來手動測試你的程式,或者試圖找出代碼中一個新引入的問題的原因,編寫測試程式還是令人惬意的。
測試不僅僅可以發現問題,它們還能防止問題
将測試看做隻是開發過程中消極的一面是錯誤的。
沒有測試,應用的目的和意圖将會變得相當模糊。 甚至在你檢視自己的代碼時,也不會發現這些代碼真正幹了些什麼。
測試改變了這一切; 它們使你的代碼内部變得明晰,當錯誤出現後,它們會明确地指出哪部分代碼出了問題 —— 甚至你自己都不會料到問題會出現在那裡。
測試使你的代碼更受歡迎
你可能已經建立了一個堪稱輝煌的軟體,但是你會發現許多其他的開發者會由于它缺少測試程式而拒絕檢視它一眼;沒有測試程式,他們不會信任它。 Jacob Kaplan-Moss,Django最初的幾個開發者之一,說過“不具有測試程式的代碼是設計上的錯誤。”
你需要開始編寫測試的另一個原因就是其他的開發者在他們認真研讀你的代碼前可能想要檢視一下它有沒有測試。
測試有助于團隊合作
之前的觀點是從單個開發人員來維護一個程式這個方向來闡述的。 複雜的應用将會被一個團隊來維護。 測試能夠減少同僚在無意間破壞你的代碼的機會(和你在不知情的情況下破壞别人的代碼的機會)。 如果你想在團隊中做一個好的Django開發者,你必須擅長測試!
基本的測試政策
編寫測試有很多種方法。
一些開發者遵循一種叫做“由測試驅動的開發”的規則;他們在編寫代碼前會先編好測試。 這似乎與直覺不符,盡管這種方法與大多數人經常的做法很相似:人們先描述一個問題,然後建立一些代碼來解決這個問題。 由測試驅動的開發可以用Python測試用例将這個問題簡單地形式化。
更常見的情況是,剛接觸測試的人會先編寫一些代碼,然後才決定為這些代碼建立一些測試。 也許在之前就編寫一些測試會好一點,但什麼時候開始都不算晚。
有時候很難解決從什麼地方開始編寫測試。 如果你已經編寫了數千行Python代碼,挑選它們中的一些來進行測試不會是太容易的。 這種情況下,在下次你對代碼進行變更,或者添加一個新功能或者修複一個bug時,編寫你的第一個測試,效果會非常好。
現在,讓我們馬上來編寫一個測試。
編寫我們的第一個測試
我們找出一個錯誤
幸運的是,polls應用中有一個小錯誤讓我們可以馬上來修複它:如果Question在最後一個天釋出,Question.was_published_recently() 方法傳回True(這是對的),但是如果Question的pub_date 字段是在未來,它還傳回True(這肯定是不對的)。
你可以在管理站點中看到這一點; 建立一個釋出時間在未來的一個Question; 你可以看到Question 的變更清單聲稱它是最近釋出的。
你還可以使用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.utils import timezone
from django.test import TestCase
from .models import Question
class QuestionMethodTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
was_published_recently() should return False for questions whose
pub_date is in the future.
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertEqual(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.assertEqual(future_question.was_published_recently(), False)
AssertionError: True != 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建立一個pub_date為未來30天的 Question執行個體
- … 然後利用assertEqual()方法,它發現was_published_recently() 傳回True,盡管我們希望它傳回False
這個測試通知我們哪個測試失敗,甚至是錯誤出現在哪一行。
修複這個錯誤
我們已經知道問題是什麼:Question.was_published_recently() 應該傳回 False,如果它的pub_date是在未來。在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'...
在找出一個錯誤之後,我們編寫一個測試來暴露這個錯誤,然後在代碼中更正這個錯誤讓我們的測試通過。
未來,我們的應用可能會出許多其它的錯誤,但是我們可以保證我們不會無意中再次引入這個錯誤,因為簡單地運作一下這個測試就會立即提醒我們。 我們可以認為這個應用的這一小部分會永遠安全了。
更加綜合的測試
在這裡,我們可以使was_published_recently() 方法更加穩定;事實上,在修複一個錯誤的時候引入一個新的錯誤将是一件很令人尴尬的事。
在同一個類中添加兩個其它的測試方法,來更加綜合地測試這個方法:
polls/tests.py
def test_was_published_recently_with_old_question(self):
"""
was_published_recently() should return False for questions whose
pub_date is older than 1 day.
"""
time = timezone.now() - datetime.timedelta(days=30)
old_question = Question(pub_date=time)
self.assertEqual(old_question.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
was_published_recently() should return True for questions whose
pub_date is within the last day.
"""
time = timezone.now() - datetime.timedelta(hours=1)
recent_question = Question(pub_date=time)
self.assertEqual(recent_question.was_published_recently(), True)
現在我們有三個測試來保證無論釋出時間是在過去、現在還是未來 Question.was_published_recently()都将傳回合理的資料。
再說一次,polls 應用雖然簡單,但是無論它今後會變得多麼複雜以及會和多少其它的應用産生互相作用,我們都能保證我們剛剛為它編寫過測試的那個方法會按照預期的那樣工作。
測試一個視圖
這個投票應用沒有區分能力:它将會釋出任何一個Question,包括 pub_date字段位于未來。我們應該改進這一點。 設定pub_date在未來應該表示Question在此刻釋出,但是直到那個時間點才會變得可見。
視圖的一個測試
當我們修複上面的錯誤時,我們先寫測試,然後修改代碼來修複它。 事實上,這是由測試驅動的開發的一個簡單的例子,但做的順序并不真的重要。
在我們的第一個測試中,我們專注于代碼内部的行為。 在這個測試中,我們想要通過浏覽器從使用者的角度來檢查它的行為。
在我們試着修複任何事情之前,讓我們先檢視一下我們能用到的工具。
Django測試用戶端
Django提供了一個測試用戶端來模拟使用者和代碼的互動。我們可以在tests.py 甚至在shell 中使用它。
我們将再次以shell開始,但是我們需要做很多在tests.py中不必做的事。首先是在 shell中設定測試環境:
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()安裝一個模闆渲染器,可以使我們來檢查響應的一些額外屬性比如response.context,否則是通路不到的。請注意,這種方法不會建立一個測試資料庫,是以以下指令将運作在現有的資料庫上,輸出的内容也會根據你已經建立的Question不同而稍有不同。
下一步我們需要導入測試用戶端類(在之後的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('/')
>>> # we should expect a 404 from that address
>>> 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.core.urlresolvers import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
'\n\n\n <p>No polls are available.</p>\n\n'
>>> # note - you might get unexpected results if your ``TIME_ZONE``
>>> # in ``settings.py`` is not correct. If you need to change it,
>>> # you will also need to restart your shell session
>>> from polls.models import Question
>>> from django.utils import timezone
>>> # create a Question and save it
>>> q = Question(question_text="Who is your favorite Beatle?", pub_date=timezone.now())
>>> q.save()
>>> # check the response once again
>>> response = client.get('/polls/')
>>> response.content
'\n\n\n <ul>\n \n <li><a href="/polls/1/">Who is your favorite Beatle?</a></li>\n \n </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
[<Question: Who is your favorite Beatle?>]
改進我們的視圖
投票的清單顯示還沒有釋出的投票(即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]
response.context_data[‘latest_question_list’] 取出由視圖放置在context 中的資料。
我們需要修改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()) 傳回一個查詢集,包含pub_date小于等于timezone.now的Question。
測試我們的新視圖
啟動伺服器、在浏覽器中載入站點、建立一些釋出時間在過去和将來的Questions ,然後檢驗隻有已經釋出的Question會展示出來,現在你可以對自己感到滿意了。你不想每次修改可能與這相關的代碼時都重複這樣做 —— 是以讓我們基于以上shell會話中的内容,再編寫一個測試。
将下面的代碼添加到polls/tests.py:
polls/tests.py
from django.core.urlresolvers import reverse
我們将建立一個快捷函數來建立Question,同時我們要建立一個新的測試類:
polls/tests.py
def create_question(question_text, days):
"""
Creates a question with the given `question_text` 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 QuestionViewTests(TestCase):
def test_index_view_with_no_questions(self):
"""
If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
"""
Questions with a pub_date in the past should be 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_index_view_with_a_future_question(self):
"""
Questions with a pub_date in the future should not be 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.",
status_code=200)
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_index_view_with_future_question_and_past_question(self):
"""
Even if both past and future questions exist, only past questions
should be 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_index_view_with_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.>']
)
讓我們更詳細地看下以上這些内容。
第一個是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已經不在那裡,是以首頁面裡不應該有任何Question。
等等。 事實上,我們是在用測試模拟站點上的管理者輸入和使用者體驗,檢查針對系統每一個狀态和狀态的新變化,釋出的是預期的結果。
測試 DetailView
一切都運作得很好; 然而,即使未來釋出的Question不會出現在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 QuestionIndexDetailTests(TestCase):
def test_detail_view_with_a_future_question(self):
"""
The detail view of a question with a pub_date in the future should
return a 404 not found.
"""
future_question = create_question(question_text='Future question.',
days=5)
response = self.client.get(reverse('polls:detail',
args=(future_question.id,)))
self.assertEqual(response.status_code, 404)
def test_detail_view_with_a_past_question(self):
"""
The detail view of a question with a pub_date in the past should
display the question's text.
"""
past_question = create_question(question_text='Past Question.',
days=-5)
response = self.client.get(reverse('polls:detail',
args=(past_question.id,)))
self.assertContains(response, past_question.question_text,
status_code=200)
更多的測試思路
我們應該添加一個類似get_queryset的方法到ResultsView并為該視圖建立一個新的類。這将與我們剛剛建立的非常類似;實際上将會有許多重複。
我們還可以在其它方面改進我們的應用,并随之不斷增加測試。例如,釋出一個沒有Choices的Questions就顯得傻傻的。是以,我們的視圖應該檢查這點并排除這些 Questions。我們的測試應該建立一個不帶Choices 的 Question然後測試它不會釋出出來, 同時建立一個類似的帶有 Choices的Question 并驗證它會 釋出出來。
也許登陸的使用者應該被允許檢視還沒釋出的 Questions,但普通遊客不行。 再說一次:無論添加什麼代碼來完成這個要求,需要提供相應的測試代碼,無論你是否是先編寫測試然後讓這些代碼通過測試,還是先用代碼解決其中的邏輯然後編寫測試來證明它。
從某種程度上來說,你一定會檢視你的測試,然後想知道是否你的測試程式過于臃腫,這将我們帶向下面的内容:
測試越多越好
看起來我們的測試代碼的增長正在失去控制。 以這樣的速度,測試的代碼量将很快超過我們的應用,對比我們其它優美簡潔的代碼,重複毫無美感。
沒關系。讓它們繼續增長。最重要的是,你可以寫一個測試一次,然後忘了它。 當你繼續開發你的程式時,它将繼續執行有用的功能。
有時,測試需要更新。 假設我們修改我們的視圖使得隻有具有Choices的 Questions 才會釋出。在這種情況下,我們許多已經存在的測試都将失敗 —— 這會告訴我們哪些測試需要被修改來使得它們保持最新,是以從某種程度上講,測試可以自己照顧自己。
在最壞的情況下,在你的開發過程中,你會發現許多測試現在變得備援。 即使這樣,也不是問題;對測試來說,備援是一件好 事。
隻要你的測試被合理地組織,它們就不會變得難以管理。 從經驗上來說,好的做法是:
- 每個模型或視圖具有一個單獨的TestClass
- 為你想測試的每一種情況建立一個單獨的測試方法
- 測試方法的名字可以描述它們的功能
進一步的測試
本教程隻介紹了一些基本的測試。 還有很多你可以做,有許多非常有用的工具可以随便使用來你實作一些非常聰明的做法。
例如,雖然我們的測試覆寫了模型的内部邏輯和視圖釋出資訊的方式,你可以使用一個“浏覽器”架構例如Selenium來測試你的HTML檔案在浏覽器中真實渲染的樣子。 這些工具不僅可以讓你檢查你的Django代碼的行為,還能夠檢查你的JavaScript的行為。 它會啟動一個浏覽器,并開始與你的網站進行互動,就像有一個人在操縱一樣,非常值得一看! Django 包含一個LiveServerTestCase來幫助與Selenium 這樣的工具內建。
如果你有一個複雜的應用,你可能為了實作continuous integration,想在每次送出代碼後對代碼進行自動化測試,讓代碼自動 —— 至少是部分自動 —— 地來控制它的品質。
發現你應用中未經測試的代碼的一個好方法是檢查測試代碼的覆寫率。 這也有助于識别脆弱的甚至死代碼。 如果你不能測試一段代碼,這通常意味着這些代碼需要被重構或者移除。 Coverage将幫助我們識别死代碼。 檢視與coverage.py 內建來了解更多細節。
Django 中的測試有關于測試更加全面的資訊。
下一步?
關于測試的完整細節,請檢視Django 中的測試。
當你對Django 視圖的測試感到滿意後,請閱讀本教程的第6部分來 了解靜态檔案的管理。
譯者: Django 文檔協作翻譯小組 ,原文: Part 5: Testing。
本文以
CC BY-NC-SA 3.0 協定釋出,轉載請保留作者署名和文章出處。 人手緊缺,有興趣的朋友可以加入我們,完全公益性質。交流群:467338606。