本教程上接教程第4部分。 咱們已經創建一個網頁投票應用,如今咱們將爲它建立一些自動化測試。html
測試是檢查你的代碼是否正常運行的簡單程序。python
測試能夠劃分爲不一樣的級別。 一些測試可能專一於小細節(某一個模型的方法是否會返回預期的值?), 其餘的測試可能會檢查軟件的總體運行是否正常(用戶在對網站進行了一系列的操做後,是否返回了正確的結果?)。這些其實和你早前在教程 1中作的差很少, 使用shell來檢測一個方法的行爲,或者運行程序並輸入數據來檢查它的行爲方式。shell
自動化測試的不一樣之處就在於這些測試會由系統來幫你完成。你建立了一組測試程序,當你修改了你的應用,你就能夠用這組測試程序來檢查你的代碼是否仍然同預期的那樣運行,而無需執行耗時的手動測試。數據庫
那麼,爲何要建立測試?並且爲何是如今?django
你可能感受學習Python/Django已經足夠,再去學習其餘的東西也許須要付出巨大的努力並且沒有必要。 畢竟,咱們的投票應用已經活蹦亂跳了; 將時間運用在自動化測試上還不如運用在改進咱們的應用上。 若是你學習Django就是爲了建立一個投票應用,那麼建立自動化測試顯然沒有必要。 但若是不是這樣,如今是一個很好的學習機會。編程
在某種程度上, ‘檢查起來彷佛正常工做’將是一種使人滿意的測試。 在更復雜的應用中,你可能有幾十種組件之間的複雜的相互做用。瀏覽器
這些組件的任何一個小的變化,均可能對應用的行爲產生意想不到的影響。 檢查起來‘彷佛正常工做’可能意味着你須要運用二十種不一樣的測試數據來測試你代碼的功能,僅僅是爲了確保你沒有搞砸某些事 —— 這不是對時間的有效利用。安全
尤爲是當自動化測試只須要數秒就能夠完成以上的任務時。 若是出現了錯誤,測試程序還可以幫助找出引起這個異常行爲的代碼。服務器
有時候你可能會以爲編寫測試程序將你從有價值的、創造性的編程工做裏帶出,帶到了單調乏味、無趣的編寫測試中,尤爲是當你的代碼工做正常時。session
然而,比起用幾個小時的時間來手動測試你的程序,或者試圖找出代碼中一個新引入的問題的緣由,編寫測試程序仍是使人愜意的。
將測試看作只是開發過程當中消極的一面是錯誤的。
沒有測試,應用的目的和意圖將會變得至關模糊。 甚至在你查看本身的代碼時,也不會發現這些代碼真正幹了些什麼。
測試改變了這一切; 它們使你的代碼內部變得明晰,當錯誤出現後,它們會明確地指出哪部分代碼出了問題 —— 甚至你本身都不會料到問題會出如今那裏。
你可能已經建立了一個堪稱輝煌的軟件,可是你會發現許多其餘的開發者會因爲它缺乏測試程序而拒絕查看它一眼;沒有測試程序,他們不會信任它。 Jacob Kaplan-Moss,Django最初的幾個開發者之一,說過「不具備測試程序的代碼是設計上的錯誤。」
你須要開始編寫測試的另外一個緣由就是其餘的開發者在他們認真研讀你的代碼前可能想要查看一下它有沒有測試。
以前的觀點是從單個開發人員來維護一個程序這個方向來闡述的。 複雜的應用將會被一個團隊來維護。 測試可以減小同事在無心間破壞你的代碼的機會(和你在不知情的狀況下破壞別人的代碼的機會)。 若是你想在團隊中作一個好的Django開發者,你必須擅長測試!
編寫測試有不少種方法。
一些開發者遵循一種叫作「由測試驅動的開發」的規則;他們在編寫代碼前會先編好測試。 這彷佛與直覺不符,儘管這種方法與大多數人常常的作法很類似:人們先描述一個問題,而後建立一些代碼來解決這個問題。 由測試驅動的開發能夠用Python測試用例將這個問題簡單地形式化。
更常見的狀況是,剛接觸測試的人會先編寫一些代碼,而後才決定爲這些代碼建立一些測試。 也許在以前就編寫一些測試會好一點,但何時開始都不算晚。
有時候很難解決從什麼地方開始編寫測試。 若是你已經編寫了數千行Python代碼,挑選它們中的一些來進行測試不會是太容易的。 這種狀況下,在下次你對代碼進行變動,或者添加一個新功能或者修復一個bug時,編寫你的第一個測試,效果會很是好。
如今,讓咱們立刻來編寫一個測試。
幸運的是,polls應用中有一個小錯誤讓咱們能夠立刻來修復它:若是Question在最後一個天發佈,Question.waspublishedrecently() 方法返回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子類,它具備一個方法能夠建立一個pubdate在將來的Question實例。而後咱們檢查waspublished_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開始
它運行testwaspublishedrecentlywithfuturequestion建立一個pub_date爲將來30天的 Question實例
... 而後利用assertEqual()方法,它發現waspublishedrecently() 返回True,儘管咱們但願它返回False
這個測試通知咱們哪一個測試失敗,甚至是錯誤出如今哪一行。
咱們已經知道問題是什麼:Question.waspublishedrecently() 應該返回 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'...
在找出一個錯誤以後,咱們編寫一個測試來暴露這個錯誤,而後在代碼中更正這個錯誤讓咱們的測試經過。
將來,咱們的應用可能會出許多其它的錯誤,可是咱們能夠保證咱們不會無心中再次引入這個錯誤,由於簡單地運行一下這個測試就會當即提醒咱們。 咱們能夠認爲這個應用的這一小部分會永遠安全了。
在這裏,咱們可使waspublishedrecently() 方法更加穩定;事實上,在修復一個錯誤的時候引入一個新的錯誤將是一件很使人尷尬的事。
在同一個類中添加兩個其它的測試方法,來更加綜合地測試這個方法:
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.waspublishedrecently()都將返回合理的數據。
再說一次,polls 應用雖然簡單,可是不管它從此會變得多麼複雜以及會和多少其它的應用產生相互做用,咱們都能保證咱們剛剛爲它編寫過測試的那個方法會按照預期的那樣工做。
這個投票應用沒有區分能力:它將會發布任何一個Question,包括 pubdate字段位於將來。咱們應該改進這一點。 設定pubdate在將來應該表示Question在此刻發佈,可是直到那個時間點纔會變得可見。
當咱們修復上面的錯誤時,咱們先寫測試,而後修改代碼來修復它。 事實上,這是由測試驅動的開發的一個簡單的例子,但作的順序並不真的重要。
在咱們的第一個測試中,咱們專一於代碼內部的行爲。 在這個測試中,咱們想要經過瀏覽器從用戶的角度來檢查它的行爲。
在咱們試着修復任何事情以前,讓咱們先查看一下咱們能用到的工具。
Django提供了一個測試客戶端來模擬用戶和代碼的交互。咱們能夠在tests.py 甚至在shell 中使用它。
咱們將再次以shell開始,可是咱們須要作不少在tests.py中沒必要作的事。首先是在 shell中設置測試環境:
>>> from django.test.utils import setup_test_environment >>> setup_test_environment()
setuptestenvironment()安裝一個模板渲染器,可使咱們來檢查響應的一些額外屬性好比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.contextdata['latestquestion_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(pubdatelte=timezone.now()) 返回一個查詢集,包含pubdate小於等於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的過程封裝在一塊兒。
testindexviewwithnoquestions不建立任何Question,但會檢查消息「No polls are available.」 並驗證latestquestion_list爲空。注意django.test.TestCase類提供一些額外的斷言方法。在這些例子中,咱們使用assertContains() 和 assertQuerysetEqual()。
在testindexviewwithapastquestion中,咱們建立一個Question並驗證它是否出如今列表中。
在testindexviewwithafuturequestion中,咱們建立一個pub_date 在將來的Question。數據庫會爲每個測試方法進行重置,因此第一個Question已經不在那裏,所以首頁面裏不該該有任何Question。
等等。 事實上,咱們是在用測試模擬站點上的管理員輸入和用戶體驗,檢查針對系統每個狀態和狀態的新變化,發佈的是預期的結果。
一切都運行得很好; 然而,即便將來發布的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())
固然,咱們將增長一些測試來檢驗pubdate 在過去的Question 能夠顯示出來,而pubdate在將來的不能夠:
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 協議發佈,轉載請保留做者署名和文章出處。
Django 文檔協做翻譯小組人手緊缺,有興趣的朋友能夠加入咱們,徹底公益性質。交流羣:467338606。