Python 测试框架: 寻找要测试的模块

最近出现了行业级的 Python 测试框架,这意味着 Python 测试可以编写得更简洁、更统一,能够产生更好的结果报告。本文讨论先进的测试框架如何提供健壮的应用程序测试自动发现,以及这如何替代过去维护的集中式测试列表。

Brandon Craig Rhodes, 独立顾问, Rhodes Mill Studios, Inc.

Brandon Craig Rhodes 是 Python Magazine 的主编和独立的 web 应用程序顾问,在 Python 语言方面具有十年以上的经验。他维护几大洲的天文学家所使用的 PyEphem 扩展模块已经超过九年了,这个模块为行业级计算的天文学例程提供面向对象的接口。Brandon 是 Python Atlanta 用户组的成员。



2009 年 7 月 20 日

Python 编程社区非常重视单元测试和功能性测试。这种风气不但有助于确保组件和应用程序最初的质量,还促使程序员不断调整和改进代码。

本文是讨论现代 Python 测试框架的 三篇系列文章 的第二篇。本系列中的 第一篇文章 介绍了 zope.testingpy.testnose,介绍它们如何影响 Python 项目编写和维护测试的方式。本文介绍如何调用这三种框架、它们如何在项目中发现测试以及如何选择并运行测试。最后一篇文章将讨论如何通过各种报告特性让测试支持更强大的技术。

Python 测试的黑暗时代

Python 项目测试曾经是非常特殊化、个人化的活动。开发人员可能先在单独的 Python 脚本中编写每组测试。然后,编写一个名为 test_all.pytests.py 的脚本,这个脚本导入并运行他的所有测试。但是,无论这个过程的自动化做得多么好,这种方式仍然是特殊化的:参与项目的每个开发人员都必须知道测试脚本放在哪里以及如何调用它们。如果某个 Python 开发人员从事十几个项目,他就必须记住十几个测试命令。

test_all.py(或项目采用的其他名称)还可能手工导入所有其他测试,这可能导致风险。如果这个集中的测试列表过时了(常常是由于开发人员添加了新的测试套件,手工运行它,但是忘了把它添加到中心脚本中),那么在 Python 包投入生产之前的最后一次测试就会遗漏许多测试。

这种无政府状态的另一个缺点是,它要求每个测试文件包含样板代码,从而能够作为单独的命令运行。如果查看 Python 文档或当今的一些 Python 项目,会看到许多这样的测试示例:

# test_old.py - The old way of doing things

import unittest

class TruthTest(unittest.TestCase):
    def testTrue(self):
        assert True == 1

    def testFalse(self):
        assert False == 0

if __name__ == '__main__':
unittest.main()

本系列的 第一篇文章 已经讨论过基于 TestCase 类的测试在现代环境中为什么常常是不必要的。但是,现在注意最后两行:它们起什么作用?答案是,它们检测什么时候从命令行单独运行这个 test_old.py 脚本,在这种情况下,它们运行一个 unittest 简便函数,这个函数在模块中搜索测试并运行它们。它们使这个测试文件可以独立于项目范围的测试脚本单独运行。

显然,在数十甚至数百个测试模块中复制相同的代码非常麻烦。另一个不太明显的缺点是这种做法不利于标准化。如果 test_main() 函数不够完善,无法检测出某个模块的测试,那么这个模块的行为可能与其他测试套件不匹配。因此,每个模块在测试类的名称、操作方式和运行方式方面稍有差异。


Python 测试的开放时代

由于主流 Python 测试框架的出现,上述的所有问题已经解决了,而且每种框架解决这些问题的方式大致相同。

首先,这三种测试框架都提供了从操作系统命令行运行测试的标准方法。这样,每个 Python 项目就不再需要在代码基中维护全局测试脚本。

zope.testing 包运行测试的机制是最特殊化的:因为 Zope 开发人员常常使用 buildout 设置他们的项目,常常通过 buildout.cfg 文件中的 zc.recipe.testrunner recipe 安装测试脚本。但是,结果在不同的项目上相当一致:在我遇到的每个 Zope 项目中,开发 buildout 都会创建一个 ./bin/test 脚本,可以通过它调用项目的测试。

py.testnose 项目的做法更意思。它们都提供一个命令行工具,所以每个项目完全不需要有自己的测试命令:

# Run "py.test" on the project
# in the current directory...

$ py.test

# Run "nose" on the project
# in the current directory...

$ nosetests

py.testnosetests 工具甚至有几个相同的命令行选项,比如 -v 选项在执行测试时输出测试的名称。可能过不了多久,只要程序员熟悉这两种工具,就能够运行大多数公共 Python 包的测试。

