Python中的测试模块unittest和doctest的使用教程
我要坦白一点。尽管我是一个应用相当广泛的公共域Python库的创造者,但在我的模块中引入的单元测试是非常不系统的。实际上,那些测试大部分是包括在gnosis.xml.pickle的GnosisUtilities中的,并由该子软件包(subpackage)的贡献者所编写。我还发现,我下载的绝大多数第三方Python包都缺少完备的单元测试集。
不仅如此,GnosisUtilities中现有的测试也受困于另一个缺陷:您经常需要在极其大量的细节中去推定期望的输出,以确定测试的成败。测试实际上--在很多情况下--更像是使用库的某些部分的小实用工具。这些测试(或实用工具)支持来自任意数据源(类型正确)的输入和/或描述性数据格式的输出。实际上,当您需要调试一些细微的错误时,这些测试实用工具更有用。但是对于库版本间变化的自解释的完整性检查(sanitychecks)来说,这些类测试就不能胜任了。
在这一期文章中,我尝试使用Python标准库模块doctest和unittest来改进我的实用工具集中的测试,并带领您与我一起体验(并指出一些最好的方法)。
脚本gnosis/xml/objectify/test/test_basic.py给出了一个关于当前测试的缺点及解决方案的典型示例。下面是该脚本的最新版本:
清单1.test_basic.py
"ReadandprintandobjectifiedXMLfile" importsys fromgnosis.xml.objectifyimportXML_Objectify,pyobj_printer iflen(sys.argv)>1: forfilenameinsys.argv[1:]: forparserin('DOM','EXPAT'): try: xml_obj=XML_Objectify(filename,parser=parser) py_obj=xml_obj.make_instance() printpyobj_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"PleasespecifyoneormoreXMLfilestoObjectify."
实用工具函数pyobj_printer()生成了任意Python对象(具体说是这样一个对象,它既没有用到gnosis.xml.objectify的任何其他实用工具,也没有用到GnosisUtilities中的任何其他东西)的一个非-XML表示。在以后的版本中,我将可能会把这个函数移到Gnosis包内的其他地方。无论如何,pyobj_printer()使用各种类-Python的缩进和符号来描述对象和它们的属性(类似于pprint,但是扩展了实例,而不仅限于扩展内置的数据类型)。
如果一些特别的XML可能不能正确被地“对象化(objectified)”,test_basic.py脚本会提供一个很好的调试工具--您可以可视化地查看结果对象的属性和值。此外,如果您重定向了STDOUT,您可以查看STDERR上的简单消息,如这个例子中:
清单2.分析STDERR结果消息
$pythontest_basic.pytestns.xml>/dev/null ++SUCCESS(usingDOM) ++FAILED(usingEXPAT)
不过,上面运行的例子中对成功或失败的界定很不明显:成功只是意味着没有出现异常,而不表示(重定向的)输出正确。
使用doctest
doctest模块让您可以在文档字符串(docstrings)内嵌入注释以显示各种语句的期望行为,尤其是函数和方法的结果。这样做很像是让文档字符串看起来如同一个交互式shell会话;完成这一任务的一个简单方法是,从一个Python交互式shell中(或者从Idel、PythonWin、MacPython或者其他带有交互式会话的IDE中)拷贝-粘贴。这一改进的test_basic.py脚本举例说明了自诊断功能的添加:
清单3.具有自诊断功能的test_basic.py脚本
importsys fromgnosis.xml.objectifyimportXML_Objectify,pyobj_printer,EXPAT,DOM LF="\n" defshow(xml_src,parser): """Selftestusingsimpleoruser-specifiedXMLdata >>>xml='''<?xmlversion="1.0"?> ...<!DOCTYPESpamSYSTEM"spam.dtd"> ...<Spam> ...<Eggs>Sometextabouteggs.</Eggs> ...<MoreSpam>OdetoSpam</MoreSpam> ...</Spam>''' >>>squeeze=lambdas:s.replace(LF*2,LF).strip() >>>printsqueeze(show(xml,DOM)[0]) -----*_XO_Spam*----- {Eggs} PCDATA=Sometextabouteggs. {MoreSpam} PCDATA=OdetoSpam >>>printsqueeze(show(xml,EXPAT)[0]) -----*_XO_Spam*----- {Eggs} PCDATA=Sometextabouteggs. {MoreSpam} PCDATA=OdetoSpam 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__": iflen(sys.argv)==1orsys.argv[1]=="-v": importdoctest,test_basic doctest.testmod(test_basic) elifsys.argv[1]in('-h','-help','--help'): print"YoumayspecifyXMLfilestoobjectifyinsteadofself-test" print"(Use'-v'forverboseoutput,otherwisenomessagemeanssuccess)" else: forfilenameinsys.argv[1:]: forparserin(DOM,EXPAT): output,message=show(filename,parser) printoutput 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()的返回值这样的输出需要加一些保护(bemungedslightly)以进行测试。最简单的途径是使用文档字符串本身所定义的像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脚本
"ObjectifyusingExpatparser,namespacesetupwhereneeded" importunittest,sys,cStringIO fromos.pathimportisfile fromgnosis.xml.objectifyimportmake_instance,config_nspace_sep,\ XML_Objectify BASIC,NS='test.xml','testns.xml' classPrerequisite(unittest.TestCase): deftestHaveLibrary(self): "Importthegnosis.xml.objectifylibrary" importgnosis.xml.objectify deftestHaveFiles(self): "CheckforsampleXMLfiles,NSandBASIC" self.failUnless(isfile(BASIC)) self.failUnless(isfile(NS)) classExpatTest(unittest.TestCase): defsetUp(self): self.orig_nspace=XML_Objectify.expat_kwargs.get('nspace_sep','') deftestNoNamespace(self): "Objectifynamespace-freeXMLdocument" o=make_instance(BASIC) deftestNamespaceFailure(self): "RaiseSyntaxErroronnon-setupnamespaceXML" self.assertRaises(SyntaxError,make_instance,NS) deftestNamespaceSuccess(self): "SucessfullyobjectifyNSaftersetup" config_nspace_sep(None) o=make_instance(NS) deftestNspaceBasic(self): "SuccessfullyobjectifyBASICdespiteextrasetup" config_nspace_sep(None) o=make_instance(BASIC) deftearDown(self): XML_Objectify.expat_kwargs['nspace_sep']=self.orig_nspace if__name__=='__main__': iflen(sys.argv)==1: unittest.main() elifsys.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) ifnotresults.wasSuccessful(): forfailureinresults.failures: print"FAIL:",failure[0] forerrorinresults.errors: print"ERROR:",error[0] elifsys.argv[1].startswith('-'):#passargstounittest unittest.main() else: fromgnosis.xml.objectifyimportpyobj_printerasshow config_nspace_sep(None) forfnameinsys.argv[1:]: printshow(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()的默认输出被定向到StringIOout--如果您想查看它,欢迎您到out.getvalue()去查找。不过,result对象让我们对全面成功进行测试,如果不是完全成功还可以让我们处理失败和错误。显然,由于它们是变量中的值,您可以轻松地将result对象的内容记录入日志,或者在GUI中显示,不管怎么样,不是仅仅打印到STDOUT。
组合测试
可能unittest框架最好的特性是让您可以轻松地组合包含不同模块的测试。实际上,如果使用Python2.3+,您甚至可以将doctest测试转化为unittest套件。让我们将到目前为止所创建的测试组合到一个脚本test_all.py中(诚然,说它是我们目前为止所做的测试有些夸张):
清单5.test_all.py组合了单元测试
"Combinetestsforgnosis.xml.objectifypackage(req2.3+)" importunittest,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.3test_all.py doctestoftest_basic.show...ok CheckforsampleXMLfiles,NSandBASIC...ok Importthegnosis.xml.objectifylibrary...ok RaiseSyntaxErroronnon-setupnamespaceXML...ok SucessfullyobjectifyNSaftersetup...ok Objectifynamespace-freeXMLdocument...ok SuccessfullyobjectifyBASICdespiteextrasetup...ok ---------------------------------------------------------------------- Ran7testsin0.052s OK
注意对执行的测试的描述:在使用unittest测试方法的情况下,他们的描述来自于相应的docstring函数。如果您没有指定文档字符串,类和方法名被用作最合适的描述。来看一下如果一些测试失败时我们会得到什么,同样有趣(为本文去掉了回溯细节):
清单7.当一些测试失败时的结果
$mvtestns.xmltestns.xml#&&python2.3test_all.py2>&1|head-7 doctestoftest_basic.show...ok CheckforsampleXMLfiles,NSandBASIC...FAIL Importthegnosis.xml.objectifylibrary...ok RaiseSyntaxErroronnon-setupnamespaceXML...ERROR SucessfullyobjectifyNSaftersetup...ERROR Objectifynamespace-freeXMLdocument...ok SuccessfullyobjectifyBASICdespiteextrasetup...ok
随便提及,这个失败写到STDERR的最后一行是“FAILED(failures=1,errors=2)”,如果您需要的话这是一个很好的总结(相对于成功时最终的“OK”)。
从这里开始
本文向您介绍了unittest和doctest的一些典型用法,它们已经改进了我自己的软件中的测试。阅读Python文档,以深入了解可用于测试套件、测试用例和测试结果的全部范围的方法。它们全部都遵循例子中所描述的模式。
让自己遵从Python的标准测试模块规定的方法学是良好的软件实践。测试驱动(test-driven)的开发在很多软件周期中都很流行;不过,显然Python是一门适合于测试驱动模型的语言。而且,如果只是考虑软件包更可能按计划工作,一个软件包或库如果伴随有一组周全的测试,会比缺乏这些测试的软件包或库对用户更为有用。