Python 测试框架: 选择和运行测试
 

2009-09-14 作者:Brandon Craig Rhodes 来源:IBM

 
本文内容包括:
最近出现了行业级的 Python 测试框架,这意味着 Python 测试可以编写得更简洁、更统一,能够产生更好的结果报告。本文讨论三种最流行的测试框架如何识别和收集测试,以及它们如何支持编写完整的测试层,共享共同的 setup 和 teardown 代码。

这三篇系列文章 第一篇文章 讨论了标准测试框架(比如 zope.testingpy.testnose)给 Python 测试领域带来的革命性影响。这些框架支持更简单的测试方法,让项目不再需要为运行测试编写和维护专门的代码。第二篇文章 讨论了这些自动化解决方案如何搜索 Python 包以识别可能包含测试的模块。

本文讨论下一步,介绍框架在找到测试模块之后如何发现其中的待测试项。还讨论一些细节,比如这三种框架对共同的 setup 和 teardown 代码的支持情况。

Zope 框架中的测试发现

决定了感兴趣的模块列表之后,如何发现其中的实际测试呢?

对于 zope.testing 框架,您会发现 Zope 社区有一些有意思的现象。Zope 社区并不为解决每个问题构建大型工具,而是构建小型的功能有限的工具,这些工具能够连接在一起。目前,zope.testing 模块本身实际上根本没有提供检测测试的机制!

相反,zope.testing 让程序员自己寻找每个模块中需要运行的测试并把它们集中在一个列表中。它在每个测试模块中只寻找一个东西:test_suite() 函数,这个函数应该返回标准 unittest.TestSuite 类的实例,其中包含模块定义的测试。

使用 zope.testing 的一些程序员在 test_suite() 函数中手工地创建和维护测试列表。其他程序员通过编写定制代码发现已经定义的可用测试。但是,最有意思的方法是使用另一个 Zope 包 z3c.testsetup,它能够像其他现代 Python 测试框架一样自动地发现包中的测试。

这一现象再次说明 Zope 程序员倾向于编写小型代码块,然后使用它们构建框架,而不是编写大型的全面解决方案。z3c.testsetup 包不包含可以选择测试的命令行界面,也不包含可以显示测试结果的输出模块;它完全依靠 zope.testing 实现这些功能。

实际上,z3c.testsetup 用户一般不使用 zope.testing 的测试模块发现功能。相反,他们绕开 zope.testing 的算法,按照它的默认行为只寻找名为 test.py 的模块,然后在整个源代码树中只提供一个采用此名称的模块。在最简单的情况下,他们的 test.py 像下面这样:

import z3c.testsetup
test_suite = z3c.testsetup.register_all_tests(my_package)

这完全不通过 zope.testing 执行测试发现任务,而是依靠 z3c.testsetup 本身提供的更强大的发现机制。

可以向 register_all_tests() 函数提供几个配置选项。详细信息请参见 z3c.testsetup 文档,这里只需要介绍它的基本行为。与本文讨论的其他框架不同,z3c.testsetup 在默认情况下不关心包中每个 Python 模块的名称,而是关注它的内容。它检查所有模块以及包中的所有 .txt.rst 文件,选择文本中指定了 :Test-Layer: 的文件。然后,它组合模块中的所有 TestCase 和文本文件中的所有 doctest 部分,形成测试套件。

使用 :Test-Layer: 字符串标出包含测试的文件是一种有意思的机制。它的缺点是,在浏览包的文件时,为了找到测试的位置,新程序员必须打开每个文件,至少要用 grep 命令寻找 :Test-Layer: 字符串。(更不用提 z3c.testsetup 显然必须做同样的事;这使它比那些只操作文件名的框架要慢)。

最后注意,Zope 测试框架只支持 UnitTest 实例或 doctest。正如本系列的第一篇文章中讨论的,更现代的 Python 测试框架还支持一般的 Python 函数作为有效测试。这需要不同的测试检测算法,在下面讨论的框架中就会看到。

py.test 和 nose 中的测试发现

正如前一篇文章中提到的,py.testnose 框架使用相似但略有差异的规则集搜索 Python 包,寻找它们认为包含测试的模块。但是,之后它们都会遇到相同的情况:它们必须检查模块列表,寻找开发人员希望作为测试运行的函数和类。

