本教程上接教程第4部分。 我們已經(jīng)建立一個網(wǎng)頁投票應(yīng)用,現(xiàn)在我們將為它創(chuàng)建一些自動化測試。
測試是檢查你的代碼是否正常運行的簡單程序。
測試可以劃分為不同的級別。 一些測試可能專注于小細(xì)節(jié)(某一個模型的方法是否會返回預(yù)期的值?), 其他的測試可能會檢查軟件的整體運行是否正常(用戶在對網(wǎng)站進(jìn)行了一系列的操作后,是否返回了正確的結(jié)果?)。這些其實和你早前在教程 1中做的差不多, 使用shell來檢測一個方法的行為,或者運行程序并輸入數(shù)據(jù)來檢查它的行為方式。
自動化測試的不同之處就在于這些測試會由系統(tǒng)來幫你完成。你創(chuàng)建了一組測試程序,當(dāng)你修改了你的應(yīng)用,你就可以用這組測試程序來檢查你的代碼是否仍然同預(yù)期的那樣運行,而無需執(zhí)行耗時的手動測試。
那么,為什么要創(chuàng)建測試?而且為什么是現(xiàn)在?
你可能感覺學(xué)習(xí)Python/Django已經(jīng)足夠,再去學(xué)習(xí)其他的東西也許需要付出巨大的努力而且沒有必要。 畢竟,我們的投票應(yīng)用已經(jīng)活蹦亂跳了; 將時間運用在自動化測試上還不如運用在改進(jìn)我們的應(yīng)用上。 如果你學(xué)習(xí)Django就是為了創(chuàng)建一個投票應(yīng)用,那么創(chuàng)建自動化測試顯然沒有必要。 但如果不是這樣,現(xiàn)在是一個很好的學(xué)習(xí)機(jī)會。
在某種程度上, ‘檢查起來似乎正常工作’將是一種令人滿意的測試。 在更復(fù)雜的應(yīng)用中,你可能有幾十種組件之間的復(fù)雜的相互作用。
這些組件的任何一個小的變化,都可能對應(yīng)用的行為產(chǎn)生意想不到的影響。 檢查起來‘似乎正常工作’可能意味著你需要運用二十種不同的測試數(shù)據(jù)來測試你代碼的功能,僅僅是為了確保你沒有搞砸某些事 —— 這不是對時間的有效利用。
尤其是當(dāng)自動化測試只需要數(shù)秒就可以完成以上的任務(wù)時。 如果出現(xiàn)了錯誤,測試程序還能夠幫助找出引發(fā)這個異常行為的代碼。
有時候你可能會覺得編寫測試程序?qū)⒛銖挠袃r值的、創(chuàng)造性的編程工作里帶出,帶到了單調(diào)乏味、無趣的編寫測試中,尤其是當(dāng)你的代碼工作正常時。
然而,比起用幾個小時的時間來手動測試你的程序,或者試圖找出代碼中一個新引入的問題的原因,編寫測試程序還是令人愜意的。
將測試看做只是開發(fā)過程中消極的一面是錯誤的。
沒有測試,應(yīng)用的目的和意圖將會變得相當(dāng)模糊。 甚至在你查看自己的代碼時,也不會發(fā)現(xiàn)這些代碼真正干了些什么。
測試改變了這一切; 它們使你的代碼內(nèi)部變得明晰,當(dāng)錯誤出現(xiàn)后,它們會明確地指出哪部分代碼出了問題 —— 甚至你自己都不會料到問題會出現(xiàn)在那里。
你可能已經(jīng)創(chuàng)建了一個堪稱輝煌的軟件,但是你會發(fā)現(xiàn)許多其他的開發(fā)者會由于它缺少測試程序而拒絕查看它一眼;沒有測試程序,他們不會信任它。 Jacob Kaplan-Moss,Django最初的幾個開發(fā)者之一,說過“不具有測試程序的代碼是設(shè)計上的錯誤?!?/p>
你需要開始編寫測試的另一個原因就是其他的開發(fā)者在他們認(rèn)真研讀你的代碼前可能想要查看一下它有沒有測試。
之前的觀點是從單個開發(fā)人員來維護(hù)一個程序這個方向來闡述的。 復(fù)雜的應(yīng)用將會被一個團(tuán)隊來維護(hù)。 測試能夠減少同事在無意間破壞你的代碼的機(jī)會(和你在不知情的情況下破壞別人的代碼的機(jī)會)。 如果你想在團(tuán)隊中做一個好的Django開發(fā)者,你必須擅長測試!
編寫測試有很多種方法。
一些開發(fā)者遵循一種叫做“由測試驅(qū)動的開發(fā)”的規(guī)則;他們在編寫代碼前會先編好測試。 這似乎與直覺不符,盡管這種方法與大多數(shù)人經(jīng)常的做法很相似:人們先描述一個問題,然后創(chuàng)建一些代碼來解決這個問題。 由測試驅(qū)動的開發(fā)可以用Python測試用例將這個問題簡單地形式化。
更常見的情況是,剛接觸測試的人會先編寫一些代碼,然后才決定為這些代碼創(chuàng)建一些測試。 也許在之前就編寫一些測試會好一點,但什么時候開始都不算晚。
有時候很難解決從什么地方開始編寫測試。 如果你已經(jīng)編寫了數(shù)千行Python代碼,挑選它們中的一些來進(jìn)行測試不會是太容易的。 這種情況下,在下次你對代碼進(jìn)行變更,或者添加一個新功能或者修復(fù)一個bug時,編寫你的第一個測試,效果會非常好。
現(xiàn)在,讓我們馬上來編寫一個測試。
幸運的是,polls應(yīng)用中有一個小錯誤讓我們可以馬上來修復(fù)它:如果Question在最后一個天發(fā)布,Question.was_published_recently() 方法返回True(這是對的),但是如果Question的pub_date 字段是在未來,它還返回True(這肯定是不對的)。
你可以在管理站點中看到這一點; 創(chuàng)建一個發(fā)布時間在未來的一個Question; 你可以看到Question 的變更列表聲稱它是最近發(fā)布的。
你還可以使用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
由于將來的事情并不能稱之為‘最近’,這確實是一個錯誤。
我們需要在自動化測試?yán)镒龅暮蛣偛旁趕hell里做的差不多,讓我們來將它轉(zhuǎn)換成一個自動化測試。
應(yīng)用的測試用例安裝慣例一般放在該應(yīng)用的tests.py文件中;測試系統(tǒng)將自動在任何以test開頭的文件中查找測試用例。
將下面的代碼放入polls應(yīng)用下的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)
我們在這里做的是創(chuàng)建一個django.test.TestCase子類,它具有一個方法可以創(chuàng)建一個pub_date在未來的Question實例。然后我們檢查was_published_recently()的輸出 —— 它應(yīng)該是 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'...
發(fā)生了如下這些事:
這個測試通知我們哪個測試失敗,甚至是錯誤出現(xiàn)在哪一行。
我們已經(jīng)知道問題是什么:Question.was_published_recently() 應(yīng)該返回 False,如果它的pub_date是在未來。在models.py中修復(fù)這個方法,讓它只有當(dāng)日期是在過去時才返回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'...
在找出一個錯誤之后,我們編寫一個測試來暴露這個錯誤,然后在代碼中更正這個錯誤讓我們的測試通過。
未來,我們的應(yīng)用可能會出許多其它的錯誤,但是我們可以保證我們不會無意中再次引入這個錯誤,因為簡單地運行一下這個測試就會立即提醒我們。 我們可以認(rèn)為這個應(yīng)用的這一小部分會永遠(yuǎn)安全了。
在這里,我們可以使was_published_recently() 方法更加穩(wěn)定;事實上,在修復(fù)一個錯誤的時候引入一個新的錯誤將是一件很令人尷尬的事。
在同一個類中添加兩個其它的測試方法,來更加綜合地測試這個方法:
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)
現(xiàn)在我們有三個測試來保證無論發(fā)布時間是在過去、現(xiàn)在還是未來 Question.was_published_recently()都將返回合理的數(shù)據(jù)。
再說一次,polls 應(yīng)用雖然簡單,但是無論它今后會變得多么復(fù)雜以及會和多少其它的應(yīng)用產(chǎn)生相互作用,我們都能保證我們剛剛為它編寫過測試的那個方法會按照預(yù)期的那樣工作。
這個投票應(yīng)用沒有區(qū)分能力:它將會發(fā)布任何一個Question,包括 pub_date字段位于未來。我們應(yīng)該改進(jìn)這一點。 設(shè)定pub_date在未來應(yīng)該表示Question在此刻發(fā)布,但是直到那個時間點才會變得可見。
當(dāng)我們修復(fù)上面的錯誤時,我們先寫測試,然后修改代碼來修復(fù)它。 事實上,這是由測試驅(qū)動的開發(fā)的一個簡單的例子,但做的順序并不真的重要。
在我們的第一個測試中,我們專注于代碼內(nèi)部的行為。 在這個測試中,我們想要通過瀏覽器從用戶的角度來檢查它的行為。
在我們試著修復(fù)任何事情之前,讓我們先查看一下我們能用到的工具。
Django提供了一個測試客戶端來模擬用戶和代碼的交互。我們可以在tests.py 甚至在shell 中使用它。
我們將再次以shell開始,但是我們需要做很多在tests.py中不必做的事。首先是在 shell中設(shè)置測試環(huán)境:
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()安裝一個模板渲染器,可以使我們來檢查響應(yīng)的一些額外屬性比如response.context,否則是訪問不到的。請注意,這種方法不會建立一個測試數(shù)據(jù)庫,所以以下命令將運行在現(xiàn)有的數(shù)據(jù)庫上,輸出的內(nèi)容也會根據(jù)你已經(jīng)創(chuàng)建的Question不同而稍有不同。
下一步我們需要導(dǎo)入測試客戶端類(在之后的tests.py 中,我們將使用django.test.TestCase類,它具有自己的客戶端,將不需要導(dǎo)入這個類):
>>> 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?>]
投票的列表顯示還沒有發(fā)布的投票(即pub_date在未來的投票)。讓我們來修復(fù)它。
在教程 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 中的數(shù)據(jù)。
我們需要修改get_queryset方法并讓它將日期與timezone.now()進(jìn)行比較。首先我們需要添加一行導(dǎo)入:
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。
啟動服務(wù)器、在瀏覽器中載入站點、創(chuàng)建一些發(fā)布時間在過去和將來的Questions ,然后檢驗只有已經(jīng)發(fā)布的Question會展示出來,現(xiàn)在你可以對自己感到滿意了。你不想每次修改可能與這相關(guān)的代碼時都重復(fù)這樣做 —— 所以讓我們基于以上shell會話中的內(nèi)容,再編寫一個測試。
將下面的代碼添加到polls/tests.py:
polls/tests.py
from django.core.urlresolvers import reverse
我們將創(chuàng)建一個快捷函數(shù)來創(chuàng)建Question,同時我們要創(chuàng)建一個新的測試類:
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.>']
)
讓我們更詳細(xì)地看下以上這些內(nèi)容。
第一個是Question的快捷函數(shù)create_question,將重復(fù)創(chuàng)建Question的過程封裝在一起。
test_index_view_with_no_questions不創(chuàng)建任何Question,但會檢查消息“No polls are available.” 并驗證latest_question_list為空。注意django.test.TestCase類提供一些額外的斷言方法。在這些例子中,我們使用assertContains() 和 assertQuerysetEqual()。
在test_index_view_with_a_past_question中,我們創(chuàng)建一個Question并驗證它是否出現(xiàn)在列表中。
在test_index_view_with_a_future_question中,我們創(chuàng)建一個pub_date 在未來的Question。數(shù)據(jù)庫會為每一個測試方法進(jìn)行重置,所以第一個Question已經(jīng)不在那里,因此首頁面里不應(yīng)該有任何Question。
等等。 事實上,我們是在用測試模擬站點上的管理員輸入和用戶體驗,檢查針對系統(tǒng)每一個狀態(tài)和狀態(tài)的新變化,發(fā)布的是預(yù)期的結(jié)果。
一切都運行得很好; 然而,即使未來發(fā)布的Question不會出現(xiàn)在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())
當(dāng)然,我們將增加一些測試來檢驗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)
我們應(yīng)該添加一個類似get_queryset的方法到ResultsView并為該視圖創(chuàng)建一個新的類。這將與我們剛剛創(chuàng)建的非常類似;實際上將會有許多重復(fù)。
我們還可以在其它方面改進(jìn)我們的應(yīng)用,并隨之不斷增加測試。例如,發(fā)布一個沒有Choices的Questions就顯得傻傻的。所以,我們的視圖應(yīng)該檢查這點并排除這些 Questions。我們的測試應(yīng)該創(chuàng)建一個不帶Choices 的 Question然后測試它不會發(fā)布出來, 同時創(chuàng)建一個類似的帶有 Choices的Question 并驗證它會 發(fā)布出來。
也許登陸的用戶應(yīng)該被允許查看還沒發(fā)布的 Questions,但普通游客不行。 再說一次:無論添加什么代碼來完成這個要求,需要提供相應(yīng)的測試代碼,無論你是否是先編寫測試然后讓這些代碼通過測試,還是先用代碼解決其中的邏輯然后編寫測試來證明它。
從某種程度上來說,你一定會查看你的測試,然后想知道是否你的測試程序過于臃腫,這將我們帶向下面的內(nèi)容:
看起來我們的測試代碼的增長正在失去控制。 以這樣的速度,測試的代碼量將很快超過我們的應(yīng)用,對比我們其它優(yōu)美簡潔的代碼,重復(fù)毫無美感。
沒關(guān)系。讓它們繼續(xù)增長。最重要的是,你可以寫一個測試一次,然后忘了它。 當(dāng)你繼續(xù)開發(fā)你的程序時,它將繼續(xù)執(zhí)行有用的功能。
有時,測試需要更新。 假設(shè)我們修改我們的視圖使得只有具有Choices的 Questions 才會發(fā)布。在這種情況下,我們許多已經(jīng)存在的測試都將失敗 —— 這會告訴我們哪些測試需要被修改來使得它們保持最新,所以從某種程度上講,測試可以自己照顧自己。
在最壞的情況下,在你的開發(fā)過程中,你會發(fā)現(xiàn)許多測試現(xiàn)在變得冗余。 即使這樣,也不是問題;對測試來說,冗余是一件好 事。
只要你的測試被合理地組織,它們就不會變得難以管理。 從經(jīng)驗上來說,好的做法是:
本教程只介紹了一些基本的測試。 還有很多你可以做,有許多非常有用的工具可以隨便使用來你實現(xiàn)一些非常聰明的做法。
例如,雖然我們的測試覆蓋了模型的內(nèi)部邏輯和視圖發(fā)布信息的方式,你可以使用一個“瀏覽器”框架例如Selenium來測試你的HTML文件在瀏覽器中真實渲染的樣子。 這些工具不僅可以讓你檢查你的Django代碼的行為,還能夠檢查你的JavaScript的行為。 它會啟動一個瀏覽器,并開始與你的網(wǎng)站進(jìn)行交互,就像有一個人在操縱一樣,非常值得一看! Django 包含一個LiveServerTestCase來幫助與Selenium 這樣的工具集成。
如果你有一個復(fù)雜的應(yīng)用,你可能為了實現(xiàn)continuous integration,想在每次提交代碼后對代碼進(jìn)行自動化測試,讓代碼自動 —— 至少是部分自動 —— 地來控制它的質(zhì)量。
發(fā)現(xiàn)你應(yīng)用中未經(jīng)測試的代碼的一個好方法是檢查測試代碼的覆蓋率。 這也有助于識別脆弱的甚至死代碼。 如果你不能測試一段代碼,這通常意味著這些代碼需要被重構(gòu)或者移除。 Coverage將幫助我們識別死代碼。 查看與coverage.py 集成來了解更多細(xì)節(jié)。
Django 中的測試有關(guān)于測試更加全面的信息。
關(guān)于測試的完整細(xì)節(jié),請查看Django 中的測試。
當(dāng)你對Django 視圖的測試感到滿意后,請閱讀本教程的第6部分來 了解靜態(tài)文件的管理。
譯者:Django 文檔協(xié)作翻譯小組,原文:Part 5: Testing。
本文以 CC BY-NC-SA 3.0 協(xié)議發(fā)布,轉(zhuǎn)載請保留作者署名和文章出處。
Django 文檔協(xié)作翻譯小組人手緊缺,有興趣的朋友可以加入我們,完全公益性質(zhì)。交流群:467338606。