近年來測試驅動開發(TDD)受到越來越多的關注。這是一個持續改進的過程,能從一開始就形成規範,幫助提高程式碼質量。這是切實可行的而非天馬行空的。
TDD的全過程是非常簡單的。藉助TDD,程式碼質量會得到提升,同時可以讓你保持清晰的思路。TDD與敏捷開發可謂強強聯合,特別是在進行結對程式設計的時候。本文主要介紹了TDD的核心概念,還有結合nosetest單元測試包進行Python示例簡析。另外還會介紹一些Python備用包。
TDD是什麼?
使用該方法可讓你少走前人的彎路
顧名思義,TDD即進行程式設計時先把測試部分寫好,當發現不能通過時,再進行程式設計以使測試通過。然後在這基礎上適當地調整測試程式碼以實現更多功能,最後再編寫程式碼使之實現。
TDD看起來非常像一個環,首先是要不斷調整測試程式碼,然後是編碼,改進,最後直至完成。先實現測試部分的做法會使你自然養成把問題放在首位的思維習慣。當真正去構建程式碼時,就不得不想清楚該如何把設計做好;比方說,該方法有何返回值?當遇到異常時該怎麼辦?諸如此類。
以這樣的方式進行開發,意味著要想出不同的程式碼實現路徑,並在測試中進行實踐。這樣做可使你少走前人的彎路:陷入一個問題後寫出毫不相關的解決方案。
該過程可描述如下:
- 寫出一個缺陷單元測試
- 使該單元測試通過
- 重構
與敏捷開發結合
TDD與敏捷開發並行不悖甚至1+1遠大於2,這裡指的是程式碼質量而不是數量。
“這意味著結對雙方都會參與其中,著重於當前工作,然後在每個環節進行互檢。”
然而在結對程式設計時TDD是單獨進行的。如果能把雙方的開發流程混合好,互相都能理解就最好不過了。例如,其中一人寫出單元測試,當測試通過後,另外一人可以編寫不同的測試以之通過。
任何時候結對雙方都可以互換角色,每半天或天。這意味著結對雙方都會參與其中,每人都把精力放在當前任務上,然後在每個環節進行交叉互檢。這難道不是一個雙贏的做法嗎?
TDD也可以是行為驅動開發過程中的組成部分,同樣地,首先寫出測試,只不過這裡指的是接受測試。這樣有助於把工作從頭到尾都保持規範。
單元測試語法
進行單元測試時,使用到的Python方法如下:
- assert: 編寫個人宣告的基本方式
- assertEqual(a,b):檢查a和b的是否等價
- assertNotEqual(a,b):檢查a和b的是否非等價
- assertIn(a,b):檢查是否存在b中
- assertNotIn(a,b): 檢查是否不存在b中
- assertFalse(a):檢查a的值是否為False
- assertTrue(a):檢查a的值是否為Ture
- assertIsInstance(a,TYPE):檢查a是否為“TYPE”型別
- assertRaises(ERROR,a,args):以引數args呼叫a時,檢查是否會出現ERROR
以上是實際當中使用頻率最高的方法,更多的方法請查閱Python單元測試文件。
安裝並使用Python Nose
進行下面的練習前,請把nosetest測試執行包安裝好。使用標準pip語句進行安裝是最直接的做法。此外在專案中使用VirtualEnv(Python虛擬環境)也是不錯的做法,因為它可確保所有包在不同專案中是獨立的。假如對pip或VirtualEnv瞭解不多,不妨先查閱相關文件:VirtualEnv,PIP。
pip語句十分簡潔:
1 |
"pip install nose" |
安裝完成後,可以執行單個測試檔案
1 |
$ nosetests example_unit_test.py |
或者可以直接執行資料夾中的檔案組
1 |
$ nosetests /path/to/tests |
這裡要注意的是每個測試方法都應以“test_”為開頭,這樣nosetest執行機才能正確識別出目標測試檔案。
可選引數
下面介紹幾個有用的命令列引數:
- -v:輸出更多資訊,包括正在執行的測試檔名;
- -s或-nocapture:進行PRINT語句輸出,一般情況下這是隱藏的。開啟後可方便除錯;
- –nologcapture:輸出日誌資訊;
- –rednose:一個可選外掛,請點選這裡下載,輸出帶顏色的輸出資訊;
- –tags=TAGS:指定要執行的測試檔案,而不是整個測試檔案組。
例項分析和測試驅動方法
接下來結合一個簡單的計算器類例子例如相加/相減,來講述Python單元測試和TDD概念。對於add相加功能,會嘗試編寫一個缺陷測試。
在一個空白專案中,首先建立兩個python包app和test。然後在每個檔案裡建立兩個名為_init_.py空白檔案。這是Phthon工程的標準結構,完成後可以擁有一個可匯入的檔案結構。如果需要了解更多有關文件架構的資訊,請查閱Python包說明文件。 在測試目錄裡建立一個test_calulator.py檔案,其程式碼如下:
1 2 3 4 5 6 |
import unittest class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result) |
說明:
- 首先,從Python標準庫裡匯入標準的unittest模組
- 接著,建立一個含有不同測試用例的類
- 最後,建立以“test_”為開頭的一個測試方法
完成後可著手編寫測試程式碼了。執行方法前要先對計算器進行初始化,初始化完成後便可呼叫add方法,並把結果存入變數result中。完成後,使用unittest的assertEqual方法來確保add方法正常執行。
現在可以啟動nosetest來執行測試檔案了。程式碼如下:
1 2 |
if __name__ == '__main__': unittest.main() |
標準的Python檔案執行方式為$ python test_calculator.py,相比之下本文使用的nosetests方法功能更豐富,例如可以執行目錄中的全部測試檔案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ nosetests test_calculator.py E ====================================================================== ERROR: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 6, in test_calculator_add_method_returns_correct_result calc = Calculator() NameError: global name 'Calculator' is not defined ---------------------------------------------------------------------- Ran 1 test in 0.001s FAILED (errors=1) |
執行後可見出錯的原因是沒有匯入Caculator。因為還沒有建立呢!建立的方法是在app目錄下建立calculator.py檔案,然後匯入:
1 2 3 |
class Calculator(object): def add(self, x, y): pass |
1 2 3 4 5 6 7 8 9 10 11 |
import unittest from app.calculator import Calculator class TddInPythonExample(unittest.TestCase): def test_calculator_add_method_returns_correct_result(self): calc = Calculator() result = calc.add(2,2) self.assertEqual(4, result) if __name__ == '__main__': unittest.main() |
把Caculator構建好之後,再次執行看會出現什麼結果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ nosetests test_calculator.py F ====================================================================== FAIL: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 9, in test_calculator_add_method_returns_correct_result self.assertEqual(4, result) AssertionError: 4 != None ---------------------------------------------------------------------- Ran 1 test in 0.001s FAILED (failures=1) |
很明顯,add方法返回了錯誤的值,因為還沒有為它指定行為。幸好nosetest會指出出錯的位置,方便進行修改。稍作改動後,測試便可通過了:
1 2 3 |
class Calculator(object): def add(self, x, y): return x+y |
1 2 3 4 5 6 |
$ nosetests test_calculator.py . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK |
雖然通過了,但是圍繞該方法還可以做更多的工作。
沉迷於某個案例很容易造成短視
如果進行非數字型資料相加會導致什麼後果呢?事實上Python是允許字串或其它型別進行相加的,但在我們的例子裡不允許。接著嘗試就這個例子加入另一個缺陷測試,然後使用assertRaises方法來判斷是否有異常丟擲:
1 2 3 4 5 6 7 8 9 10 11 12 |
import unittest from app.calculator import Calculator class TddInPythonExample(unittest.TestCase): def setUp(self): self.calc = Calculator() def test_calculator_add_method_returns_correct_result(self): result = self.calc.add(2, 2) self.assertEqual(4, result) def test_calculator_returns_error_message_if_both_args_not_numbers(self): self.assertRaises(ValueError, self.calc.add, 'two', 'three') if __name__ == '__main__': unittest.main() |
以上程式碼中,檢查了是否引起了ValueError錯誤,其實還可以進行更多的檢測,不過在這裡不作深入講述。此外,setup()方法用於推入計算物件。下面再看看nosetest會反饋什麼資訊:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ nosetests test_calculator.py .F ====================================================================== FAIL: test_calculator_returns_error_message_if_both_args_not_numbers (test.test_calculator.TddInPythonExample) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 15, in test_calculator_returns_error_message_if_both_args_not_numbers self.assertRaises(ValueError, self.calc.add, 'two', 'three') AssertionError: ValueError not raised ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1) |
顯然nosetests告訴我們ValueError沒有被丟擲。現在我們有了一個新的缺陷測試,接著嘗試編碼進行解決:
1 2 3 4 5 6 7 8 |
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): return x + y else: raise ValueError |
程式碼中使用了isinstance方法是為了確保輸入的是數字型資料。
由於兩個變數的型別有多種組合,為了進行完整的測試,所以需要把可能出現的組合進行統籌並進行處理:
1 2 3 4 5 6 7 8 |
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): return x + y else: raise ValueError |
至此我們可以執行所有的測試了,所要實現的需求也都滿足了。
其它的單元測試包
py.test
pytest的作用與nosetest類似,不過可以在單獨的區域裡輸出資訊,這意味著能夠使我們很快地看清楚命令列中出現的列印資訊。這對於只執行單個測試的情況是很有用的。
1 2 3 4 5 6 |
$ nosetests test_calculator.py .... ---------------------------------------------------------------------- Ran 4 tests in 0.001s OK |
安裝pytest的方式與nosetest差不多,命令是$ pip install pytes。執行的命令是$ pip install pytes或者指定要執行的測試檔案$ py.test test/calculator_tests.py。
1 2 3 4 5 6 7 8 |
$ py.test test/test_calculator.py ================================================================= test session starts ================================================================= platform darwin -- Python 2.7.6 -- py-1.4.26 -- pytest-2.6.4 collected 4 items test/test_calculator.py .... ============================================================== 4 passed in 0.02 seconds =============================================================== |
pytest執行後的結果如下。注:只有程式碼含有錯誤或異常的情況下,pytest才會進行輸出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
$ py.test test/test_calculator.py ================================================================= test session starts ================================================================= platform darwin -- Python 2.7.6 -- py-1.4.26 -- pytest-2.6.4 collected 4 items test/test_calculator.py F... ====================================================================== FAILURES ======================================================================= ________________________________________ TddInPythonExample.test_calculator_add_method_returns_correct_result _________________________________________ self = <test.test_calculator.TddInPythonExample testMethod=test_calculator_add_method_returns_correct_result> def test_calculator_add_method_returns_correct_result(self): result = self.calc.add(3, 2) > self.assertEqual(4, result) E AssertionError: 4 != 5 test/test_calculator.py:11: AssertionError ---------------------------------------------------------------- Captured stdout call ----------------------------------------------------------------- X value is: 3 Y value is: 2 Result is 5 ========================================================= 1 failed, 3 passed in 0.03 seconds ========================================================== |
單元測試
如果不想安裝額外的包並想保持一個純淨的標準庫結構,使用Python內建的unittest單元測試包是不錯的選擇。其使用方法如下:
1 2 |
if __name__ == '__main__': unittest.main() |
使用python calculator_tests.py執行後,看會得到什麼結果:
1 2 3 4 5 6 |
$ python test/test_calculator.py .... ---------------------------------------------------------------------- Ran 4 tests in 0.004s OK |
使用PDB進行除錯
以TDD方式開發,經常會遇到來自程式碼或測試的問題。有時這些錯誤又是比較隱蔽的。因此,需要配合使用高明的除錯技術。
以TDD方式進行開發出現問題時可能難以發現
幸運地,有不少的辦法來解決這些問題。其中最簡單的方式是透過增添print語句實現“斷點”輸出。
結合print語句進行除錯
加法通過後,可以嘗試進行減法除錯。把app/calculator.py中的add部分程式碼作如下改動:
1 2 3 4 5 6 7 8 |
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): return x - y else: raise ValueError |
這裡不妨嘗試使用print語句進行輸出,來監視值是怎樣變化的。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): print 'X is: {}'.format(x) print 'Y is: {}'.format(y) result = x - y print 'Result is: {}'.format(result) return result else: raise ValueError |
現在可以使用nosetest來執行並檢視結果,可見這樣的工整輸出結構,對除錯是十分有幫助的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
$ nosetests test/test_calculator.py F... ====================================================================== FAIL: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 11, in test_calculator_add_method_returns_correct_result self.assertEqual(4, result) AssertionError: 4 != 0 -------------------- >> begin captured stdout << --------------------- X is: 2 Y is: 2 Result is: 0 --------------------- >> end captured stdout << ---------------------- ---------------------------------------------------------------------- Ran 4 tests in 0.002s FAILED (failures=1) |
PDB進階除錯
如果遇到更復雜的除錯環節,僅僅依靠print語句是不夠的。其中最經常使用的進階除錯工具是pdb(Python Debugger)。該工具包含在標準庫中,使用的時候只需加入一行程式碼到“斷點”位置。請看下面的程式碼:
1 2 3 4 5 6 7 8 9 |
class Calculator(object): def add(self, x, y): number_types = (int, long, float, complex) if isinstance(x, number_types) and isinstance(y, number_types): import pdb; pdb.set_trace() return x - y else: raise ValueError |
請注意,如果使用nosetest執行測試,請務必使用-s標記,否則nosetest會繼續對輸出進行抓取,這樣會使pdb無法正常執行。如果是使用unittest或pytest則無需這樣做。
如果測試停止並有pdb提示,請使用list命令來進行當前程式碼定位。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
$ nosetests -s > /Users/user/PycharmProjects/tdd_in_python/app/calculator.py(7)add() -> return x - y (Pdb) list 2 def add(self, x, y): 3 number_types = (int, long, float, complex) 4 5 if isinstance(x, number_types) and isinstance(y, number_types): 6 import pdb; pdb.set_trace() 7 -> return x - y 8 else: 9 raise ValueError [EOF] (Pdb) |
出現提示後是可以進行互動操作的,比方說想在這個時候檢閱x和y的值:
1 2 3 4 |
(Pdb) x 2 (Pdb) y 2 |
如果想了解更多命令,可以鍵入help來檢視。經常使用的命令如下所示:
- n: 步進到下個執行
- list: 顯示當前位置
- args: 顯示在當前執行點上用到的變數
- continue:執行程式碼直至結束
- jump <line number>: 執行並跳轉到行號位置
- quit/exit:停止pdb
寫在最後
TDD模式十分有趣同時能幫助提高程式碼質量。不論是大型團隊還是個人開發,TDD都可運用其中。此外,成功的缺陷測試設計是非常有滿足感的。所以,不妨從今天起嘗試把TDD引入到日常工作中,親身體驗試驗前後會有什麼變化。