但是,还有最后一级标准化!当今的大多数 Python 项目在源代码中包含一个顶级 setup.py 文件,它支持下面的命令:

# Common commands supported by setup.py files

$ python setup.py build
$ python setup.py install

当今的许多 Python 项目使用 setuptools 包支持标准 Python 没有提供的 setup.py 命令,包括运行项目的所有测试的 test 命令:

# If a project's setup.py uses "setuptools"
# then it will provide a "test" command too

$ python setup.py test

这是标准化的最高层次:如果项目都以一致的方式支持 setup.py test,开发人员就可以通过统一的接口运行所有 Python 包的测试套件。nose 通过提供一个入口点支持 setup.py,这个入口点调用与 nosetests 命令相同的测试运行例程:

# A setup.py file that uses "nose" for testing

from setuptools import setup

setup(
    # ...
    # package metadata
    # ...
    setup_requires = ['nose'],
    test_suite = 'nose.collector',
    )

当然,即使项目提供了 setup.py 入口点,大多数开发人员可能仍然使用 nosetests,因为 nosetests 提供更强大的命令行选项。但是对于新的开发人员,如果只想在调试 bug 或添加新特性之前检查包是否能够在他的平台上工作,那么 test_suite 入口点是非常方便的。


自动 Python 模块发现

zope.testingpy.testnose 的一个关键特性是,它们都可以搜索项目的源代码树,寻找项目的所有测试,所以不需要集中的测试列表。但是,它们采用的测试发现规则不太一样,在选择框架时需要考虑到这一点。

测试框架执行的第一步是,选择将在哪些目录中搜索包含测试的文件。注意,这三种框架都从整个项目的基目录开始搜索;如果要测试名为 example 的包,那么它们会从包含 example 的父目录开始搜索测试。但是,这三种框架在选择搜索哪些目录方面有所差异:

  • zope.testing 工具向下递归地搜索是 Python 包的所有目录,也就是包含 __init__.py 文件的目录(对于 Python,这说明可以用 import 语句导入它们)。这意味着不检查非包目录中的数据和代码,但是另一方面,这也意味着从理论上说程序员可以用 import 语句导入您编写的每个测试。一些程序员觉得这让人不舒服,希望能够把测试放在包的一般用户看不到的地方。
  • py.test 命令向下递归地搜索项目的每个目录和子目录,无论目录是否是 Python 包。注意,当两个相邻目录包含同名的测试时,它似乎有一个 bug。例如,如果相邻的 dir1/test.pydir2/test.py 文件都包含名为 test_example 的测试,那么 py.test 将运行第一个测试两次,而完全忽略第二个测试!如果为 py.test 编写测试并把它们放在非包目录中,就要注意保持名称是惟一的。
  • nose 测试运行器采用的实现方式介于另两种工具之间:它向下递归地搜索每个 Python 包,但是只检查目录名中包含单词 test 的目录。这意味着,如果不想让 nose 搜索某个目录,那么只需注意不在目录名中包含 test 即可。与 py.test 不同,nose 可以正确地处理包含同名测试的相邻目录(但是保持测试名称惟一仍然是有帮助的,这样在用 -v 选项显示测试结果时不容易混淆)。

选择了要搜索的目录之后,这三种测试工具的做法就非常相似了:它们都寻找与某一模式匹配的 Python 模块(也就是以 .py 结尾的文件)。zope.testing 工具在默认情况下使用正则表达式 "tests",也就是只寻找名为 tests.py 的文件,忽略其他所有文件。可以使用命令行选项或 buildout.cfg 指定另一个正则表达式:

# Snippet of a buildout.cfg file that searches for tests
# in any Python module starting with "test" or "ftest".

[test]
recipe = zc.recipe.testrunner
eggs = my_package
defaults = ['--tests-pattern', 'f?test']

