[TOC]
熟練的Python程序員同意測試是軟件開發最重要的方面之一。盡管本章差不多位于書的最后,但這不是事后的想法;到目前為止,我們所研究的一切都將有助于我們寫測試。我們將研究:
* 單元測試和測試驅動開發的重要性
* 標準單元測試模塊
* `py.test`自動化測試套件
* `mock`模塊
* 代碼覆蓋率
* 使用`tox`跨平臺測試
## 為什么測試
大量程序員已經知道測試他們的代碼是件非常重要的事情。如果你是其中之一,請隨意瀏覽這一節。在下一節你會發現——如何在Python中進行測試——包括更多內容。如果你不相信測試的重要性,我保證你的代碼肯定有問題,你只是不知道而已。請繼續讀下去!
</b>
有些人認為測試在Python代碼中更重要,因為它動態特性;像Java和C++這樣的編譯語言偶爾會被認為會更“安全”一點兒,因為它們在編譯時強制執行類型檢查。然而,Python測試很少檢查類型。它們進行值檢查。它們確保在正確的時間設置了正確的屬性,或者序列具有正確的長度、順序和值。這些高層次的東西在任何語言中都需要被測試。Python程序員比其他語言的程序員做更多測試的真正原因是,用Python測試是如此容易!
</b>
但是為什么要測試呢?我們真的需要測試嗎?如果我們不測試會發生什么呢?為了回答這些問題,試想從頭開始寫一個井字游戲,不需要任何測試。先不要運行它,直到完全寫完程序再開始測試。井字游戲非常容易實現,如果兩個玩家都是人類玩家(不是人工智能)的話。你甚至不用去計算誰是贏家。現在運行你的程序。修復所有的錯誤。有多少個錯誤?我在井字游戲中發現了八個錯誤,并且我不確定我發現了所有的錯誤。你自己的程序中發現了多少錯誤?
</b>
我們需要測試我們的代碼,以確保它能夠工作。運行程序,就像我們剛剛做得那樣,然后修復錯誤,這是一種粗糙的測試形式。Python程序員可以編寫幾行代碼并運行程序,以確保這些行正在運行他們所期望的。但是更改幾行代碼會影響程序的某些部分,開發者可能會沒有意識到變化所帶來的影響,因此不會測試它。此外,隨著程序的增長,解釋器獲取代碼的路線也在增長,很快手動完成所有的測試是不可能的。
</b>
為了解決這個問題,我們編寫自動化測試。這些程序會自動通過其他程序或部分程序運行某些輸入。我們可以很快測試程序,并涵蓋更多的測試條件,這比程序員每次做一些測試改變范圍更大。寫測試有四個主要原因:
* 確保代碼以開發人員認為的方式工作
* 確保代碼在我們進行更改時繼續工作
* 確保開發人員理解這些要求
* 確保我們編寫的代碼有一個可維護的接口
第一點并不能證明寫測試所花費的時間是合理的;我們可以簡單地直接在交互式解釋器中測試代碼。但是我們在多次執行測試操作序列時,不得不做同樣的事情,一次自動化這些步驟花費的時間更少,然后在必要時運行它們。每當我們更改代碼,無論是在初始開發還是維護版本,運行測試是一個好主意。當我們有一套全面的自動化測試,我們可以在代碼更改后運行它們,確保我們沒有無意中破壞任何測試過的代碼。
</b>
最后兩點更有趣。當我們編寫代碼測試時,它會幫助我們設計代碼采用的API、接口或模式。因此,如果我們誤解了需求,寫測試有助于發現這種誤解。另一方面,如果我們不確定如何設計一個類,我們可以寫一個測試,與那個類交互,這樣我們可以知道什么是最自然的測試方法。事實上,在我們編寫代碼之前編寫測試通常是有益的。
### 測試驅動的開發模式
“先寫測試”是測試驅動開發的口頭禪。測試驅動開發將“未經測試的代碼就是壞代碼”的概念向前推進了一步,建議只有未寫的代碼才可以不經過測試。寫完某段代碼的測試之前不要寫任何代碼。所以第一步是編寫一個測試來證明代碼可以工作。顯然,測試將會失敗,因為代碼還沒有編寫。然后編寫代碼,確保測試通過。然后為下一個代碼段編寫另一個測試。
</b>
測試驅動開發很有趣。它允許我們建立一些小難題來解決。然后我們編寫代碼來解決這些難題。然后我們設計一個更復雜的難題,編寫代碼來解決新的難題,而不是解決前一個難題。
</b>
測試驅動方法有兩個目標。首先是確保測試真的寫下來了。在我們寫完代碼后,很容易說:“嗯,它似乎奏效了。我不需要為此寫任何測試。這只是一個小小的變化,沒有什么可能會出問題的。”如果在我們編寫代碼之前已經寫完測試代碼,我們將確切知道它什么時候起作用(因為測試將會通過),并且我們將會知道將來它是否會被我們或其他人所做的改變破壞。
</b>
其次,編寫測試首先迫使我們考慮代碼是如何進行交互的。它告訴我們對象需要什么方法以及屬性將如何被訪問。它幫助我們將最初的問題分解成更小的、可測試的問題,然后將測試過的解決方案重新組合成更大的也測試過的解決方案。因此,測試可以成為設計過程的一部分。通常,如果我們為一個新對象寫測試,我們會發現設計中的異常,迫使我們考慮軟件中新的特征。
</b>
作為一個具體的例子,想象編寫使用對象關系映射器將對象屬性存儲在數據庫中的代碼。通常為這些對象使用自動分配的數據庫ID。我們的代碼可能出于不同目的使用該ID。如果我們正在為這樣的代碼編寫測試,在我們編寫之前,我們可能會意識到我們的設計是錯誤的,因為對象在保存到數據庫之前,不會有這樣的ID。如果我們想在測試中,在不保存對象的前提下操縱一個對象,它會在我們根據錯誤的前提編寫代碼之前,發現這個問題。
</b>
測試讓軟件變得更好。在我們發布軟件之前寫測試,最好在最終用戶看到或購買錯誤版本之前完成(在我已經工作過的一些公司,存在“用戶可以測試它”的理念,雖然它們依舊繁榮,但這不是健康的商業模式!)。在我們寫軟件之前寫測試會讓它變得更好。
## 單元測試
讓我們從Python的內置測試工具開始探索。這個工具提供了單元測試的一個公共接口。單元測試側重于在任何一個測試中盡可能測試最少量的代碼。每一個測試只對可用代碼總量的一個單位進行測試。
</b>
不出所料,這個Python庫被稱為`unittest`。它提供了幾個創建和運行單元測試的工具,最重要的是測試用例`TestCase`類。這個類提供了一組方法,允許我們比較值、設置測試、完成后清理。
</b>
當我們想要為特定任務編寫一組單元測試時,我們創建一個`TestCase`子類,并編寫單獨的方法來進行實際的測試。這些方法的命名必須以`test`開頭。當這個慣例被遵守時,測試將作為測試過程的一部分自動運行。通常,測試會在一個對象設置一些值,然后運行方法,并使用內置比較方法,確保計算出正確的結果。這里有一個非常簡單的例子:
```
import unittest
class CheckNumbers(unittest.TestCase):
def test_int_float(self):
self.assertEqual(1, 1.0)
if __name__ == "__main__":
unittest.main()
```
這段代碼簡單地擴展了`TestCase`類,并添加了一個調用`TestCase.assertEqual`方法。此方法要么成功,要么引發異常,這取決于兩個參數是否相等。如果我們運行這個代碼,`unittest`主函數將給出以下輸出:
```
.
--------------------------------------------------------------
Ran 1 test in 0.000s
OK
```
你知道浮點型數字和整數型數字可以等值比較嗎?讓我們添加一個失敗的測試:
```
def test_str_float(self):
self.assertEqual(1, "1")
```
這段代碼的輸出更加險惡,因為整數和字符串被認為是不相等的:
```
.F
============================================================
FAIL: test_str_float (__main__.CheckNumbers)
--------------------------------------------------------------
Traceback (most recent call last):
File "simplest_unittest.py", line 8, in test_str_float
self.assertEqual(1, "1")
AssertionError: 1 != '1'
--------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
```
第一行的點表示第一個測試(我們之前寫的)成功通過;它后面的字母F表示第二個測試失敗。然后,在最后,它給我們一些信息輸出,告訴我們測試失敗的方式和地點,以及失敗次數的摘要。
</b>
我們可以在一個`TestCase`類上添加任意多的測試方法;只要方法名以`test`開始,測試運行程序將對每個方法做單獨測試。每個測試完全獨立于其他測試。結果或先前測試中的計算對當前測試沒有影響。編寫好的單元測試的關鍵是盡可能縮短每個測試方法,每個測試用例僅測試一小段代碼。如果你的代碼似乎沒有自然中斷成這樣的可測試片段,可能是你需要重新思考設計的一個信號。
### 斷言方法
測試用例的總體布局是給某些變量設置一些已知的值,運行一個或多個函數、方法或過程,然后使用`TestCase`斷言方法“證明”正確的預期返回或計算結果。
</b>
有幾種不同的斷言方法可以用來確認特定的結果已經實現了。我們剛剛看到了`assertEqual`,如果兩個參數沒有通過等式檢查,將會引發一個測試失敗。反過來,`assertNotEqual`,如果兩個參數確實相等,則測試失敗。`assertTrue`和`assertFalse`,每個方法都接受一個表達式,如果表達式沒能通過`if`測試,則測試失敗。這些測試不檢查布爾值是真還是假。相反,它們測試相同的條件,就像使用`if`語句一樣:False,None,0,或者空列表、字典、字符串、集合或元組會將傳遞給`assertFalse`方法,而非零數字、包含值的容器或真值則調用`assertTrue`方法時會成功。
</b>
有一個`assertRaises`方法可以用來確保一個特定的函數調用引發特定的異常,或者可選地,它可以用作上下文管理器來包裝內嵌代碼。如果with語句中的代碼引發了正確的異常,則測試通過;否則,測試失敗。以下是兩個版本的示例:
```
import unittest
def average(seq):
return sum(seq) / len(seq)
class TestAverage(unittest.TestCase):
def test_zero(self):
self.assertRaises(ZeroDivisionError,
average,
[])
def test_with_zero(self):
with self.assertRaises(ZeroDivisionError):
average([])
if __name__ == "__main__":
unittest.main()
```
上下文管理器允許我們以通常的方式編寫代碼(通過調用函數或直接執行代碼),而不是必須在另一個函數調用中包裝函數調用。
</b>
下表還總結了其他幾種斷言方法:
|方法 |描述 |
| --- | --- |
| assertGreater assertGreaterEqual <p> assertLess <p> assertLessEqual | 接受兩個可比較的對象,并確保命名不等式成立。 |
| assertIn <p> assertNotIn | 確保元素是(或不是)一個容器對象中的元素。 |
| assertIsNone <p> assertIsNotNone | 確保一個元素是(或不是)精確None(但不是另一個虛假值)。 |
| assertSameElements | 確保兩個容器對象具有相同的元素,忽略順序。 |
| assertSequenceEqualassertDictEqual <p>assertSetEqual <p>assertListEqual <p>assertTupleEqual | 確保兩個容器具有相同順序的相同元素。如果測試失敗,顯示代碼差異,比較兩個列表,看看它們有什么不同。最后四種方法也用于測試列表的類型。 |
每個斷言方法都接受一個名為`msg`的可選參數。如果有供應這個參數,如果斷言失敗,它會包含在錯誤消息中。這有助于澄清預期會發生什么,或者解釋哪里可能發生了錯誤,從而導致斷言失敗。
### 減少樣本代碼和清理
寫了幾個小測試后,我們經常發現對一些相關的測試,我們必須做同樣的代碼設置。例如,下面的列表子類有三種統計計算方法:
```
from collections import defaultdict
class StatsList(list):
def mean(self):
return sum(self) / len(self)
def median(self):
if len(self) % 2:
return self[int(len(self) / 2)]
else:
idx = int(len(self) / 2)
return (self[idx] + self[idx-1]) / 2
def mode(self):
freqs = defaultdict(int)
for item in self:
freqs[item] += 1
mode_freq = max(freqs.values())
modes = []
for item, value in freqs.items():
if value == mode_freq:
modes.append(item)
return modes
```
顯然,三種方法的每一種測試情況有非常相似的輸入;我們想看看對于空列表或包含非數值列表或包含普通數據集的列表,方法會發生什么。我們可以使用`TestCase`類上為每個測試進行初始化的`setUp`方法。這種方法不接受任何參數,允許我們在每次測試運行前進行任意設置。例如,我們可以在相同的整數列表上測試所有三種方法,如下所示:
```
from stats import StatsList
import unittest
class TestValidInputs(unittest.TestCase):
def setUp(self):
self.stats = StatsList([1,2,2,3,3,4])
def test_mean(self):
self.assertEqual(self.stats.mean(), 2.5)
def test_median(self):
self.assertEqual(self.stats.median(), 2.5)
self.stats.append(4)
self.assertEqual(self.stats.median(), 3)
def test_mode(self):
self.assertEqual(self.stats.mode(), [2,3])
self.stats.remove(2)
self.assertEqual(self.stats.mode(), [3])
if __name__ == "__main__":
unittest.main()
```
如果我們運行這個例子,它表明所有測試都通過了。首先注意`setUp`方法從未在三個`test_*`方法中被顯式調用。測試套件替我們做了。更重要的是,注意`test_median`如何改變列表,向它添加4,但當調用`test_mode`時,列表恢復到`setUp`中指定的值(如果沒有,列表中將有兩個4,`mode`方法將返回三個值)。這表明`setUp`在每次測試前被單獨調用,以確保測試類從頭開始。測試可以以任何順序執行,一個測試的結果不應該依賴于其他測試中的結果。
</b>
除了`setUp`方法,`TestCase`還提供了一個無參數`tearDown`方法,它可用于在類上的每個測試運行后進行清理。如果除了讓對象被垃圾回收之外清理還需要任何東西,這會很有用。例如,如果我們正在測試執行文件輸入/輸出的代碼,我們的測試可能會創建含有測試副作用的新文件:`tearDown`方法可以移除這些文件,確保系統處于測試運行前的狀態。測試用例應該永遠不要有副作用。通常,我們將被測試方法分組到單獨的`TestCase`子類中,這取決于這些方法有什么共同的設置代碼。具有相同或相似設置的測試將被放在一個類中,而具有不相關設置的方法則分到另一個類中。
無關設置進入另一個類。
### 組織和運行測試
單元測試的集合很快就會變得非常龐大和笨拙。一次加載和運行所有測試很快變得復雜。這是一個單元測試的主要目標;在我們的程序上運行所有的測試應該是輕松愉快的,并快速回答“我最近的改變破壞已有的測試了嗎?”
</b>
Python的`discover`模塊基本上尋找當前文件夾或子文件夾中任何名稱以`test`開頭的模塊。如果它從中發現任何`TestCase`對象,測試被執行。這是一種無痛的方式來確保我們不會錯過任何測試。要使用它,請確保你的測試模塊已命名為`test_*.py`,然后運行命令`python3 -m unittest discover`。
### 忽略中斷的測試
有時,測試會失敗,但是我們不希望測試套件報告失敗。這可能是因為,我們已經為一個壞的或未完成的特性編寫了測試,但是我們目前并不專注于改進它。更常見的情況是,這個功能僅在特定平臺、特定Python版本或特定庫的高級版上可用。Python為我們提供了一些裝飾器來標記在已知條件下,預期會失敗或跳過的測試。
</b>
這些裝飾器是:
* expectedFailure()
* skip(reason)
* skipIf(condition, reason)
* skipUnless(condition, reason)
這些都是使用Python裝飾器語法來應用的。第一個不接受參數,并簡單地告訴測試運行者當測試失敗時,不要將測試記錄為失敗。`skip`方法走得更遠,甚至懶得運行測試。它需要一個字符串參數來描述測試被跳過的原因。另外兩個裝飾器接受兩個參數,一個是布爾表達式,表示測試是否應該運行,另一個是與`skip`方法類似的描述。在使用中,這三個裝飾器可以這樣應用:
```
import unittest
import sys
class SkipTests(unittest.TestCase):
@unittest.expectedFailure
def test_fails(self):
self.assertEqual(False, True)
@unittest.skip("Test is useless")
def test_skip(self):
self.assertEqual(False, True)
@unittest.skipIf(sys.version_info.minor == 4,
"broken on 3.4")
def test_skipif(self):
self.assertEqual(False, True)
@unittest.skipUnless(sys.platform.startswith('linux'),
"broken unless on linux")
def test_skipunless(self):
self.assertEqual(False, True)
if __name__ == "__main__":
unittest.main()
```
第一次測試失敗,但被報告為預期失敗;第二個測試永遠不會運行。另外兩個測試可能運行,也可能不運行,這取決于當前的Python版本和操作系統。在運行Python 3.4的Linux系統上輸出如下所示:
```
xssF
=============================================================
FAIL: test_skipunless (__main__.SkipTests)
--------------------------------------------------------------
Traceback (most recent call last):
File "skipping_tests.py", line 21, in test_skipunless
self.assertEqual(False, True)
AssertionError: False != True
--------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1, skipped=2, expected failures=1)
```
第一行的x表示預期故障;后兩個`s`字符代表跳過測試,而`F`表示真正的失敗,因為`skipUnless`的條件在我的系統上是真的。
## 使用py.test測試
Python`unittest`模塊需要大量樣板代碼來設置初始化測試。它基于非常流行的Java JUNit測試框架。它甚至使用相同的方法名(你可能已經注意到它們不符合PEP-8命名標準,PEP-8建議使用下劃線而不是駝峰方法來分割名稱中的單獨單詞)和測試布局。雖然這對于在Java中測試是有效的,但它不一定是Python測試的最佳設計。
</b>
因為Python程序員喜歡他們的代碼優雅簡單,所以他們已經在標準庫之外開發了其他測試框架。兩個最流行的框架是`py.test`和`nose`。前者更健壯,Python3將長期支持,所以我們將討論它。
</b>
由于`py.test`不是標準庫的一部分,你需要自己下載并安裝;你可以從`http://pytest.org/`的`py.test`主頁上獲得各種解釋器全面的安裝說明,但是你通常可以把這些事情甩給更常見的python包安裝程序,`pip`。只需在命令行上鍵入`pip install pytest`,你就可以喝茶去了(譯注:如果使用`pycharm`,`pycharm`提供了4種可選的測試工具,包括 `unitTest`和`pytest`)。
</b>
`py.test`的布局與`unittest`模塊有很大不同。它不要求測試用例是類。相反,它利用了Python函數是對象的優勢,允許任何正確命名的函數像測試一樣運行。它不需要提供一組等值斷言的客戶方法,它使用了斷言語句來驗證結果。這使得測試更加可讀和可維護。當我們運行`py.test`時,它將從當前文件夾開始,在當前文件夾或子包中,搜索任何名稱以字符`test_`開頭的模塊。若模塊中含有`test`開頭的函數,它們將作為單獨的測試。此外,如果模塊中有任何類的名稱以`Test`開頭,該類中以`test_`開頭的任何方法也將被測試。
</b>
讓我們將之前寫給`unittest`的最簡單的示例移植到`py.test`:
```
def test_int_float():
assert 1 == 1.0
```
對于完全相同的測試,我們只需要寫兩行更加可讀的代碼,而在
`unittest`示例中,我們需要寫六行。
</b>
然而,我們沒有被禁止編寫基于類的測試。類可用于將相關測試分組在一起,或者用于需要訪問類中相關屬性或方法的測試。下面的示例顯示了一個帶有測試通過和失敗的擴展類;我們將看到錯誤輸出比`unittest`模塊提供的更全面:
```
class TestNumbers:
def test_int_float(self):
assert 1 == 1.0
def test_int_str(self):
assert 1 == "1"
```
請注意,這個類不需要擴展任何特殊的對象來實現一個測試(盡管`py.test`將運行標準的`unittest TestCases`)。如果我們運行`py.test <filename>`,輸出如下:
```
============== test session starts ==============
python: platform linux2 -- Python 3.4.1 -- pytest-2.6.4
test object 1: class_pytest.py
class_pytest.py .F
=================== FAILURES====================
___________ TestNumbers.test_int_str __________
self = <class_pytest.TestNumbers object at 0x85b4fac>
def test_int_str(self):
> assert 1 == "1"
E assert 1 == '1'
class_pytest.py:7: AssertionError
====== 1 failed, 1 passed in 0.10 seconds =======
```
輸出從一些關于平臺和解釋器的有用信息開始。這對于在不同的系統之間共享bug非常有用。第三行告訴我們被測試文件的名稱(如果有多個測試模塊被發現,它們將全部顯示出來),后面跟著我們在`unittest`模塊中看到熟悉的`.F`;`.`表示通過測試,而`F`表示失敗。
</b>
所有測試運行后,將顯示每個測試的錯誤輸出。它呈現了一個局部變量摘要(在這個例子中只有一個局部變量:`self`參數被傳遞到函數中),錯誤發生的源代碼,以及錯誤消息摘要。此外,如果拋出的是異常,而不是`AssertionError`,`py.test`將為我們提供完整的回溯,包括源代碼引用。
</b>
默認情況下,如果測試成功,`py.test`會抑制打印語句的輸出。這對測試調試很有用;當測試失敗時,我們可以添加`print`測試語句,檢查測試進行時特定變量和屬性的值。如果測試失敗,輸出這些值有助于診斷。但是,一旦測試成功,`print`語句輸出將不會顯示,它們很容易被忽略。我們不必通過移除`print`語句來“清理”輸出。如果測試由于未來的變化而再次失敗,調試輸出將立即可用。
### 一種設置和清理的方法
`py.test`支持類似于`unittest`中使用的`setup`和`teardown`方法,但是它提供了更多的靈活性。我們將簡短地討論這些,因為它們是很常見的,但是它們沒有在`unittest`模塊中使用得像`py.test`那樣廣泛。`py.test`為我們提供了強大的funcargs功能,我們將在下一節討論。
</b>
如果我們正在編寫基于類的測試,我們可以使用兩種方法,稱為`setup_method`和`teardown_method`,基本上與在unittest中調用`setUp`和`tearDown`相同。它們在類中的每個測試方法之前和之后被調用,執行設置和清理任務。與`unittest`有一個不同之處。兩種方法都接受一個參數:表示正在調用方法的函數對象。
</b>
此外,`py.test`還提供了其他`setUp`和`tearDown`函數,為我們提供了更多控制何時執行設置和清除代碼的功能。`setup_class`和`teardown_class`方法應該是類方法;他們只接受一個參數(沒有`self`參數),表示所討論的類。
</b>
最后,我們有`setup_module`和`teardown_module`函數,在該模塊中所有測試(在函數或類中)之前和之后運行。這些對于“一次性”設置非常有用,例如創建一個套接字或數據庫連接,被模塊中的所有測試使用。小心這個,因為如果正在設置的對象存儲了狀態,則可能意外地引入測試之間的依賴關系。這個簡短的描述并不能很好地解釋這些方法,我們來看一個例子,它準確地說明了這種情況何時發生:
```
def setup_module(module):
print("setting up MODULE {0}".format(
module.__name__))
def teardown_module(module):
print("tearing down MODULE {0}".format(
module.__name__))
def test_a_function():
print("RUNNING TEST FUNCTION")
class BaseTest:
def setup_class(cls):
print("setting up CLASS {0}".format(
cls.__name__))
def teardown_class(cls):
print("tearing down CLASS {0}\n".format(
cls.__name__))
def setup_method(self, method):
print("setting up METHOD {0}".format(
method.__name__))
def teardown_method(self, method):
print("tearing down METHOD {0}".format(
method.__name__))
class TestClass1(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 1-1")
def test_method_2(self):
print("RUNNING METHOD 1-2")
class TestClass2(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 2-1")
def test_method_2(self):
print("RUNNING METHOD 2-2")
```
`BaseTest`類的唯一目的是提取四種與測試類相同的方法,并使用繼承來減少重復代碼。所以,從`py.test`的角度來看,這兩個子類不但有兩種測試方法,而且有兩個`setup`和兩個`teardown`方法(分別對應類級別和方法級別)。
</b>
如果我們使用py.test運行這些測試,并禁用`print`的輸出抑制(通過傳遞`-s`或`-capture =no`),它們向我們顯示函數的調用與測試本身是相關的:
```
py.test setup_teardown.py -s
setup_teardown.py
setting up MODULE setup_teardown
RUNNING TEST FUNCTION
.setting up CLASS TestClass1
setting up METHOD test_method_1
RUNNING METHOD 1-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 1-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass1
setting up CLASS TestClass2
setting up METHOD test_method_1
RUNNING METHOD 2-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 2-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass2
tearing down MODULE setup_teardown
```
模塊的`setup`和`teardown`方法分別在測試開始和結束時分別執行。然后運行唯一的模塊級測試函數。接下來,執行第一個類的`setup`方法,然后執行該類的兩個測試。這些測試都分別包裝在單獨的`setup_method`和`teardown _ method`調用中。測試執行后,調用類`teardown`方法。同樣過程也發生在第二個類中,在`teardownn_module`方法最終調用之前,執行同樣的測試。
### 一種完全不同的設置變量的方法
各種`setup`和`teardown`函數最常見的用途之一是確保某些類或模塊變量在運行每個測試方法以前具有已知值。
</b>
`py.test`提供了一種完全不同的方法,使用**funcargs**來實現這一點
,funcargs是函數參數的縮寫。funcargs基本上是命名變量,它在測試配置文件中預先定義。這允許我們將配置從執行測試分離出來,允許funcargs跨多個類和模塊中使用。
</b>
為了使用它們,我們將參數添加到測試函數中。參數的名稱用于在特殊命名函數中查找特定的參數。例如,如果當我們演示`unittest`時,測試使用的`StatsList`類,我們再次希望重復測試有效整數列表。但是我們可以這樣寫我們的測試,而不是使用設置方法:
```
from stats import StatsList
def pytest_funcarg__valid_stats(request):
return StatsList([1,2,2,3,3,4])
def test_mean(valid_stats):
assert valid_stats.mean() == 2.5
def test_median(valid_stats):
assert valid_stats.median() == 2.5
valid_stats.append(4)
assert valid_stats.median() == 3
def test_mode(valid_stats):
assert valid_stats.mode() == [2,3]
valid_stats.remove(2)
assert valid_stats.mode() == [3]
```
三種測試方法都接受一個名為`valid_stats`的參數;這個參數是通過調用在文件的頂部定義的`pytest_funcarg__valid_stats`函數創建的。如果這個函數參數是多個模塊所需要的,它也可以在一個名為`conftest.py`的文件中定義。通過`py.test`將最新的py文件解析為加載到任何“全局”測試配置;這是一種一次搞定的定制`py.test`體驗。
</b>
和其他`py.test`特性一樣,返回`funcarg`的工廠名稱很重要;funcarg是名為`pytest_funcarg__<identifier>`的函數,其中`<identifier>`是一個有效的變量名,可以用作測試函數的參數。該函數接受一個神秘的請求參數,并返回一個作為參數傳遞給各個測試函數的對象。這個funcarg在單個測試函數的每次調用中重新創建;例如,這允許我們,在一次測試中更改列表并知道它將在下一次測試中重置為原始值。(譯注:pytest_2.3已經不支持這種定義方法,需要加入`@pytest.fixture(scope="session")`,具體可參考[pytest-2.3: reasoning for fixture/funcarg evolution](https://docs.pytest.org/en/latest/funcarg_compare.html),上段代碼應該改為如下所示,后面示例也需要更改)。
```
from stats import StatsList
@pytest.fixture(scope="session")
def valid_stats(request):
return StatsList([1,2,2,3,3,4])
def test_mean(valid_stats):
assert valid_stats.mean() == 2.5
def test_median(valid_stats):
assert valid_stats.median() == 2.5
valid_stats.append(4)
assert valid_stats.median() == 3
def test_mode(valid_stats):
assert valid_stats.mode() == [2,3]
valid_stats.remove(2)
assert valid_stats.mode() == [3]
```
Funcargs不僅僅可以返回基本變量。傳遞到funcarg工廠的該請求對象提供了一些非常有用的、修改funcarg行為的方法和屬性。模塊`module`、`cls`和函數`function`屬性允許我們查看哪個測試正在請求funcarg。配置屬性允許我們檢查命令行參數和其他配置數據。
</b>
更有趣的是,request對象提供了允許我們額外清理funcarg的方法,或者在測試中重用它,否則將忽略被歸入特定范圍的`setup`和`teardown`方法。
</b>
`request.addfinalizer`方法接受執行以下操作的回調函數(譯注:回調的意思是,執行完測試后,再執行一些函數。[通俗理解“回調函數”](https://blog.csdn.net/angciyu/article/details/80794273),這個帖解釋的很通俗!),調用使用funcarg的每個測試函數后進行清理。這提供了`teardown`方法的等價方法,允許我們清理文件,關閉連接,清空列表或重置隊列。例如,下面的代碼通過創建臨時目錄funcarg測試`os.mkdir`:
```
import tempfile
import shutil
import os.path
@pytest.fixture(scope="session")
def temp_dir(request):
dir = tempfile.mkdtemp()
print(dir)
def cleanup():
shutil.rmtree(dir)
request.addfinalizer(cleanup)
return dir
def test_osfiles(temp_dir):
os.mkdir(os.path.join(temp_dir, 'a'))
os.mkdir(os.path.join(temp_dir, 'b'))
dir_contents = os.listdir(temp_dir)
assert len(dir_contents) == 2
assert 'a' in dir_contents
assert 'b' in dir_contents
```
funcarg為要為創建的文件創建一個新的空臨時目錄。然后它添加一個`finalizer`調用,當測試完成時刪除該目錄(使用`shutil.rmtree`遞歸刪除目錄和其中的任何內容)。
</b>
然后文件系統回到其開始時的狀態。
</b>
我們可以使用`request.cached _ setup`方法創建持續時間超過一次測試的函數參數變量。在設置昂貴的、可以被多個測試重用的操作時,這是非常有用的,只要資源重用沒有打破測試的原子或單元性質(這樣一個測試不依賴并不受先前測試的影響)。例如,如果我們要測試以下響應服務器,我們可能希望在單獨的進程中只運行服務器的一個實例,然后讓多個測試連接到該實例:
```
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('localhost',1028))
s.listen(1)
while True:
client, address = s.accept()
data = client.recv(1024)
client.send(data)
client.close()
```
所有這些代碼所做的就是在特定的端口上監聽并等待來自客戶端套接字的輸入。當它接收到輸入時,它會返回相同的值。為了測試這一點,我們可以啟動服務器,并緩存結果以供多個測試使用。這是測試代碼:
```
import subprocess
import socket
import time
@pytest.fixture(scope="session")
def echoserver(request):
def setup():
p = subprocess.Popen(
['python3', 'echo_server.py'])
time.sleep(1)
return p
def cleanup(p):
p.terminate()
return request.cached_setup(
setup=setup,
teardown=cleanup,
scope="session")
@pytest.fixture(scope="session")
def clientsocket(request):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 1028))
request.addfinalizer(lambda: s.close())
return s
def test_echo(echoserver, clientsocket):
clientsocket.send(b"abc")
assert clientsocket.recv(3) == b'abc'
def test_echo2(echoserver, clientsocket):
clientsocket.send(b"def")
assert clientsocket.recv(3) == b'def'
```
我們在這里創建了兩個funcargs。第一個在單獨的進程中運行響應服務器,并返回進程對象。第二個為每個測試實例化一個新的套接字對象測試,并在測試完成后關閉它。第一個funcarg是我們目前感興趣的。它看起來很像傳統的單元測試`setup` 和`teardown`。我們創建了一個不接受參數并返回正確參數的`setup`函數;在這種情況下,測試實際上忽略了一個過程對象,因為他們只關心服務器是否在運行。然后,我們創建一個清理函數(函數的名稱是任意的,因為它只是我們傳遞給另一個函數的對象),它接受一個參數:`setup`函數返回的參數。此清理代碼終止進程。
</b>
父函數返回調用`request.cached_setup`的結果,而不是直接返回funcarg。它接受`setup` 和`teardown`函數(我們剛剛創建的)的兩個參數和一個范圍`scope`參數。最后一個參數應該是三個字符串“function”、“module”或“session”之一;它決定了參數將被緩存多久。在本例中,我們將其設置為“session”,因此它被緩存用于整個`py.test`運行的持續時間。該進程不會被終止或重新啟動,直到所有測試都運行完畢。當然,“模塊”范圍只為模塊測試緩存它,而“函數”作用域更像一個普通的funcarg,因為它在每個測試函數運行后復位。
(譯注:這段代碼還是有點小問題,就是`request.cached_setup`也被`pytest_2.3`廢棄了,用`addfinalizer`代替。python這點兒挺令人痛苦的,就是版本更新太頻繁了。改過的代碼如下。)
```
import subprocess
import socket
import time
import pytest
@pytest.fixture(scope="session")
def echoserver(request):
p = subprocess.Popen(
['python', 'echo_server.py'])
time.sleep(1)
request.addfinalizer(lambda: p.terminate())
return p
@pytest.fixture(scope="session")
def clientsocket(request):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 1028))
request.addfinalizer(lambda: s.close())
return s
def test_echo(echoserver,clientsocket):
clientsocket.send(b"abc")
assert clientsocket.recv(3) == b'abc'
def test_echo2(echoserver,clientsocket):
clientsocket.send(b"def")
assert clientsocket.recv(3) == b'def'
```
(譯注:測試結果如下,`test_echo2`為何測試失敗,我還沒搞懂……-……)
```
Testing started at 2:39 PM ...
/home/a/PycharmProjects/opp/venv/bin/python /snap/pycharm-community/132/helpers/pycharm/_jb_pytest_runner.py --path /home/a/PycharmProjects/opp/test_class.py
Launching pytest with arguments /home/a/PycharmProjects/opp/test_class.py in /home/a/PycharmProjects/opp
============================= test session starts ==============================
platform linux -- Python 3.5.2, pytest-5.0.1, py-1.8.0, pluggy-0.12.0
rootdir: /home/a/PycharmProjects/oppcollected 2 items
test_class.py .F
test_class.py:31 (test_echo2)
b'' != b'def'
Expected :b'def'
Actual :b''
<Click to see difference>
echoserver = <subprocess.Popen object at 0x7f4440e7ac88>
clientsocket = <socket.socket fd=10, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 47048)>
def test_echo2(echoserver,clientsocket):
clientsocket.send(b"def")
> assert clientsocket.recv(3) == b'def'
E AssertionError: assert b'' == b'def'
E Use -v to get the full diff
test_class.py:34: AssertionError
[100%]
=================================== FAILURES ===================================
__________________________________ test_echo2 __________________________________
echoserver = <subprocess.Popen object at 0x7f4440e7ac88>
clientsocket = <socket.socket fd=10, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 47048)>
def test_echo2(echoserver,clientsocket):
clientsocket.send(b"def")
> assert clientsocket.recv(3) == b'def'
E AssertionError: assert b'' == b'def'
E Use -v to get the full diff
test_class.py:34: AssertionError
====================== 1 failed, 1 passed in 1.03 seconds ======================
Process finished with exit code 0
Assertion failed
Assertion failed
```
### 使用py.test忽略測試
與`unittest`模塊一樣,我們經常需要跳過`py.test`(譯注:有時候是`py.test`,有時候是`pytest`,兩者都可以,這叫人困惑)中的測試,原因很多:被測試的代碼還沒有被編寫,測試只是在某些解釋器或操作系統上運行而已,或者測試很耗時,只在某些情況下運行。
</b>
我們可以使用`py.test.skip`函數在代碼中的任何一點跳過測試。它接受一個單一的參數:一個描述它為什么被跳過的字符串。這個函數可以在任何地方被調用;如果我們在測試函數中調用它,測試將被跳過。如果我們在模塊級別調用它,那么該模塊中的所有測試都將被跳過。如果我們在`funcarg`函數中調用它,所有調用該`funcarg`的測試都將被跳過。
</b>
當然,很多時候,我們希望在所有這些地方,只有確定的條件成立或不成立的情況下,才跳過這些測試。因為我們可以在Python代碼的任何地方執行跳過函數,我們可以在`if`語句中執行它。所以我們可以寫一個測試,看起來像這樣:
```
import sys
import py.test
def test_simple_skip():
if sys.platform != "fakeos":
py.test.skip("Test works only on fakeOS")
fakeos.do_something_fake()
assert fakeos.did_not_happen
```
這段代碼相當愚蠢。沒有名為`fakeos`的Python平臺,所以該測試將在所有操作系統上被跳過。它展示了我們如何有條件地跳過,因為`if`語句可以檢查任何有效的條件,所以我們有很大的權力決定何時跳過測試。通常,我們用`sys.version_info`來檢查Python解釋器版本、用`sys.platform`檢查操作系統,或者檢查`some_library.__ version__`檢查我們是否具有給定API足夠新的版本。
</b>
因為基于某個條件跳過單獨的測試方法或函數,是跳過測試最常見的用法之一,py.test提供了一個方便的裝飾器,允許我們在一行中完成這項工作。裝飾器接受單個字符串,它可以包含任何計算結果為布爾值的可執行Python代碼。例如,以下測試將僅在Python 3或更高版本上運行:
```
import py.test
@py.test.mark.skipif("sys.version_info <= (3,0)")
def test_python3():
assert b"hello".decode() == "hello"
```
`py.test.mark.xfail`裝飾器的行為類似,只是它將測試標記為預期失敗,類似于`unittest.expectedFailure()`。如果測試成功,它將被記錄為失敗;如果失敗,它將被報告為預期行為。在`xfail`的情況下,條件參數是可選的;如果沒有提供,測試將被標記為在任何情況下都期望會失敗。
## 模擬昂貴的對象
有時,我們希望測試一段代碼,需要提供一個對象,該對象要么很昂貴,要么難以建造。雖然這可能意味著你需要重新思考為你的API提供一個更可測試的接口(這通常意味著一個更可用的接口),但有時我們發現自己編寫的測試代碼需要大量樣板代碼用于設置一些對象,這些對象僅僅是偶然與測試代碼相關的。
</b>
例如,假設我們有一些代碼用來跟蹤航班狀態,通過鍵值存儲(如`redis`或`memcache`),我們可以存儲時間戳和最近的狀態。這段代碼的基本版本可能如下所示:
```
import datetime
import redis
class FlightStatusTracker:
ALLOWED_STATUSES = {'CANCELLED', 'DELAYED', 'ON TIME'}
def __init__(self):
self.redis = redis.StrictRedis()
def change_status(self, flight, status):
status = status.upper()
if status not in self.ALLOWED_STATUSES:
raise ValueError(
"{} is not a valid status".format(status))
key = "flightno:{}".format(flight)
value = "{}|{}".format(
datetime.datetime.now().isoformat(), status)
self.redis.set(key, value)
```
在`change_status`方法中,我們應該測試很多東西。我們應該檢查如果傳入了錯誤狀態,是否會引發適當的錯誤。我們需要確保它將狀態轉換為大寫。在`redis`對象上調用`set()`方法時,我們可以看到鍵和值有正確的格式。
</b>
然而,有一件事我們不必在單元測試中檢查,那就是redis對象是否正確存儲數據。這絕對應該在集成或應用程序測試中進行測試的項目,但是在單元測試級別,我們可以假設`py-redis`開發人員已經測試了他們的代碼,這個方法做了我們想要的。作為慣例,單元測試應該是自包含的,而不是依賴于外部資源,例如正在運行的`Redis`實例。
</b>
相反,我們只需要測試`set()`方法是否適當地使用了適當的參數,被恰當地調用了很多次。我們可以在我們的測試中使用`Mock()`對象,用一個我們可以反省的對象代替麻煩的方法。以下示例說明了`Mock`的使用:
```
from unittest.mock import Mock
import py.test
def pytest_funcarg__tracker():
return FlightStatusTracker()
def test_mock_method(tracker):
tracker.redis.set = Mock()
with py.test.raises(ValueError) as ex:
tracker.change_status("AC101", "lost")
assert ex.value.args[0] == "LOST is not a valid status"
assert tracker.redis.set.call_count == 0
```
這個使用`py.test`語法編寫的測試,當不適當的參數傳入時,斷言引發了正確的異常。此外,它還為`set`方法創建了一個`mock`對象,確保從不調用它。如果它被調用了,那就意味著我們的異常處理代碼中有一個錯誤。
</b>
在這種情況下,簡單地替換方法是可行的,因為被替換的對象最終被銷毀了。然而,我們經常想僅在測試期間替換一個函數或方法。例如,如果我們想在mock方法中測試時間戳格式化,我們需要確切知道`datetime.datetime.now()`返回的結果。但是,該值隨運行而變化。我們需要一些方法把它固定在一個特定的值上,這樣我們就可以確定地測試它。
</b>
還記得猴子補丁嗎?將庫函數臨時設置為特定的值是對它的極好利用。mock庫提供了一個補丁上下文管理器,允許我們用模擬對象替換現有庫中的屬性。當上下文管理器退出時,原始屬性會自動恢復,以免影響其他測試用例。這里有一個例子:
```
from unittest.mock import patch
def test_patch(tracker):
tracker.redis.set = Mock()
fake_now = datetime.datetime(2015, 4, 1)
with patch('datetime.datetime') as dt:
dt.now.return_value = fake_now
tracker.change_status("AC102", "on time")
dt.now.assert_called_once_with()
tracker.redis.set.assert_called_once_with(
"flightno:AC102",
"2015-04-01T00:00:00|ON TIME")
```
在這個例子中,我們首先構造一個名為`fake_now`的值,我們將它設置為`datetime.datetime.now`函數的返回值。我們必須在給`datetime.datetime`打補丁之前建造這個對象,否則補丁過的`now`函數將起作用(譯注:這里有點兒小困惑,確實應該要在之前構建對象啊)!
</b>
`with`語句邀請補丁程序用模擬對象替換`datetime.datetime`模塊,該對象作為值`dt`返回。模擬對象的好處是無論何時訪問該對象的屬性或方法時,它都會返回另一個模擬對象。因此,當我們現在訪問`dt.now`時,它給了我們一個新的模擬對象。我們將該對象的`return_value`設置為我們的`fake_now`對象;這樣,無論何時調用`datetime.datetime.now`函數,它將返回我們的對象,而不是新的模擬對象。
</b>
然后,在用已知值調用我們的`change_status`方法后,我們使用模擬類的`assert _called_once_with`函數,以確保`now`函數確實是沒有參數情況下只被調用了一次。然后第二次調用`now`函數,證明`redis.set`方法按照我們預期的參數格式被調用。
</b>
前面的例子很好地說明了如何編寫測試來指導我們API設計。`FlightStatusTracker`對象乍看起來很合理;我們在構建這個對象時建造了一個`redis`連接,我們需要時調用它。然而,當我們為這段代碼編寫測試時,我們發現即使我們模仿`FlightStatusTracker`上的`self.redis`變量,我們仍然不得不建造`redis`連接。如果沒有運行`Redis`服務器,這個調用實際上會失敗,測試也一同失敗了。
</b>
我們可以在`setUp`方法中通過模擬`redis.StrictRedis `類,返回一個`mock`對象來解決這個問題。然而,一個更好的想法可能是重新思考我們的例子。也許我們應該允許用戶傳入一個`redis`實例,而不是在__init__內部構造redis實例,如下例所示:
```
def __init__(self, redis_instance=None):
self.redis = redis_instance if redis_instance else redis.StrictRedis()
```
這允許我們在測試時傳遞一個模擬對象,所以`StrictRedis`方法從未被構建。然而,這也允許任何與`FlightStatusTracker`交流的客戶端代碼傳遞自己的`redis`實例。客戶端有各種各樣的這么做的原因。他們可能已經為他們代碼的一部分建造了一個`redis`實例。他們可能已經創建了一個`redis`API的優化實現。也許他們有一個用于記錄內部監控系統的測量結果。通過編寫單元測試,我們發現了一個用例,它使我們的應用API從一開始就更加靈活,而不用等待客戶要求我們支持他們的特別需求。
</b>
這是對模擬代碼奇跡的簡要介紹。模擬對象是自Python 3.3以來的標準`unittest`庫的一部分,但是從這些例子中可以看出,它們也可以與`py.test`和其他庫一起使用。模擬對象還有其他的優點,隨著代碼越來越復雜,你可能會用到這些高級功能。例如,你可以使用`spec`參數邀請一個模仿對象來模仿一個現有的類,如果代碼試圖訪問不存在于模仿類中的一個屬性,它將拋出一個錯誤。你還可以構造模擬方法,每次調用時,都會傳遞一個列表作為`side_effect`參數,然后返回不同的參數。`side_effect`參數非常通用;當調用模擬或引發異常時,你也可以使用它來執行任意函數。
</b>
總的來說,我們應該對嘲笑相當吝嗇。如果我們發現自己在給定的單元測試中模擬了多個元素,我們可能最終測試的是模擬框架而不是我們真正的代碼。這毫無用處;畢竟,模擬對象已經經過了很好的測試!如果我們的代碼做了這么多,這可能是另一個跡象,說明我們測試的API設計得很差。模仿應該存在于測試中的代碼和與之接口的庫之間的邊界上。如果不是這樣,我們可能需要更改API,以便在不同的地方重新繪制邊界。
## 多少測試才算是充足的?
我們已經聲明,未經測試的代碼是壞代碼。但是我們如何知道我們的代碼測試得有多好?我們如何知道我們的代碼實際上有多少正在測試,有多少測試是失敗的?第一個問題更重要,但是很難回答。即使我們知道已經測試了應用程序的每一行,我們仍然不知道我們是否已經正確地測試了它。例如,如果我們編寫一個stats測試,只檢查當我們提供一個整數列表時會發生什么,如果它被用在浮點型、字符串或者自制對象的列表上,它仍然會失敗。設計完整測試套件的責任仍然在于程序員。
</b>
第二個問題——我們有多少代碼實際上正在測試——這個很容易驗證。代碼覆蓋率本質上是一個由程序執行的代碼行數的估計。如果我們知道這個數字和程序中的行數,我們可以估計出代碼被測試或被覆蓋的真實百分比。如果我們額外有一個指標來說明哪些行沒有經過測試,我們可以更容易地編寫新的測試,以確保這些代碼不那么脆弱。
</b>
最受歡迎的測試代碼覆蓋率的工具,非常值得記憶的是`coverage.py`。它可以像大多數其他第三方庫一樣使用`pip install coverage`命令安裝。我們沒有足夠的空間來覆蓋`coverage`API的所有細節,所以我們只看一看幾個典型的例子。如果我們有一個運行所有單元測試的Python腳本(例如,使用`unittest.main`,一個定制的測試運行程序或`discover`),我們可以使用以下命令執行覆蓋率分析:
```
coverage run coverage_unittest.py
```
該命令將正常退出,但它會創建一個名為`.coverage`、存儲運行數據的文件。我們現在可以使用`coverage report`命令來獲取代碼覆蓋率分析:
```
>>> coverage report
```
輸出如下:
```
Name Stmts Exec Cover
--------------------------------------------------
coverage_unittest 7 7 100%
stats 19 6 31%
--------------------------------------------------
TOTAL 26 13 50%
```
這個基本報告列出了已經執行的文件(我們的單元測試和導入的模塊)。每個文件中的代碼行數、測試執行的行數也被列出。然后將這兩個數字結合起來進行估計代碼覆蓋率。如果我們將`-m`選項傳遞給報告命令,它還會添加一個如下所示的列:
```
Missing
-----------
8-12, 15-23
```
此處列出的行范圍標識了stats模塊中沒有在測試運行期間執行的代碼行。
</b>
我們剛剛在上運行代碼覆蓋工具的例子使用了與我們本章前面創建的相同的統計模塊。然而,它故意使用單一測試,文件中的許多代碼并沒有測試。測試如下:
```
from stats import StatsList
import unittest
class TestMean(unittest.TestCase):
def test_mean(self):
self.assertEqual(StatsList([1,2,2,3,3,4]).mean(), 2.5)
if __name__ == "__main__":
unittest.main()
```
This code doesn't test the median or mode functions, which correspond to the line
numbers that the coverage output told us were missing.
該代碼并沒有測試中值函數或模函數,覆蓋率輸出告訴我們那些沒有經過測試對應的行號。
The textual report is suficient, but if we use the command coverage html, we can
get an even fancier interactive HTML report that we can view in a web browser. The
web page even highlights which lines in the source code were and were not tested.
Here's how it looks:
文本報告是足夠的,但是如果我們使用命令覆蓋`coverage html`,我們可以獲得一個更好的交互式HTML報告,我們可以在網絡瀏覽器中查看。這個網頁甚至突出顯示了源代碼中哪些行被測試過,哪些行沒有被測試過。看起來像這樣:

我們也可以將`coverage.py`模塊與`py.test`結合使用。我們需要使用`pip install pytest-coverage`安裝用于代碼覆蓋率的`py.test`插件`pytest-coverage`。該插件為`py.test`添加了幾個命令行選項,最有用的是`--cover-report`,可以設置為`html`、`report`或`annotation`(實際上后者修改源代碼以突出顯示任何未覆蓋的行)。
</b>
不幸的是,如果我們能本節運行一次覆蓋率報告,我們會發現我們還沒有涵蓋關于代碼覆蓋率的大部分知識!我們可以使用覆蓋率API從我們的程序(或測試套件)內部管理代碼覆蓋率。`coverage.py`接受多種我們沒有觸及的配置選項。我們還沒有討論語句覆蓋率和分支覆蓋率(后者更有用,是`coverage.py`最新版本中的默認值)或其他類型的代碼覆蓋率。
</b>
記住,100%代碼覆蓋率是我們應該追求的崇高目標,但100%仍然不夠!僅僅一個語句被測試,并不意味著它對所有可能輸入都進行了適當的測試。
## 個案研究
Let's walk through test-driven development by writing a small, tested, cryptography
application. Don't worry, you won't need to understand the mathematics behind
complicated modern encryption algorithms such as Threeish or RSA. Instead,
we'll be implementing a sixteenth-century algorithm known as the Vigenère cipher.
The application simply needs to be able to encode and decode a message, given
an encoding keyword, using this cipher.
First, we need to understand how the cipher works if we apply it manually
(without a computer). We start with a table like this:
讓我們通過編寫一個小的、經過測試的密碼術來完成測試驅動的開發
應用程序。別擔心,你不需要理解后面的數學
復雜的現代加密算法,如三進制或RSA。相反,
我們將實施一種16世紀的算法,稱為維格納密碼。
在給定的情況下,應用程序只需要能夠對消息進行編碼和解碼
使用該密碼的編碼關鍵字。
首先,如果我們手動應用密碼,我們需要了解它是如何工作的
(沒有電腦)。我們從這樣一張桌子開始:

Given a keyword, TRAIN, we can encode the message ENCODED IN PYTHON
as follows:
1. Repeat the keyword and message together such that it is easy to map letters
from one to the other:
E N C O D E D I N P Y T H O N T R A I N T R A I N T R A I N
2. For each letter in the plain text, ind the row that begins with that letter
in the table.
3. Find the column with the letter associated with the keyword letter for the
chosen plaintext letter.
4. The encoded character is at the intersection of this row and column.
For example, the row starting with E intersects the column starting with T at
the character X. So, the irst letter in the ciphertext is X. The row starting with N
intersects the column starting with R at the character E, leading to the ciphertext
XE. C intersects A at C, and O intersects I at W. D and N map to Q while E and T
map to X. The full encoded message is XECWQXUIVCRKHWA.
Decoding basically follows the opposite procedure. First, ind the row with the
character for the shared keyword (the T row), then ind the location in that row
where the encoded character (the X) is located. The plaintext character is at the
top of the column for that row (the E).
給定關鍵字TRAIN,我們可以用PYTHON編碼消息
如下所示:
1.一起重復關鍵字和消息,以便于映射字母
從一個到另一個:
英、中、英、法、英、法、俄、法、俄、西
2.對于純文本中的每個字母,找到以該字母開頭的行
在桌子上。
3.查找包含與關鍵字字母相關聯的字母的列
選擇明文字母。
4.編碼字符位于該行和列的交叉點。
例如,以E開頭的行與以T開頭的列相交于
字符x。所以,密文中的第一個字母是x。以N開頭的行
在字符E處與以R開頭的列相交,得到密文
XE。C與A相交于C,O與I相交于西經和北經,映射到Q,而E和T
映射到x。完整編碼的消息是XECWQXUIVCRKHWA。
解碼基本上遵循相反的過程。首先,找到帶有
字符,然后找到該行中的位置
編碼字符(X)所在的位置。明文字符位于
該行的列頂部(E)。
### 實現它
Our program will need an encode method that takes a keyword and plaintext and returns the ciphertext, and a decode method that accepts a keyword and ciphertext and returns the original message. But rather than just writing those methods, let's follow a test-driven development strategy. We'll be using py.test for our unit testing. We need an encode method, and we know what it has to do; let's write a test for that method irst:
我們的程序將需要一種采用關鍵字和明文的編碼方法
返回密文,以及接受關鍵字和密文的解碼方法
并返回原始消息。
但是,讓我們遵循測試驅動的開發,而不僅僅是編寫這些方法
策略。我們將使用py.test進行單元測試。我們需要一種編碼方法,
我們知道這有什么關系;讓我們先為這個方法寫一個測試:
```
def test_encode():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODEDINPYTHON")
assert encoded == "XECWQXUIVCRKHWA"
```
This test fails, naturally, because we aren't importing a VigenereCipher class
anywhere. Let's create a new module to hold that class.
Let's start with the following VigenereCipher class:
這個測試自然會失敗,因為我們沒有導入VigenereCipher類
任何地方。讓我們創建一個新模塊來容納該類。
讓我們從下面的VigenereCipher類開始:
```
class VigenereCipher:
def __init__(self, keyword):
self.keyword = keyword
def encode(self, plaintext):
return "XECWQXUIVCRKHWA"
```
If we add a from vigenere_cipher import VigenereCipher line to the top of our
test class and run py.test, the preceding test will pass! We've inished our irst
test-driven development cycle.
Obviously, returning a hardcoded string is not the most sensible implementation
of a cipher class, so let's add a second test:
如果我們在頂部添加一個從vigenere_cipher導入VigenereCipher行
測試類并運行py.test,前面的測試將通過!我們已經完成了第一次
測試驅動的開發周期。
顯然,返回硬編碼字符串不是最明智的實現
密碼類,所以讓我們添加第二個測試:
```
def test_encode_character():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("E")
assert encoded == "X"
```
Ah, now that test will fail. It looks like we're going to have to work harder. But I
just thought of something: what if someone tries to encode a string with spaces or
lowercase characters? Before we start implementing the encoding, let's add some
tests for these cases, so we don't we forget them. The expected behavior will be to
remove spaces, and to convert lowercase letters to capitals:
啊,現在考試要失敗了。看來我們得更加努力工作了。但是我
想想看:如果有人試圖用空格或
小寫字符?在我們開始實現編碼之前,讓我們添加一些
測試這些病例,所以我們不會忘記。預期的行為是
刪除空格,并將小寫字母轉換為大寫:
```
def test_encode_spaces():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODED IN PYTHON")
assert encoded == "XECWQXUIVCRKHWA"
def test_encode_lowercase():
cipher = VigenereCipher("TRain")
encoded = cipher.encode("encoded in Python")
assert encoded == "XECWQXUIVCRKHWA"
```
If we run the new test suite, we ind that the new tests pass (they expect the same
hardcoded string). But they ought to fail later if we forget to account for these cases.
如果我們運行新的測試套件,我們會發現新的測試通過了(他們期望相同
硬編碼字符串)。但是,如果我們忘記考慮這些情況,它們以后應該會失敗。
Now that we have some test cases, let's think about how to implement our encoding
algorithm. Writing code to use a table like we used in the earlier manual algorithm is
possible, but seems complicated, considering that each row is just an alphabet rotated
by an offset number of characters. It turns out (I asked Wikipedia) that we can use
modulo arithmetic to combine the characters instead of doing a table lookup. Given
plaintext and keyword characters, if we convert the two letters to their numerical
values (with A being 0 and Z being 25), add them together, and take the remainder
mod 26, we get the ciphertext character! This is a straightforward calculation, but since
it happens on a character-by-character basis, we should probably put it in its own
function. And before we do that, we should write a test for the new function:
現在我們有了一些測試用例,讓我們考慮如何實現我們的編碼
算法。編寫代碼來使用像我們在早期手動算法中使用的表是
可能,但是看起來很復雜,考慮到每行只是一個旋轉的字母表
偏移字符數。結果(我問維基百科)我們可以使用
模運算來組合字符,而不是查找表。考慮到
明文和關鍵字字符,如果我們把這兩個字母轉換成數字
值(A為0,Z為25),將它們相加,取余數
mod 26,我們得到密文字符!這是一個簡單的計算,但是因為
它是在逐個字符的基礎上發生的,我們可能應該把它放在自己的
功能。在此之前,我們應該為新函數編寫一個測試:
```
from vigenere_cipher import combine_character
def test_combine_character():
assert combine_character("E", "T") == "X"
assert combine_character("N", "R") == "E"
```
Now we can write the code to make this function work. In all honesty, I had to run
the test several times before I got this function completely correct; irst I returned
an integer, and then I forgot to shift the character back up to the normal ASCII scale
from the zero-based scale. Having the test available made it easy to test and debug
these errors. This is another bonus of test-driven development.
```
def combine_character(plain, keyword):
plain = plain.upper()
keyword = keyword.upper()
plain_num = ord(plain) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (plain_num + keyword_num) % 26)
```
Now that combine_characters is tested, I thought we'd be ready to implement our
encode function. However, the irst thing we want inside that function is a repeating
version of the keyword string that is as long as the plaintext. Let's implement a
function for that irst. Oops, I mean let's implement the test irst!
現在已經測試了組合字符,我想我們已經準備好實現我們
編碼功能。然而,我們想在函數中做的第一件事是重復
與明文一樣長的關鍵字字符串的版本。讓我們實現一個
第一個功能。哎呀,我的意思是讓我們先實現測試!
```
def test_extend_keyword():
cipher = VigenereCipher("TRAIN")
extended = cipher.extend_keyword(16)
assert extended == "TRAINTRAINTRAINT"
```
Before writing this test, I expected to write extend_keyword as a standalone function
that accepted a keyword and an integer. But as I started drafting the test, I realized
it made more sense to use it as a helper method on the VigenereCipher class. This
shows how test-driven development can help design more sensible APIs. Here's
the method implementation:
在編寫這個測試之前,我希望將extend_keyword作為一個獨立的函數來編寫
接受關鍵字和整數的。但是當我開始起草測試時,我意識到
在VigenereCipher類中使用它作為幫助方法更有意義。這
展示了測試驅動開發如何幫助設計更合理的APIs。這是
方法實現:
```
def extend_keyword(self, number):
repeats = number // len(self.keyword) + 1
return (self.keyword * repeats)[:number]
```
Once again, this took a few runs of the test to get right. I ended up adding a second
versions of the test, one with ifteen and one with sixteen letters, to make sure it
works if the integer division has an even number.
Now we're inally ready to write our encode method:
這又一次需要幾次測試才能得到正確答案。我最后增加了一秒鐘
測試的版本,一個有ifteen,一個有16個字母,以確保它
如果整數除法有偶數,則有效。
現在我們終于準備好編寫我們的編碼方法了:
```
def encode(self, plaintext):
cipher = []
keyword = self.extend_keyword(len(plaintext))
for p,k in zip(plaintext, keyword):
cipher.append(combine_character(p,k))
return "".join(cipher)
```
That looks correct. Our test suite should pass now, right?
Actually, if we run it, we'll ind that two tests are still failing. We totally forgot about
the spaces and lowercase characters! It is a good thing we wrote those tests to remind
us. We'll have to add this line at the beginning of the method:
看起來沒錯。我們的測試套件現在應該通過了,對吧?
事實上,如果我們運行它,我們會發現兩個測試仍然失敗。我們完全忘記了
空格和小寫字符!我們寫這些測試來提醒是件好事
我們。我們必須在方法的開頭添加這一行:
```
plaintext = plaintext.replace(" ", "").upper()
```
If we have an idea about a corner case in the middle of implementing something, we can create a test describing that idea. We don't even have to implement the test; we can just run assert False to remind us to implement it later. The failing test will never let us forget the corner case and it can't be ignored like iling a task can. If it takes a while to get around to ixing the implementation, we can mark the test as an expected failure.
> 如果我們在實施過程中有一個關于角落案例的想法
我們可以創建一個測試來描述這個想法。我們甚至不知道
必須實施測試;我們可以運行斷言False來提醒
我們以后再實施。失敗的測試永遠不會讓我們忘記
轉角案例,它不能像發送任務一樣被忽略。如果需要一個
為了著手固定實現,我們可以標記測試
作為預期的失敗。
Now all the tests pass successfully. This chapter is pretty long, so we'll condense
the examples for decoding. Here are a couple tests:
現在所有的測試都成功通過了。這一章很長,所以我們要濃縮一下
解碼的例子。這里有幾個測試:
```
def test_separate_character():
assert separate_character("X", "T") == "E"
assert separate_character("E", "R") == "N"
def test_decode():
cipher = VigenereCipher("TRAIN")
decoded = cipher.decode("XECWQXUIVCRKHWA")
assert decoded == "ENCODEDINPYTHON"
```
Here's the separate\_character function:
```
def separate_character(cypher, keyword):
cypher = cypher.upper()
keyword = keyword.upper()
cypher_num = ord(cypher) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (cypher_num - keyword_num) % 26)
```
And the decode method:
```
def decode(self, ciphertext):
plain = []
keyword = self.extend_keyword(len(ciphertext))
for p,k in zip(ciphertext, keyword):
plain.append(separate_character(p,k))
return "".join(plain)
```
These methods have a lot of similarity to those used for encoding. The great thing
about having all these tests written and passing is that we can now go back and
modify our code, knowing it is still safely passing the tests. For example, if we
replace our existing encode and decode methods with these refactored methods,
our tests still pass:
這些方法與用于編碼的方法有很多相似之處。偉大的事情
關于所有這些測試的編寫和通過,我們現在可以回去
修改我們的代碼,知道它仍然安全地通過測試。例如,如果我們
用這些重構方法替換我們現有的編碼和解碼方法,
我們的測試仍然通過:
```
def _code(self, text, combine_func):
text = text.replace(" ", "").upper()
combined = []
keyword = self.extend_keyword(len(text))
for p,k in zip(text, keyword):
combined.append(combine_func(p,k))
return "".join(combined)
def encode(self, plaintext):
return self._code(plaintext, combine_character)
def decode(self, ciphertext):
return self._code(ciphertext, separate_character)
```
This is the inal beneit of test-driven development, and the most important. Once the
tests are written, we can improve our code as much as we like and be conident that
our changes didn't break anything we have been testing for. Furthermore, we know
exactly when our refactor is inished: when the tests all pass.
Of course, our tests may not comprehensively test everything we need them to;
maintenance or code refactoring can still cause undiagnosed bugs that don't show
up in testing. Automated tests are not foolproof. If bugs do occur, however, it is still
possible to follow a test-driven plan; step one is to write a test (or multiple tests) that
duplicates or "proves" that the bug in question is occurring. This will, of course, fail.
Then write the code to make the tests stop failing. If the tests were comprehensive,
the bug will be ixed, and we will know if it ever happens again, as soon as we run
the test suite.
Finally, we can try to determine how well our tests operate on this code. With the
py.test coverage plugin installed, py.test –coverage-report=report tells
us that our test suite has 100 percent code coverage. This is a great statistic, but
we shouldn't get too cocky about it. Our code hasn't been tested when encoding
messages that have numbers, and its behavior with such inputs is thus undeined.
這是測試驅動開發的最終好處,也是最重要的。一旦
測試已經完成,我們可以盡可能多地改進我們的代碼,并且確信
我們的改變并沒有破壞我們一直在測試的任何東西。此外,我們知道
當我們的重構完成時:當測試全部通過時。
當然,我們的測試可能無法全面測試我們需要的一切;
維護或代碼重構仍然會導致無法顯示的未診斷錯誤
在測試中。自動化測試不是萬無一失的。然而,如果bug確實發生了,它仍然存在
可以遵循測試驅動的計劃;第一步是編寫一個測試(或多個測試),它
復制或“證明”有問題的bug正在發生。這當然會失敗。
然后編寫代碼使測試停止失敗。如果測試是全面的,
一旦我們運行,bug將被修復,我們將知道它是否會再次發生
測試套件。
最后,我們可以嘗試確定我們的測試在這個代碼上運行的有多好。用
py.test coverage plugin已安裝,py . test–coverage-report = report告知
我們的測試套件有100%的代碼覆蓋率。這是一個很好的統計數據,但是
我們不應該對此過于自信。我們的代碼在編碼時沒有經過測試
因此,具有數字的消息及其在這種輸入下的行為是不確定的。
## 總結
We have inally covered the most important topic in Python programming:
automated testing. Test-driven development is considered a best practice. The
standard library unittest module provides a great out-of-the-box solution for
testing, while the py.test framework has some more Pythonic syntaxes. Mocks
can be used to emulate complex classes in our tests. Code coverage gives us an
estimate of how much of our code is being run by our tests, but it does not tell
us that we have tested the right things.
In the next chapter, we'll jump into a completely different topic: concurrency.
我們最終討論了Python編程中最重要的主題:
自動化測試。測試驅動開發被認為是最佳實踐。這
標準庫unittest模塊提供了一個很好的現成解決方案,用于
測試,而py.test框架有更多的Pythonic語法。嘲弄
可以在我們的測試中用來模擬復雜的類。代碼覆蓋率給了我們一個
估計我們的測試運行了多少代碼,但這并不能說明問題
我們已經測試了正確的東西。
在下一章,我們將跳轉到一個完全不同的主題:并發。