可愛的 Python: Python 中的測試框架
確保軟件如您所願地工作
在這一期文章中,David 研究了 Python 的兩個用於單元測試的標準模塊: unittest
和 doctest
。這些模塊擴展了用來確認函數內部的先置條件和後置條件的內置 assert
語句的能力。David
討論了將測試融入到 Python 開發中的最好方法,同時權衡了用於不同類型項目的不同風格的優勢。
在 IBM Bluemix 雲平臺上開發並部署您的下一個應用。
我要坦白一點。儘管我是一個應用相當廣泛的公共域 Python 庫的創造者,但在我的模塊中引入的單元測試是非常不繫統的。實際上,那些測試大部分 是包括在 gnosis.xml.pickle 的 Gnosis Utilities 中的,並由該子軟件包(subpackage)的貢獻者所編寫。我還發現,我下載的絕大多數第三方 Python 包都缺少完備的單元測試集。
不僅如此,Gnosis Utilities 中現有的測試也受困於另一個缺陷:您經常需要在極其大量的細節中去推定期望的輸出,以確定測試的成敗。測試實際上 -- 在很多情況下 -- 更像是使用庫的某些部分的小實用工具。這些測試(或實用工具)支持來自任意數據源(類型正確)的輸入和/或描述性數據格式的輸出。實際上,當您需要調試一些細微的錯誤時,這些測試實用工具更有用。但是對於庫版本間變化的自解釋的完整性檢查(sanity checks)來說,這些類測試就不能勝任了。
在這一期文章中,我嘗試使用 Python 標準庫模塊 doctest
和 unittest
來改進我的實用工具集中的測試,並帶領您與我一起體驗(並指出一些最好的方法)。
腳本 gnosis/xml/objectify/test/test_basic.py
給出了一個關於當前測試的缺點及解決方案的典型示例。下面是該腳本的最新版本:
清單 1. test_basic.py
"Read and print and objectified XML file" import sys from gnosis.xml.objectify import XML_Objectify, pyobj_printer if len(sys.argv) > 1: for filename in sys.argv[1:]: for parser in ('DOM','EXPAT'): try: xml_obj = XML_Objectify(filename, parser=parser) py_obj = xml_obj.make_instance() print pyobj_printer(py_obj).encode('UTF-8') sys.stderr.write("++ SUCCESS (using "+parser+")\n") print "="*50 except: sys.stderr.write("++ FAILED (using "+parser+")\n") print "="*50 else: print "Please specify one or more XML files to Objectify."
實用工具函數 pyobj_printer()
生成了任意 Python 對象(具體說是這樣一個對象,它既沒有用到 gnosis.xml.objectify
的任何其他實用工具,也沒有用到
Gnosis Utilities 中的 任何其他東西)的一個 非-XML 表示。在以後的版本中,我將可能會把這個函數移到 Gnosis 包內的其他地方。無論如何, pyobj_printer()
使用各種類-Python 的縮進和符號來描述對象和它們的屬性(類似於 pprint
,但是擴展了實例,而不僅限於擴展內置的數據類型)。
如果一些特別的 XML 可能不能正確被地“對象化(objectified)”, test_basic.py
腳本會提供一個很好的調試工具
-- 您可以可視化地查看結果對象的屬性和值。此外,如果您重定向了 STDOUT,您可以查看 STDERR 上的簡單消息,如這個例子中:
清單 2. 分析 STDERR 結果消息
$ python test_basic.py testns.xml > /dev/null
++ SUCCESS (using DOM)
++ FAILED (using EXPAT)
不過,上面運行的例子中對成功或失敗的界定很不明顯:成功只是意味着沒有出現異常,而不表示(重定向的)輸出 正確。
使用 doctest
doctest
模塊讓您可以在文檔字符串(docstrings)內嵌入註釋以顯示各種語句的期望行爲,尤其是函數和方法的結果。這樣做很像是讓文檔字符串看起來如同一個交互式
shell 會話;完成這一任務的一個簡單方法是,從一個 Python 交互式 shell 中(或者從 Idel、PythonWin、MacPython 或者其他帶有交互式會話的
IDE 中)拷貝-粘貼。這一改進的 test_basic.py
腳本舉例說明了自診斷功能的添加:
清單 3. 具有自診斷功能的 test_basic.py 腳本
import sys from gnosis.xml.objectify import XML_Objectify, pyobj_printer, EXPAT, DOM LF = "\n" def show(xml_src, parser): """Self test using simple or user-specified XML data >>> xml = '''<?xml version="1.0"?> ... <!DOCTYPE Spam SYSTEM "spam.dtd" > ... <Spam> ... <Eggs>Some text about eggs.</Eggs> ... <MoreSpam>Ode to Spam</MoreSpam> ... </Spam>''' >>> squeeze = lambda s: s.replace(LF*2,LF).strip() >>> print squeeze(show(xml,DOM)[0]) -----* _XO_Spam *----- {Eggs} PCDATA=Some text about eggs. {MoreSpam} PCDATA=Ode to Spam >>> print squeeze(show(xml,EXPAT)[0]) -----* _XO_Spam *----- {Eggs} PCDATA=Some text about eggs. {MoreSpam} PCDATA=Ode to Spam PCDATA= """ try: xml_obj = XML_Objectify(xml_src, parser=parser) py_obj = xml_obj.make_instance() return (pyobj_printer(py_obj).encode('UTF-8'), "++ SUCCESS (using "+parser+")\n") except: return ("","++ FAILED (using "+parser+")\n") if __name__ == "__main__": if len(sys.argv)==1 or sys.argv[1]=="-v": import doctest, test_basic doctest.testmod(test_basic) elif sys.argv[1] in ('-h','-help','--help'): print "You may specify XML files to objectify instead of self-test" print "(Use '-v' for verbose output, otherwise no message means success)" else: for filename in sys.argv[1:]: for parser in (DOM, EXPAT): output, message = show(filename, parser) print output sys.stderr.write(message) print "="*50
注意,我在經過改進(和擴展)的測試腳本中放入了 main
代碼塊,這樣,如果您在命令行中指定了
XML 文件,腳本將繼續執行以前的行爲。這樣就讓您可以繼續分析測試用例以外其他的 XML,並只着眼於結果 -- 或者找出 gnosis.xml.objectify 所做事情中的錯誤,或者只是理解其目的。按標準的方式,您可以使用 -h
或 --help
參數來獲得用法的說明。
當不帶任何參數(或者帶有隻被 doctest
使用的 -v
參數)運行 test_basic.py
時,就會發現有趣的新功能。在這個例子中,我們在模塊/腳本自身上運行 doctest
--
您可以看到,實際上我們將 test_basic
導入到腳本自己的名稱空間中,這樣我們可以簡單地導入其他希望要測試的模塊。 doctest.testmod()
函數去遍歷模塊本身、它的函數以及它的類中的所有文檔字符串,以找出所有類似交互式
shell 會話的內容;在這個例子中,會在 show()
函數中找到這樣一個會話。
show()
的文檔字符串舉例說明了在設計好的 doctest
會話過程中的幾個小“陷阱(gotchas)”。不幸的是, doctest
在解析顯式會話時,將空行作爲會話結束來處理
-- 所以,像 pyobj_printer()
的返回值這樣的輸出需要加一些保護(be
munged slightly)以進行測試。最簡單的途徑是使用文檔字符串本身所定義的像 squeeze()
這樣的函數(它只是除去緊跟在後面的換行)。此外,由於文檔字符串畢竟是字符串換碼(escape),所以 \n
這樣的序列被擴展,這樣使得在代碼示例 內部對換行進行換碼稍微有一些混亂。您可以使用 \\n
,不過我發現對 LF
的定義解決了這些問題。
在 show()
的文檔字符串中定義的自測試所做的不僅是確保不發生異常(對照於最初的測試腳本)。爲正確的“對象化(objectification)”至少要檢查一個簡單的
XML 文檔。當然,仍然有可能不能正確地處理一些其他的 XML 文檔 -- 例如,上面我們試過的名稱空間 XML 文檔 testns.xml
遇到了
EXPAT 解析器失敗。由 doctest
處理的文檔字符串 可能會在其內部包含回溯(traceback),但是在特別的情況下,更好的方法是使用 unittest
。
使用 unittest
另一個包含在 gnosis.xml.objectify
中的測試是 test_expat.py
。創建這一測試的主要原因僅在於,使用
EXPAT 解析器的子軟件包用戶常常需要調用一個特別的設置函數來啓用有名稱空間的 XML 文檔的處理(這個實際情況是演化來的而不是設計如此,並且以後可能會改變)。老的測試會試圖不借助設置去打印對象,如果發生異常則捕獲之,然後如果需要的話藉助設置再去打印(並給出一個關於所發生事情的消息)。
而如果使用 test_basic.py
, test_expat.py
工具讓您可以分析 gnosis.xml.objectify
如何去描述一個新奇的
XML 文檔。但是與以前一樣,有很多我們可能想去驗證的具體行爲。 test_expat.py
的一個增強的、擴展的版本使用 unittest
來分析各種動作執行時發生的事情,包括持有特定條件或(近似)等式的斷言,或出現期望的某些異常。看一看:
清單 4. 自診斷的 test_expat.py 腳本
"Objectify using Expat parser, namespace setup where needed" import unittest, sys, cStringIO from os.path import isfile from gnosis.xml.objectify import make_instance, config_nspace_sep,\ XML_Objectify BASIC, NS = 'test.xml','testns.xml' class Prerequisite(unittest.TestCase): def testHaveLibrary(self): "Import the gnosis.xml.objectify library" import gnosis.xml.objectify def testHaveFiles(self): "Check for sample XML files, NS and BASIC" self.failUnless(isfile(BASIC)) self.failUnless(isfile(NS)) class ExpatTest(unittest.TestCase): def setUp(self): self.orig_nspace = XML_Objectify.expat_kwargs.get('nspace_sep','') def testNoNamespace(self): "Objectify namespace-free XML document" o = make_instance(BASIC) def testNamespaceFailure(self): "Raise SyntaxError on non-setup namespace XML" self.assertRaises(SyntaxError, make_instance, NS) def testNamespaceSuccess(self): "Sucessfully objectify NS after setup" config_nspace_sep(None) o = make_instance(NS) def testNspaceBasic(self): "Successfully objectify BASIC despite extra setup" config_nspace_sep(None) o = make_instance(BASIC) def tearDown(self): XML_Objectify.expat_kwargs['nspace_sep'] = self.orig_nspace if __name__ == '__main__': if len(sys.argv) == 1: unittest.main() elif sys.argv[1] in ('-q','--quiet'): suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(Prerequisite)) suite.addTest(unittest.makeSuite(ExpatTest)) out = cStringIO.StringIO() results = unittest.TextTestRunner(stream=out).run(suite) if not results.wasSuccessful(): for failure in results.failures: print "FAIL:", failure[0] for error in results.errors: print "ERROR:", error[0] elif sys.argv[1].startswith('-'): # pass args to unittest unittest.main() else: from gnosis.xml.objectify import pyobj_printer as show config_nspace_sep(None) for fname in sys.argv[1:]: print show(make_instance(fname)).encode('UTF-8')
使用 unittest
爲較簡單的 doctest
方式增添了相當多的能力。我們可以將我們的測試分爲幾個類,每一個類都繼承自 unittest.TestCase
。在每一個測試類內部,每一個名稱以“.test”開始的方法都被認爲是另一個測試。爲 ExpatTest
定義的兩個額外的類很有趣:在每次使用類執行測試前運行 .setUp()
,測試結束時運行 .tearDown()
(不管測試是成功、失敗還是出現錯誤)。在我們上面的例子中,我們爲專用的 expat_kwargs
字典做了一點簿記以確保每個測試獨立地運行。
順便提一下,失敗(failure)和錯誤(error)之間的區別很重要。一個測試可能會因爲一些具體的斷言無效而失敗(斷言方法或者以“.fail”開頭,或者以“.assert”開頭)。在某種意義上,失敗是期望中的 -- 最起碼從某種意義上我們已經具體分析過。另一方面,錯誤是意外的問題 -- 因爲我們事先不知道哪裏會出錯,我們需要分析實際測試運行中的回溯來診斷這種問題。不過,我們可以設計讓失敗給出診斷錯誤的提示。例如,如果 Prerequisite.haveFiles()
失敗,將在一些 TestExpat
測試中出現錯誤;如果前者是成功的,您將不得不到其他地方去查找錯誤的根源。
在 unittest.TestCase
的繼承類中,具體的測試方法中可能會包括一些 .assert...()
或者 .fail...()
方法,但也可能只是具有一系列我們相信應該會成功執行的動作。如果測試方法沒有按預期運行,我們將得到一個錯誤(以及描述這個錯誤的回溯)。
test_expat.py
中的 _main_
程序塊也值得察看。在最簡單的情況下,我們可以只使用 unittest.main()
來運行測試用例,這將斷定哪些需要運行。使用這種方式時, unittest
模塊將接受一個 -v
選項以給出更詳細的輸出。根據指定的文件名,在執行了名稱空間設置後,我們打印出指定的
XML 文件的表示,從而大致上保持了對此工具稍老版本的向後兼容。
_main_
中最有趣的分支是期待 -q
或 --quiet
標籤的那個分支。如您將期望的,除非發生失敗或錯誤,否則這個分支將是靜默的(quiet,即儘量減少輸出)。不僅如此,由於它是靜默的,它只會爲每個問題顯示一行關於失敗/錯誤位置的報告,而不是整個診斷回溯。除了對靜默輸出風格的直接利用以外,這個分支還舉例說明了相對於測試套件的自定義測試以及對結果報告的控制。稍微有些長的 unittest.TextTestRunner()
的默認輸出被定向到
StringIO out
--
如果您想查看它,歡迎您到 out.getvalue()
去查找。不過, result
對象讓我們對全面成功進行測試,如果不是完全成功還可以讓我們處理失敗和錯誤。顯然,由於它們是變量中的值,您可以輕鬆地將 result
對象的內容記錄入日誌,或者在
GUI 中顯示,不管怎麼樣,不是僅僅打印到 STDOUT。
組合測試
可能 unittest
框架最好的特性是讓您可以輕鬆地組合包含不同模塊的測試。實際上,如果使用 Python 2.3+,您甚至可以將 doctest
測試轉化爲 unittest
套件。讓我們將到目前爲止所創建的測試組合到一個腳本 test_all.py
中(誠然,說它是我們目前爲止所做的測試有些誇張):
清單 5. test_all.py 組合了單元測試
"Combine tests for gnosis.xml.objectify package (req 2.3+)" import unittest, doctest, test_basic, test_expat suite = doctest.DocTestSuite(test_basic) suite.addTest(unittest.makeSuite(test_expat.Prerequisite)) suite.addTest(unittest.makeSuite(test_expat.ExpatTest)) unittest.TextTestRunner(verbosity=2).run(suite)
由於 test_expat.py
只是包含測試類,所以它們可以容易地添加到本地的測試套件中。 doctest.DocTestSuite()
函數執行文檔字符串測試的轉換。讓我們來看看 test_all.py
運行時會做什麼:
清單 6. 來自 test_all.py 的成功輸出
$ python2.3 test_all.py
doctest of test_basic.show ... ok
Check for sample XML files, NS and BASIC ... ok
Import the gnosis.xml.objectify library ... ok
Raise SyntaxError on non-setup namespace XML ... ok
Sucessfully objectify NS after setup ... ok
Objectify namespace-free XML document ... ok
Successfully objectify BASIC despite extra setup ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.052s
OK
注意對執行的測試的描述:在使用 unittest
測試方法的情況下,他們的描述來自於相應的 docstring
函數。如果您沒有指定文檔字符串,類和方法名被用作最合適的描述。來看一下如果一些測試失敗時我們會得到什麼,同樣有趣(爲本文去掉了回溯細節):
清單 7. 當一些測試失敗時的結果
$ mv testns.xml testns.xml# && python2.3 test_all.py 2>&1 | head -7
doctest of test_basic.show ... ok
Check for sample XML files, NS and BASIC ... FAIL
Import the gnosis.xml.objectify library ... ok
Raise SyntaxError on non-setup namespace XML ... ERROR
Sucessfully objectify NS after setup ... ERROR
Objectify namespace-free XML document ... ok
Successfully objectify BASIC despite extra setup ... ok
隨便提及,這個失敗寫到 STDERR 的最後一行是“FAILED (failures=1, errors=2)”,如果您需要的話這是一個很好的總結(相對於成功時最終的“OK”)。
從這裏開始
本文向您介紹了 unittest
和 doctest
的一些典型用法,它們已經改進了我自己的軟件中的測試。閱讀 Python 文檔,以深入瞭解可用於測試套件、測試用例和測試結果的全部範圍的方法。它們全部都遵循例子中所描述的模式。
讓自己遵從 Python 的標準測試模塊規定的方法學是良好的軟件實踐。測試驅動(test-driven)的開發在很多軟件週期中都很流行;不過,顯然 Python 是一門適合於測試驅動模型的語言。而且,如果只是考慮軟件包更可能按計劃工作,一個軟件包或庫如果伴隨有一組周全的測試,會比缺乏這些測試的軟件包或庫對用戶更爲有用。
參考資料
- 您可以參閱本文在 developerWorks 全球站點上的 英文原文.
-
隨 Python 標準庫發行的文檔的確是查看有關
unittest
和doctest
最新細節的最好選擇。 -
爲了解輔助模塊應該去看一看 PyUnit Web 站點。
unittest
模塊繼承自原來獨立的PyUnit
。這個老一些的項目站點上仍然有那些沒有包含在unittest
中的工具的鏈接,比如用於測試的 GUI 前端。 - PyUnit 從 JUnit 中借鑑了很多概念。而 JUnit 的思想又大部分來源於 Smalltalk。用於其他語言的框架也是 XUnit 家族的一部分。
- 要獲得關於測試的永遠有效的好建議,以及瞭解單元測試和功能測試的區別,請閱讀“ 測試是一件有趣的事情?真的嗎?”( developerWorks, 2001 年)。
- 如果您同樣是一個 Java 開發者,請參閱“ The nuts and bolts of creating and unit testing a business process”( developerWorks, 2003 年),它是關於使用 WebSphere Studio 進行開發和測試的系列文章的第一部分。也請關注 Rational approach( developerWorks