正如在前一篇文章中看到的,py.test 往往选择单一标准,期望使用它的所有项目都遵守这一标准;而 nose 允许更丰富的定制,但是这会牺牲行为的可预测性。对于测试发现,也是如此:py.test 按照固定、不可变且可预测的规则检测测试模块中的测试,而 nose 采用灵活的可定制的规则。如果项目使用 nose 执行测试,就先必须阅读项目的 setup.cfg 文件,了解 nose 是采用通常的测试检测规则,还是采用这个项目特有的规则。

下面是 py.test 使用的过程:

  • py.test 检查 Python 测试模块的内部时,它收集名称以 test_ 开头的每个函数和名称以 Test 开头的每个类。无论类是否继承自 unittest.TestCase,它都会收集它们。
  • 测试函数直接运行,但是对于测试类,还必须搜索方法。类实例化之后,作为测试运行名称以 test_ 开头的所有方法。
  • 如果测试类继承自标准的 Python unittest.TestCase 类,py.test 框架会表现出一种古怪的行为:如果类不包含 runTest() 方法,那么即使它包含几个 test_ 方法,py.test 也会抛出异常并失败。但是,如果存在 runTest() 方法,py.test 会忽略它;这个方法必须存在,py.test 才能接受这个类,但是不会运行这个方法,因为它的名称不是以 test_ 开头的。

    为了纠正这种行为,可以在项目的 conttest.py 文件中或使用 -p 命令行选项激活框架的 unittest 插件:

    		            $ py.test -p unittest
     

    这会让 py.test 对其行为做三个更改。首先,不再只检测名称以 Test 开头的类,还会检测继承自 unittest.TestCase 的其他类。第二,对于没有提供 runTest() 方法的 TestCase 子类,py.test 不再报告异常。第三,在类包含的测试之前和之后,以标准方式正确地运行 TestCase 子类中的所有 setUp()tearDown() 方法。

尽管 nose 提供更强的定制能力,但是比较简单的测试发现过程如下:

  • nose 检查 Python 测试模块的内部时,它采用在选择测试模块时使用的正则表达式,收集与这个正则表达式匹配的函数和类。(在默认情况下,寻找包含单词 Testtest 的名称,但是可以通过命令行或配置文件提供不同的正则表达式)。
  • nose 检查测试类的内部时,它运行与同一正则表达式匹配的方法。
  • 无须特别指定,nose 总会检测 unittest.TestCase 的子类并作为测试使用它们。但是,它会根据自己的正则表达式决定哪些方法是测试,而不使用标准的 unittest 模式 ^test

衍生测试(Generative tests)

正如在第一篇文章中看到的,py.testnose 都支持编写为简单函数的测试,这会大大简化 Python 测试的编写:

# test_new.py - simple tests functions

def testTrue(self):
    assert True == 1

def testFalse(self):
assert False == 0

如果只需要在某一特定环境中检查组件行为,那么测试函数和更传统的测试类都可以满足需要。但是,如果要执行一系列测试,它们基本相同,只有一些参数有差异,那么怎么办?

为了更容易实现这种测试,避免多次复制并粘贴测试函数以及设置惟一的名称,py.testnose 都支持衍生测试(generative test)。这里的思想是,您提供一个测试函数,它实际上是一个迭代器,然后使用它的 yield 语句并提供调用参数,从而返回一系列函数。例如,如果希望针对一系列 Web 浏览器运行一个测试,可以编写下面这样的代码:

# test_browser.py

def check(browser, page):
    t = TestBrowser(browser)
    t.load_page(page)
    t.check_status(200)

def test_browsers():
    for b in 'ie6', 'ie7', 'firefox', 'safari':
        for p in 'index.html', 'about.html':
        yield check, b, p

对于衍生测试,py.test 更方便。因此您能够更加轻松的分辨出正在运行的测试,并在一个或多个测试失败时理解测试报告,在每个元组中提供的第一项可以是一个名称,它会作为测试名称的组成部分输出:

# Alternate yield statement, for py.test
...
yield 'Page %s browser %s' % (b,p), check, b, p

当前,许多项目使用手工测试或者只能使用 unittest 支持的功能,这些技术非常笨拙。衍生测试提供了好得多的参数化测试解决方案。

setup 和 teardown

在设计和编写测试套件时,一个大问题是如何处理共同的 setup 和 teardown 代码。许多真实的测试并不像本文给出的示例函数这么简单;它们必须执行一些复杂的操作,比如在 Firefox 中打开网页并单击 “Continue” 按钮,然后检查结果。在开始实际测试(比如打开页面并单击按钮)之前,测试必须先完成一些步骤。