py.test 更死板,总是寻找名称以 `test_ 开头或以 _test 结尾的 Python 模块。nosetests 命令更灵活,它使用一个正则表达式(“((?:^|[\b_\.-])[Tt]est)”)选择以 testTest 开头或这个单词处于单词边界后面的模块。通过在命令行上使用 -m 选项或在项目的 .noserc 文件中设置这个选项,可以指定另一个正则表达式。

哪种方法最好?尽管一些开发人员喜欢有灵活性,而且许多人认为 zope.testing 工具的搜索范围应该更宽,不应该只限于文件名为 tests.py 的模块,但是我实际上更喜欢 py.test 采用的方式。所有使用 py.test 的项目必须在测试命名方面采用一致的约定,这让其他程序员更容易阅读和维护测试。在使用另外两种框架时,阅读或创建测试文件需要两步:首先,必须了解这个项目使用的正则表达式,然后才能检查它的代码。如果您同时从事多个项目,就必须记住几种不同的测试文件命名约定。


在测试套件中包含 docfile 和 doctest

在比较长的文档中,由三个大于号组成的 Python 提示符 >>> 是一个非常明显的标志,它表示在 Python 提示上应该发生什么。正如在本系列的第一篇文章中看到的,这可以出现在作为文档的单独的文本文件中:

Doctest for truth and falsehood
-------------------------------

The truth values in Python, named "True" and "False",
are equivalent to the Boolean numbers one and zero.

>>> True == 1
True
>>> False == 0
True

这种说明还可以出现在源代码中模块、类或函数的 docstring 中:

def count_vowels(s):
    """Count the number of vowels in a string.

    >>> count_vowels('aardvark')
    3
    >>> count_vowels('THX')
    0

    """
    return len( c for c in s if c in 'aeiou')

当这些测试出现在文本文件中时(比如第一个示例),文件就被称为 docfile。当它们出现在 Python 源代码中的 docstring 中时(比如第二个示例),它们就被称为 doctest。

因为 docfile 和 doctest 是编写作为测试使用的文档的常用方法(而且这也能够表明文档什么时候过时了),所以 py.testnose 直接支持它们。(zope.testing 的用户必须使用标准 doctest 模块中的 DocTestSuite 类为每个文件手工创建 Python 测试用例)。

与用于寻找测试模块的规则一样,py.test 框架采用固定的过程支持 doctest,无法进行配置,这在项目之间实现标准化,但是限制了项目的灵活性。

  • 如果启用了它的 -pdoctest 插件,它就会在所有 Python 模块(包括模块名中不包含 test 的模块)的文档字符串中以及以 test_ 开头和以 .txt 扩展名结尾的所有文本文件中寻找 doctest。
  • 如果启用了它的 -prestdoc 插件,那么不但执行 .txt 文件中的所有 doctest,py.test 还要求项目中的每个 .txt 文件都是有效的 Restructured Text 文件,如果它们造成解析错误,py.test 会发出警告。还可以通过命令行选项让这个插件检查文档中指定的 URL,然后生成每个 .txt 文本文件的 HTML 版本。

nose 支持非常相似的特性集,但是更灵活。

  • --doctest-tests 是干扰最小的选项,它只要求 nose 在已经检查的测试模块的 docstring 中寻找 doctest。
  • --with-doctest 选项的影响比较大,它要求 nose 搜索所有一般模块(不是测试但包含一般代码的模块),寻找并运行它们的 docstring 中的 doctest。
  • 最后,--doctest-extension 允许指定文件扩展名(我认识的大多数开发人员选择 .txt.rst.doctest)。这要求 nose 读取项目中具有指定的扩展名的所有文本文件,运行并检查它找到的所有 doctest。

尽管 py.testnose 在这方面的特性集非常相似,但是我更喜欢 nose 的方式。我喜欢对所有 Restructured Text 文件使用非标准的 .rst 扩展名,这样就可以让文本编辑器能够识别它们并对它们应用特殊的语法突出显示。


nose 框架和可执行模块

对于 nose 框架应该注意一点:它在默认情况下回避那些标为可执行的 Python 模块。(在 Linux® 上,可以使用 chmod +x 这样的命令把文件标为可执行命令)。nose 框架忽略这样的文件,因为这些模块被设计为直接从命令行运行,它们可能执行某些对于 import 不安全的操作。

但是,可以用一个 if 语句检查模块是直接运行还是导入,从而保护它们执行的实际操作,这样就可以安全地导入命令:

#!/usr/bin/env python
# Sample Python command

if __name__ == '__main__':
print "This has been run from the command line!"

如果对每个命令都采用这种保护措施,因此知道它们是可以安全地导入的,那么就可以通过 --exe 命令行选项让 nose 检查可执行模块。

在这个方面,我更喜欢 py.test 的做法:它不考虑 Python 模块是否是可执行的,这使它的规则比 nose 的规则简单,并强制实施最佳实践(比如用 if 语句保护命令逻辑)。但是,如果要对遗留应用程序使用测试框架,程序可能包含许多代码质量不确定的可执行模块,那么 nose 似乎是更安全的工具。


结束语

本文详细讨论了这三种 Python 测试框架如何检查代码基并选择它们认为包含测试的模块。通过提供基于统一约定的自动发现,这三种测试框架都有助于编写更一致的测试套件,框架可以自动地检测和检查它们。但是,测试框架接下来会做什么?它们会在这些模块内寻找 什么?这是 本系列 的第三篇文章的主题!

参考资料

学习

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=AIX and UNIX, Linux
ArticleID=414922
ArticleTitle=Python 测试框架: 寻找要测试的模块
publish-date=07202009