编写你的第一个 Django 应用,第 5 部分
Hello,我是 Alex 007,一個熱愛計算機編程和硬件設計的小白,為啥是007呢?因為叫 Alex 的人太多了,再加上每天007的生活,Alex 007就誕生了。
我們在前幾章成功的構建了一個在線投票應用,在這一部分里我們將為它創建一些自動化測試。
自動化測試簡介
自動化測試是什么?
測試代碼,是用來檢查你的代碼能否正常運行的程序。
測試在不同的層次中都存在。有些測試只關注某個很小的細節(某個模型的某個方法的返回值是否滿足預期?),而另一些測試可能檢查對某個軟件的一系列操作(某一用戶輸入序列是否造成了預期的結果?)。
自動化 測試是由某個系統幫你自動完成的,當你創建好了一系列測試,每次修改應用代碼后,就可以自動檢查出修改后的代碼是否還像你曾經預期的那樣正常工作。你不需要花費大量時間來進行手動測試。
為什么你需要寫測試
但是,為什么需要測試呢?又為什么是現在呢?
你可能覺得學 Python/Django 對你來說已經很滿足了,再學一些新東西的話看起來有點負擔過重并且沒什么必要。畢竟,我們的投票應用看起來已經完美工作了。寫一些自動測試并不能讓它工作的更好。如果寫一個投票應用是你想用 Django 完成的唯一工作,那你確實沒必要學寫測試。但是如果你還想寫更復雜的項目,現在就是學習測試寫法的最好時機了。
測試將節約你的時間
在某種程度上,能夠「判斷出代碼是否正常工作」的測試,就稱得上是個令人滿意的了。在更復雜的應用程序中,組件之間可能會有數十個復雜的交互。
對其中某一組件的改變,也有可能會造成意想不到的結果。判斷「代碼是否正常工作」意味著你需要用大量的數據來完整的測試全部代碼的功能,以確保你的小修改沒有對應用整體造成破壞——這就太浪費時間了。
自動化測試能在幾秒鐘之內幫你完成這件事時,當某人寫出錯誤的代碼時,自動化測試還能幫助你定位錯誤代碼的位置。
有時候你會覺得,和富有創造性和生產力的業務代碼比起來,編寫枯燥的測試代碼實在是太無聊了,特別是當你知道你的代碼完全沒有問題的時候。
然而,編寫測試還是要比花費幾個小時手動測試你的應用,或者為了找到某個小錯誤而胡亂翻看代碼要有意義的多。
測試不僅能發現錯誤,而且能預防錯誤
有一種觀點叫:「測試是開發的對立面」,這種思想是不對的。
如果沒有測試,整個應用的行為意圖會變得更加的不清晰。甚至當你在看自己寫的代碼時也是這樣,有時候你需要仔細研讀一段代碼才能搞清楚它有什么用。
而測試的出現改變了這種情況。測試就好像是從內部仔細檢查你的代碼,當有些地方出錯時,這些地方將會變得很顯眼——就算你自己沒有意識到那里寫錯了。
測試使你的代碼更有吸引力
你也許遇到過這種情況:你編寫了一個絕贊的軟件,但是其他開發者看都不看它一眼,因為它缺少測試。沒有測試的代碼不值得信任。 Django 最初開發者之一的 Jacob Kaplan-Moss 說過:“項目規劃時沒有包含測試是不科學的。”
其他的開發者希望在正式使用你的代碼前看到它通過了測試,這也是你需要寫測試的另一個重要原因。
測試有利于團隊協作
前面的幾點都是從單人開發的角度來說的,復雜的應用可能由團隊維護。測試的存在保證了協作者不會不小心破壞了了你的代碼(也保證你不會不小心弄壞他們的)。如果你想作為一個 Django 程序員謀生的話,你必須擅長編寫測試!
基礎測試策略
有好幾種不同的方法可以寫測試。
一些開發者遵循 “測試驅動” 的開發原則,他們在寫代碼之前先寫測試。這種方法看起來有點反直覺,但事實上,這和大多數人日常的做法是相吻合的。我們會先描述一個問題,然后寫代碼來解決它。「測試驅動」的開發方法只是將問題的描述抽象為了 Python 的測試樣例。
更普遍的情況是,一個剛接觸自動化測試的新手更傾向于先寫代碼,然后再寫測試。雖然提前寫測試可能更好,但是晚點寫起碼也比沒有強。
有時候很難決定從哪里開始下手寫測試。如果你才寫了幾千行 Python 代碼,選擇從哪里開始寫測試確實不怎么簡單。如果是這種情況,那么在你下次修改代碼(比如加新功能,或者修復 Bug)之前寫個測試是比較合理且有效的。
所以,我們現在就開始寫吧。
開始寫我們的第一個測試
首先得有個 Bug
幸運的是,我們的 polls 應用現在就有一個小 bug 需要被修復:我們的要求是如果 Question 是在一天之內發布的, Question.was_published_recently() 方法將會返回 True ,然而現在這個方法在 Question 的 pub_date 字段比當前時間還晚時也會返回 True(這是個 Bug)。
用djadmin:shell命令確認一下這個方法的日期bug
python manage.py shell >>> import datetime >>> from django.utils import timezone >>> from polls.models import Question >>> # 在未來30天內創建一個問題實例 >>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30)) >>> # 最近出版了嗎? >>> future_question.was_published_recently() True因為將來發生的是肯定不是最近發生的,所以代碼明顯是錯誤的。
創建一個測試來暴露這個 bug
我們剛剛在 shell 里做的測試也就是自動化測試應該做的工作。所以我們來把它改寫成自動化的吧。
按照慣例,Django 應用的測試應該寫在應用的 tests.py 文件里。測試系統會自動的在所有以 tests 開頭的文件里尋找并執行測試代碼。
將下面的代碼寫入 polls 應用里的 tests.py 文件內:
import datetimefrom django.test import TestCase from django.utils import timezonefrom .models import Questionclass QuestionModelTests(TestCase):def test_was_published_recently_with_future_question(self):"""was_published_recently()對于發布日期在將來的問題返回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'... 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_questionself.assertIs(future_question.was_published_recently(), False) AssertionError: True is not False---------------------------------------------------------------------- Ran 1 test in 0.001sFAILED (failures=1) Destroying test database for alias 'default'...測試過程發生了什么呢?我們來梳理一下自動化測試的運行過程:
測試系統通知我們哪些測試樣例失敗了,和造成測試失敗的代碼所在的行號。
修復這個 bug
我們早已知道,當 pub_date 為未來某天時, Question.was_published_recently() 應該返回 False。我們修改 models.py 里的方法,讓它只在日期是過去式的時候才返回 True:
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.001sOK Destroying test database for alias 'default'...發現 bug 后,我們編寫了能夠暴露這個 bug 的自動化測試。在修復 bug 之后,我們的代碼順利的通過了測試。
將來,我們的應用可能會出現其他的問題,但是我們可以肯定的是,一定不會再次出現這個 bug,因為只要運行一遍測試,就會立刻收到警告。我們可以認為應用的這一小部分代碼永遠是安全的。
更全面的測試
我們已經搞定一小部分了,現在可以考慮全面的測試 was_published_recently() 這個方法以確定它的安全性,然后就可以把這個方法穩定下來了。事實上,在修復一個 bug 時不小心引入另一個 bug 會是非常令人尷尬的。
我們在上次寫的類里再增加兩個測試,來更全面的測試這個方法:
def test_was_published_recently_with_old_question(self):"""was_published_recently()對于發布日期早于1天的問題返回False。"""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()返回True。"""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 中配置測試環境:
python manage.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 >>> # 創建一個客戶端實例供我們使用 >>> client = Client()搞定了之后,我們可以要求 client 為我們工作了:
>>> # 從“/”獲取響應 >>> response = client.get('/') Not Found: / >>> # 我們應該會從該地址得到404;如果您看到一個“Invalid HTTP_HOST header”錯誤和一個400響應,那么您可能忽略了前面setup_test_environment()的調用。 >>> 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's up?</a></li>\n \n </ul>\n\n' >>> response.context['latest_question_list'] <QuerySet [<Question: What's up?>]>改善視圖代碼
現在的投票列表會顯示將來的投票( pub_date 值是未來的某天)。我們來修復這個問題。
在上一篇文章里,我們介紹了基于 ListView 的視圖類:
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 語句:
from django.utils import timezone然后我們把 get_queryset 方法改寫成下面這樣:
def get_queryset(self):"""返回最后5個已發布的問題(不包括將來要發布的問題)。"""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。
測試新視圖
啟動服務器、在瀏覽器中載入站點、創建一些發布時間在過去和將來的 Questions ,然后檢驗是不是只有已經發布的 Questions 會展示出來。
如果你不想每次修改可能與這相關的代碼時都重復這樣做 —— 所以讓我們基于以上 shell 會話中的內容,再編寫一個測試。
將下面的代碼添加到 polls/tests.py :
from django.urls import reverse然后我們寫一個公用的快捷函數用于創建投票問題,再為視圖創建一個測試類:
def create_question(question_text, days):"""用給定的“問題文本”創建一個問題,并根據給定的“天數”偏移(過去發布的問題為負數,尚未發布的問題為正值)。"""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):"""如果不存在任何問題,將顯示相應的消息。"""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):"""在索引頁面上會顯示過去有 pub_date 的問題。"""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):"""在索引頁上不會顯示將來與 pub_date 相關的問題。"""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):"""即使存在過去和將來的問題,也只顯示過去的問題。"""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):"""問題索引頁可能顯示多個問題。"""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.>'])我們再梳理一下上面的內容。
剩下的那些也都差不多。實際上,測試就是假裝一些管理員的輸入,然后通過用戶端的表現是否符合預期來判斷新加入的改變是否破壞了原有的系統狀態。
測試 DetailView
我們的工作似乎已經很完美了?
其實還有一個問題:就算在發布日期時未來的那些投票不會在目錄頁 index 里出現,但是如果用戶知道或者猜到正確的 URL ,還是可以訪問到它們。所以我們得在 DetailView 里增加一些約束:
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 在未來的不可以:
class QuestionDetailViewTests(TestCase):def test_future_question(self):"""將來帶有pub_date的問題的詳細視圖將返回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):"""帶有過去pub_date的問題的詳細信息視圖顯示問題的文本。"""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 的投票問題就沒什么意義。我們可以檢查并排除這樣的投票題。測試可以創建一個沒有選項的投票,然后檢查它是否被顯示在目錄上。當然也要創建一個有選項的投票,然后確認它確實被顯示了。
恩,也許你想讓管理員能在目錄上看見未被發布的那些投票,但是普通用戶看不到。不管怎么說,如果你想要增加一個新功能,那么同時一定要為它編寫測試。不過你是先寫代碼還是先寫測試那就隨你了。
在未來的某個時刻,你去查看測試代碼,然后開始懷疑:「這么多的測試不會使代碼越來越復雜嗎?」。別著急,我們馬上就會談到這一點。
當需要測試的時候,測試用例越多越好
貌似我們的測試多的快要失去控制了。按照這樣發展下去,測試代碼就要變得比應用的實際代碼還要多了。而且測試代碼大多都是重復且不優雅的,特別是在和業務代碼比起來的時候,這種感覺更加明顯。
但是這沒關系! 就讓測試代碼繼續肆意增長吧。大部分情況下,你寫完一個測試之后就可以忘掉它了。在你繼續開發的過程中,它會一直默默無聞地為你做貢獻的。
但有時測試也需要更新。想象一下如果我們修改了視圖,只顯示有選項的那些投票,那么只前寫的很多測試就都會失敗。但這也明確地告訴了我們哪些測試需要被更新,所以測試也會測試自己。
最壞的情況是,當你繼續開發的時候,發現之前的一些測試現在看來是多余的。但是這也不是什么問題,多做些測試也 不錯。
如果你對測試有個整體規劃,那么它們就幾乎不會變得混亂。下面有幾條好的建議:
深入代碼測試
在本教程中,我們僅僅是了解了測試的基礎知識。你能做的還有很多,而且世界上有很多有用的工具來幫你完成這些有意義的事。
舉個例子,在上述的測試中,我們已經從代碼邏輯和視圖響應的角度檢查了應用的輸出,現在你可以從一個更加 “in-browser” 的角度來檢查最終渲染出的 HTML 是否符合預期,使用 Selenium 可以很輕松的完成這件事。這個工具不僅可以測試 Django 框架里的代碼,還可以檢查其他部分,比如說你的 JavaScript。它假裝成是一個正在和你站點進行交互的瀏覽器,就好像有個真人在訪問網站一樣!Django 它提供了 LiveServerTestCase 來和 Selenium 這樣的工具進行交互。
如果你在開發一個很復雜的應用的話,你也許想在每次提交代碼時自動運行測試,也就是我們所說的持續集成 continuous integration ,這樣就能實現質量控制的自動化,起碼是部分自動化。
一個找出代碼中未被測試部分的方法是檢查代碼覆蓋率。它有助于找出代碼中的薄弱部分和無用部分。如果你無法測試一段代碼,通常說明這段代碼需要被重構或者刪除。
當你已經比較熟悉測試 Django 視圖的方法后,下一節我們學習靜態文件管理的相關知識。
總結
以上是生活随笔為你收集整理的编写你的第一个 Django 应用,第 5 部分的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2019\Province_C_C++_
- 下一篇: 97. Interleaving Str