现在,考虑如果一百个功能性测试都要这样执行测试,会怎么样。它们都需要通过调用共同的 setup 例程运行 Firefox,然后才能执行自己的测试。与此相应,为了取消 setup 所做的操作,可能还有 teardown 代码。这样,在测试套件中就会增加两百个额外的函数调用。每个测试函数会像下面这样:

# How test functions look if they each do setup and teardown

def test_index_click_continue():
    do_big_setup()          # <- the same in every test
    t = TestBrowser(browser)
    t.load_page('index.html')
    t.click('#continue')
    t.check_status(200)
    do_big_teardown()       # <- the same in every test

为了消除这些重复的代码,许多测试框架提供了一次性指定每个测试都需要运行的 setup 和 teardown 代码的机制。

本文讨论的三种框架 zope.testingpy.testnose 都支持程序员编写的 unittest.TestCase 类中的标准 setUp()tearDown() 例程。但是,除此之外,各个框架为共同 setup 代码提供的特性有显著差异。

zope.testing 本身没有为 setup 和 teardown 提供额外支持,但是前面讨论过的 z3c.testsetup 扩展会对 doctest 做一些有意思的处理。它通过在文件中寻找 :Test-Layer: 字符串来寻找测试。doctest 中的层实际上可以指定两个值之一。如果把 doctest 标为属于 unit 层,就意味着运行它不需要任何特殊的 setup。但是,如果把它标为属于 functional 层,就意味着只能在调用框架 setup 函数之后运行它。

通常情况下,:Test-Layer: functional 测试被设计为在完整地配置了 Zope Web 框架的情况下运行,因此它们可以创建测试浏览器实例、发送请求和查看 Web 框架返回的响应。通过代表 doctest 执行 setup,z3c.testsetup 可以避免在每个功能性 doctest 中复制大量样板代码。

最后一项减少样板代码的便捷特性是,可以向 z3c.testsetup 提供一个预装载到每个单元 doctest 的名称空间中的变量列表,以及另一个预装载到每个功能性 doctest 中的变量列表。这样就不需要在每个 doctest 文件的开头复制一组相同的 import 语句。

py.test 在默认情况下不提供对 setup 和 teardown 的支持。它甚至不运行标准 unittest.TestCase 类的 setUp() tearDown() 方法,除非打开它的 unittest 插件。

nose 在支持共同测试代码方面是最出色的。在寻找测试时,nose 跟踪记录找到测试的上下文。它认为 unittest.TestCase 子类内部的每个测试方法是这个类 “内部的”,因此由它的 setUp()tearDown() 方法控制,它还认为测试存在于它们的模块、包含模块的包以及外层所有包的 “内部”。因此,对于多层 “同心” 容器内的测试,nose 会在运行测试之前运行所有容器中的 setup 代码,在运行测试之后运行所有容器中的 teardown 代码。

关于包范围和模块范围的 setup 和 teardown 函数的更多信息,请参考 nose 文档;除了其他信息之外,您会发现在调用 setup 和 teardown 函数方面有许多选择。(同样,nose不太鼓励不同的项目以相同方式编写测试,因此不利于阅读其他项目的代码)。但是,它们提供非常强大的分组方式,不但可以按照结构,还可以按语义将函数分组到包和模块中(把在相同环境中运行的测试放在一起)。

在一种情况下,nose 不考虑 setup 和 teardown 函数的名称:使用 @with_setup 修饰符为某一函数显式地指定它们。同样,详细信息请参考 nose 文档。在这里,我只想提醒您注意一点:因为在 Python 中函数是一级对象,所以可以给某个修饰符分配名称并反复使用它:

# Naming a with_setup decorator

firefox_test = with_setup(firefox_setup, firefox_teardown)

@firefox_test
def test_index_click():
    ...

@firefox_test
def test_index_menu():
...

最后,在 @with_setup 修饰符中指定的或作为 unittest.TestCase 子类中的方法提供的 setup 和 teardown 函数对于相关的每个函数或测试运行一次,而在模块级或包级向 nose 提供的 setup 和 teardown 代码对于整个测试集只运行一次。因此,不要认为这样的测试是完全互相隔离的:它们会共享在模块或包的 setup 例程中创建的资源拷贝。

结束语

恭喜!现在您了解了不同的测试框架在检测测试和安排运行测试方面提供的支持。本系列的最后一篇文章将讨论使用测试框架的好处:强大的测试选择选项、报告工具和调试支持帮助我们更好地利用测试结果。最后,我们将讨论如何在这三种框架中选择最适合自己的框架。

参考资料

学习 讨论

火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织