我要坦白一点。尽管我是一个应用相当广泛的公共域 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."
</div>
实用工具函数 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)</div>
不过,上面运行的例子中对成功或失败的界定很不明显:成功只是意味着没有出现异常,而不表示(重定向的)输出 正确。
使用 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
</div>
注意,我在经过改进(和扩展)的测试脚本中放入了 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

