Python高手之路

978-7-115-38713-4
作者: 【法】Julien Danjou(朱利安•丹乔)
译者: 王飞龙
编辑: 杨海玲
分类: Python

图书目录:

详情

本书首先从如何开始一个新的项目讲起,首先是整个项目的结构设计,对模块和库的管理,如何编写文档,进而讲到如何分发,以及如何通过虚拟环境对项目进行测试。此外,本书还涉及了很多高级主题,如性能优化、插件化结构的设计与架构、Python 3的支持策略等。本书适合各个层次的Python程序员阅读和参考。

图书摘要

版权信息

书名:Python高手之路

ISBN:978-7-115-38713-4

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


这不是一本常规意义上 Python 的入门书。这本书中没有 Python 关键字和 for 循环的使用,也没有细致入微的标准库介绍,而是完全从实战的角度出发,对构建一个完整的 Python 应用所需掌握的知识进行了系统而完整的介绍。更为难得的是,本书的作者是开源项目 OpenStack 的 PTL(项目技术负责人)之一,因此本书结合了 Python 在 OpenStack 中的应用进行讲解,非常具有实战指导意义。

本书从如何开始一个新的项目讲起,首先是整个项目的结构设计,对模块和库的管理,如何编写文档,进而讲到如何分发,以及如何通过虚拟环境对项目进行测试。此外,本书还涉及了很多高级主题,如性能优化、插件化结构的设计与架构、Python 3 的支持策略等。本书适合各个层次的 Python 程序员阅读和参考。


Julien Danjou 具有12年从业经验的自由软件黑客。拥有多个开源社区的不同身份:Debian开发者、Freedesktop贡献者、GNU Emacs提交者、awesome窗口管理器的创建者以及OpenStack Ceilometer项目的技术主管。最近这些年,他经常使用Python,尤其是在参与了OpenStack(云计算平台)的开发之后。在此期间,他有机会与许多著名的黑客一起工作。

王飞龙 OpenStack Glance项目和Zaqar项目的核心开发人员。曾任职于IBM,从事系统管理软件和云计算的开发工作,现居住在新西兰,专注于OpenStack开发。

“我还发现,在写任何代码之前写文档给了我一种深入思考功能特性和流程的方式,而且无需提交任何实现细节。” ——Doug Hellmann,《Python标准库》作者

“在购买本书后我马上就开始阅读,我想说的是,它就是我接触任何新语言时想读的那种书。书中包含标准的编程书通常都不会涉及的细节,以及很难找到的经验及习惯用法。Julien,你太棒了!迫不及待一口气读完它!” ——Stephen Walker

“祝贺Julien Danjou出书了!我已经开始读这本书了,发现它对我很有用。这是目前为止我看到的最好的Python书籍之一。” ——Miguel Expósito,任职于坎塔布里亚统计局(西班牙),负责统计计算工作

“我刚开始读这本书关于单元测试的部分。我每天都在写测试,我以为自己已经掌握了一切。(事实证明我没有。testscenarios? Yes!)谢谢Julien Danjou所做的贡献。” ——Mike Waters,Dealertrack软件工程师

Python是一门优美的语言,它快速、灵活且内置了丰富的标准库,已经用于越来越多的不同领域。通常大多数关于Python的书都会教读者这门语言的基础知识,但是掌握了这些基础知识后,读者在设计自己的应用程序和探索最佳实践时仍需要完全靠自己。本书则不同,介绍了如何利用Python有效地解决问题,以及如何构建良好的Python应用程序。

从本书中读者将学到什么

最佳实践:书中给出了构建应用程序时可参考的方法和建议,帮助读者充分利用Python的特性,构建不会过时的应用程序。如果读者正在做一些东西,可以立刻应用本书中提及的技术去改进自己当前的工作。

解决问题:书中介绍了测试、移植、扩展Python应用程序和库等方面的实际问题并提供了相应的解决方案,还介绍了一些非常好的小技巧,讨论了一些长期维护软件的策略。

语言的内部机制:书中阐述了Python语言的一些内部机制,帮助读者更好地理解如何开发更高效的代码,并获得对这门语言内部工作原理更深刻的洞察力。

专家访谈录:书中包含多篇对不同领域专家的访谈,让读者可以从开源社区和Python社区的知名黑客那里获得意见、建议和技巧。

本书英文原版配套网址是https://julien.danjou.info/books/the-hacker-guide-to-python


亲爱的中国读者你们好!

祝贺你,你正在读The Hackers Guide to Python一书的中文版。我非常高兴看到这本书最终翻译完成,这样你就可以用自己的语言去阅读。这是这本书第三种语言的版本(之前已经有了英语和韩语两个版本)。能够有更多的读者看到这本书真是太棒了!

你将阅读的这本书的大部分内容来自我在OpenStack这个大规模项目中开发Python代码时的经验。你们是非常幸运的,因为这本书是由王飞龙翻译的,他是一名软件工程师,他和我同在OpenStack社区做开发工作。因此,高质量的翻译和对本书内容的精确表述是可期待的,因为飞龙对本书涉及的内容有着很好的理解。

真心希望你们能喜欢这本书。祝你阅读愉快!

Julien Danjou


如果你读到这里,你肯定已经使用Python有一阵子了。你可能是通过一些文档学习的,钻研了一些已有的项目或者从头开发,但不管是哪种情况,你都已经在以自己的方式学习它了。直到两年前我加入OpenStack项目组之前,这其实也正是我个人熟悉Python的方法。

在此之前,我只是开发过一些“车库项目[1]”级别的Python库或应用程序,而一旦你参与开发涉及数百名开发人员并有着上万个用户的软件或库时,情况就会有所不同。OpenStack平台有超过150万行Python代码,所有代码都需要精确、高效,并根据用户对云计算应用程序的需求进行任意扩展。在有了这一规模的项目之后,类似测试和文档这类问题就一定需要自动化,否则根本无法完成。

我刚开始加入OpenStack的时候,我认为自己已经掌握了不少Python知识,但这两年,在我起步时无法想象其规模的这样一个项目上,我学到了更多。而且我还有幸结识了很多业界最棒的Python黑客,并从他们身上获益良多—大到通用架构和设计准则,小到各种有用的经验和技巧。通过本书,我想分享一些我所学到的最重要的东西,以便你能构建更好的Python应用,并且是更加高效地构建。

[1]作者这里的意思是规模很小,比较业余的项目。——译者注


你很可能会问的第一个问题就是:“我的软件应该支持Python的哪些版本?”这是一个好问题,因为每个Python新版本都会在引入新功能的同时弃用一些老的功能。而且,Python 2.x和Python 3.x之间有着巨大的不同,这两个分支之间的剧烈变化导致很难使代码同时兼容它们。本书后面章节会进一步讨论,而且当刚刚开始一个新项目时很难说哪个版本更合适。

总之,在确实有需要的情况下支持2.6版本(或者想自我挑战),必须支持2.7版本,如果需要保证软件在可预见的未来也能运行,就需要也支持3.3及更高的版本。忽略那些更老的Python版本基本没什么问题,尽管同时支持所有这些版本是有可能的:CherryPy项目(http://cherrypy.org)支持Python 2.3及所有后续版本(http://docs.cherrypy.org/stable/intro/install.html)。

编写同时支持Python 2.7和3.3版本的程序的技术将在第13章介绍。某些技术在后续的示例代码中也会涉及,所有本书中的示例代码都同时支持这两个主要版本。

项目结构应该保持简单,审慎地使用包和层次结构,过深的层次结构在目录导航时将如同梦魇,但过平的层次结构则会让项目变得臃肿。

一个常犯的错误是将单元测试放在包目录的外面。这些测试实际上应该被包含在软件的子一级包中,以便:

图1-1展示了一个项目的标准的文件层次结构。

图1-1 标准的包目录结构

setup.py是Python安装脚本的标准名称。在安装时,它会通过Python分发工具(distuils)进行包的安装。也可以通过README.rst(或者README.txt,或者其他合适的名字)为用户提供重要信息。requirements.txt应该包含Python包所需要的依赖包,也就是说,所有这些包都会预先通过pip这样的工具进行安装以保证你的包能正常工作。还可以包含test-requirements.txt,它应该列出运行测试集所需要的依赖包。最后,docs文件夹应该包括reStructuredText格式的文档,以便能够被Sphinx处理(参见3.1节)。

包中还经常需要包含一些额外的数据,如图片、shell脚本等。不过,关于这类文件如何存放并没有一个统一的标准。因此放到任何觉得合适的地方都可以。

下面这些顶层目录也比较常见。

一个常见的设计问题是根据将要存储的代码的类型来创建文件或模块。使用functions.py或者exceptions.py这样的文件是很糟糕的方式。这种方式对代码的组织毫无帮助,只能让读代码的人在多个文件之间毫无理由地来回切换。

此外,应该避免创建那种只有一个__init__.py文件的目录,例如,如果hooks.py够用的话就不要创建hooks/__init__.py。如果创建目录,那么其中就应该包含属于这一分类/模块的多个Python文件。

可能你已经有所了解,Python生态系统中正在对包的元数据进行标准化。其中的一项元数据就是版本号。

PEP 440(http://www.python.org/dev/peps/pep-0440/)针对所有的Python包引入了一种版本格式,并且在理论上所有的应用程序都应该使用这种格式。这样,其他的应用程序或包就能简单而可靠地识别它们需要哪一个版本的包。

PEP440中定义版本号应该遵从以下正则表达式的格式:

N[.N]+[{a|b|c|rc}N][.postN][.devN]

它允许类似1.2或1.2.3这样的格式,但需注意以下几点。

最终即将发布的组件也可以使用下面这种格式。

通常用到的还有以下这些后缀。

这一结构可以满足大部分常见的使用场景。

注意

  你可能已经听说过语义版本(http://semver.org/),它对于版本号提出了自己的规则。这一规范和PEP 440部分重合,但二者并不完全兼容。例如,语义版本对于预发布版本使用的格式1.0.0.-alpha+001就与PEP 440不兼容。

如果需要处理更高级的版本号,可以考虑一下PEP 426(http://www.python.org/dev/peps/pep-0426)中定义的源码标签,这一字段可以用来处理任何版本字符串,并生成同PEP要求一致的版本号。

许多分布式版本控制系统(Distributed Version Control System,DVCS)平台,如Git和Mercurial,都可以使用唯一标识的散列字符串[1]作为版本号。但遗憾的是,它不能与PEP 440中定义的模式兼容:问题就在于,唯一标识的散列字符串不能排序。不过,是有可能通过源码标签这个字段维护一个版本号,并利用它构造一个同PEP 440兼容的版本号的。

提示

  pbr(即Python Build Reasonableness,https://pypi.python.org/pypi/pbr)将在4.2节中讨论,它可以基于项目的Git版本自动生成版本号。

没错,编码风格是一个不太讨巧的话题,不过这里仍然要聊一下。

Python具有其他语言少有的绝佳质量[2]:使用缩进来定义代码块。乍一看,似乎它解决了一个由来已久的“往哪里放大括号?”的问题,然而,它又带来了“如何缩进?”这个新问题。

而Python社区则利用他们的无穷智慧,提出了编写Python代码的PEP 8[3]http://www.python.org/dev/peps/pep-0008/)标准。这些规范可以归纳成下面的内容。

这些规范其实很容易遵守,而且实际上很合理。大部分程序员在按照这些规范写代码时并没有什么不便。

然而,犯错在所难免,保持代码符合PEP 8规范的要求仍是一件麻烦事。工具pep8(https://pypi.python.org/pypi/pep8)就是用来解决这个问题的,它能自动检查Python文件是否符合PEP 8要求,如示例1.1所示。

示例1.1 运行pep8

$ pep8 hello.py  
hello.py:4:1: E302 expected 2 blank lines, found 1  
$ echo $?  
1

pep8会显示在哪行哪里违反了PEP 8,并为每个问题给出其错误码。如果违反了那些必须遵守的规范,则会报出错误(以E开头的错误码),如果是细小的问题则会报警告(以W开头的错误码)。跟在字母后面的三位数字则指出具体的错误或警告,可以从错误码的百位数看出问题的大概类别。例如,以E2开头的错误通常与空格有关,以E3开头的错误则与空行有关,而以W6开头的警告则表明使用了已废弃的功能。

社区仍然在争论对并非标准库一部分的代码进行PEP 8验证是否是一种好的实践。这里建议还是考虑一下,最好能定期用PEP 8验证工具对代码进行检测。一种简单的方式就是将其集成到测试集中。尽管这似乎有点儿极端,但这能保证代码一直遵守PEP 8规范。6.7节中将介绍如何将pep8与tox集成,从而让这些检查自动化。

OpenStack项目从一开始就通过自动检查强制遵守PEP 8规范。尽管有时候这让新手比较抓狂,但这让整个代码库的每一部分都保持一致,要知道现在它有167万行代码。对于任何规模的项目这都是非常重要的,因为即使对于空白的顺序,不同的程序员也会有不同的意见。

也可以使用--ignore选项忽略某些特定的错误或警告,如示例1.2所示。

示例1.2 运行pep8时指定--ignore选项

$ pep8 --ignore=E3 hello.py  
$ echo $?  
0

这可以有效地忽略那些不想遵循的PEP 8标准。如果使用pep8对已有的代码库进行检查,这也可以暂时忽略某些问题,从而每次只专注解决一类问题。

注意

  如果正在写一些针对Python的C语言代码(如模块),则PEP 7(http://www.python.org/dev/peps/pep-0007/)标准描述了应该遵循的相应的编码风格。

还有一些其他的工具能够检查真正的编码错误而非风格问题。下面是一些比较知名的工具。

这些工具都是利用静态分析技术,也就是说,解析代码并分析代码而无需运行。

如果选择使用pyflakes,要注意它按自己的规则检查而非按PEP 8,所以仍然需要运行pep8。为了简化操作,一个名为flake8(https://pypi.python.org/pypi/flake8)的项目将pyflakes和pep8合并成了一个命令,而且加入了一些新的功能,如忽略带有#noqa的行以及通过入口点(entry point)进行扩展。

为了追求优美而统一的代码,OpenStack选择使用flake8进行代码检查。不过随着时间的推移,社区的开发者们已经开始利用flake8的可扩展性对提交的代码进行更多潜在问题的检查。最终flake8的这个扩展被命名为hacking(https://pypi.python.org/pypi/hacking)。它可以检查except语句的错误使用、Python 2与Python 3的兼容性问题、导入风格、危险的字符串格式化及可能的本地化问题。

如果你正开始一个新项目,这里强烈建议使用上述工具之一对代码的质量和风格进行自动检查。如果已经有了代码库,那么一种比较好的方式是先关闭大部分警告,然后每次只解决一类问题。

尽管没有一种工具能够完美地满足每个项目或者每个人的喜好,但flake8和hacking的结合使用是持续改进代码质量的良好方式。要是没想好其他的,那么这是一个向此目标前进的好的开始。

提示

  许多文本编辑器,包括流行的GNU Emacs(http://www.gnu.org/software/emacs/)和vim(http://www.vim.org/),都有能够直接对代码运行pep8和flake8这类工具的插件(如Flymake),能够交互地突出显示代码中任何不兼容PEP 8规范的部分。这种方式能够非常方便地在代码编写过程中修正大部分风格错误。

[1]对于Git,指的是git-describe(1)。

[2]你可能有不同意见。

[3] PEP 8 Style Guide for Python Code, 5th July 2001, Guido van Rossum, Barry Warsaw, Nick Coghlan


要使用模块和库,需要先进行导入。

Python之禅

>>> import this  
The Zen of Python, by Tim Peters  

Beautiful is better than ugly.  
Explicit is better than implicit.  
Simple is better than complex.  
Complex is better than complicated.  
Flat is better than nested.  
Sparse is better than dense.  
Readability counts.  
Special cases aren't special enough to break the rules.  
Although practicality beats purity.  
Errors should never pass silently.  
Unless explicitly silenced.  
In the face of ambiguity, refuse the temptation to guess.  
There should be one-- and preferably only one --obvious way to do it.  
Although that way may not be obvious at first unless you're Dutch.  
Now is better than never.  
Although never is often better than *right* now.  
If the implementation is hard to explain, it's a bad idea.  
If the implementation is easy to explain, it may be a good idea.  
Namespaces are one honking great idea -- let's do more of those!

导入系统是相当复杂的,不过你可能已经了解了一些基本知识。这里会介绍一些关于这一子系统的内部机理。

sys模块包含许多关于Python导入系统的信息。首先,当前可导入的模块列表都是通过sys.moudle变量才可以使用的。它是一个字典,其中键(key)是模块名字,对应的值(value)是模块对象。

>>> sys.modules['os']  
<module 'os' from '/usr/lib/python2.7/os.pyc'>

许多模块是内置的,这些内置的模块在sys.builtin_module_names中列出。内置模块可以根据传入Python构建系统的编译选项的不同而变化。

导入模块时,Python会依赖一个路径列表。这个列表存储在sys.path变量中,并且告诉Python去哪里搜索要加载的模块。可以在代码中修改这个列表,根据需要添加或删除路径,也可以通过编写Python代码直接修改环境变量PYTHONPATH。下面的方法几乎是相等的[1]

>>> import sys  
>>> sys.path.append('/foo/bar')

$ PYTHONPATH=/foo/bar python  
>>> import sys  
>>> '/foo/bar' in sys.path  
True

sys.path中顺序很重要,因为需要遍历这个列表来寻找请求的模块。

也可以通过自定义的导入器(importer)对导入机制进行扩展。Hy[2]正是利用的这种技术告诉Python如何导入其他非标准的.py或者.pyc文件的。

顾名思义,导入钩子机制是由PEP 302(http://www.python.org/dev/peps/pep-0302/)定义的[3]。它允许扩展标准的导入机制,并对其进行预处理,也可以通过追加一个工厂类到sys.path_hooks来添加自定义的模块查找器(finder)。

模块查找器对象必须有一个返回加载器对象的find_module(fullname, path=None)方法,这个加载器对象必须包含一个负责从源文件中加载模块的load_module(fullname)方法。

为了进一步说明,下面给出了Hy利用自定义的导入器导入.hy而不是.py结尾的源文件的方法,见示例2.1。

示例2.1 Hy模块导入器

class MetaImporter(object):  
    def find_on_path(self, fullname):  
        fls = ["%s/__init__.hy", "%s.hy"]  
        dirpath = "/".join(fullname.split("."))  

        for pth in sys.path:  
            pth = os.path.abspath(pth)  
            for fp in fls:  
                composed_path = fp % ("%s/%s" % (pth, dirpath))  
                if os.path.exists(composed_path):  
                    return composed_path  

    def find_module(self, fullname, path=None):  
        path = self.find_on_path(fullname)  
        if path:  
            return MetaLoader(path)  

sys.meta_path.append(MetaImporter())

一旦路径被确定是有效的且指向了一个模块,就会返回一个MetaLoader对象。

Hy模块加载器

class MetaLoader(object):  
    def __init__(self, path):  
        self.path = path  

    def is_package(self, fullname):  
        dirpath = "/".join(fullname.split("."))  
        for pth in sys.path:  
            pth = os.path.abspath(pth)  
            composed_path = "%s/%s/__init__.hy" % (pth, dirpath)  
            if os.path.exists(composed_path):  
                return True  
        return False  

    def load_module(self, fullname):  
        if fullname in sys.modules:  
            return sys.modules[fullname]  

        if not self.path:  
            return  

        sys.modules[fullname] = None  
        mod = import_file_to_module(fullname, self.path) 

        ispkg = self.is_package(fullname)  

        mod.__file__ = self.path  
        mod.__loader__ = self  
        mod.__name__ = fullname  

        if ispkg:  
            mod.__path__ = []  
            mod.__package__ = fullname  
        else:  
            mod.__package__ = fullname.rpartition('.')[0]  

        sys.modules[fullname] = mod  
        return mod

i mport_file_to_module读取一个Hy源文件,将其编译成Python代码,并返回一个Python模块对象。

uprefix模块(https://pypi.python.org/pypi/uprefix)是这个功能起作用的另一个好的例子。Python 3.0到3.2并没有像Python 2中用来表示Unicode字符串的u前缀[4],这个模块通过在编译前删除字符串的前缀u 来确保在2.x和3.x之间的兼容性。

Python本身内置的巨大标准库提供了丰富的工具和功能,可以满足你能想到的任何需求。很多Python的初学者习惯于自己写代码实现一些基本的功能,然后会惊奇地发现很多功能已经内置了,直接就可以使用。

任何时候想要自己写函数处理一些简单的工作时,请停下来先看看标准库。我的建议是至少大概浏览一遍标准库,这样下次再需要一个函数时就能知道是否可以利用标准库中已有的函数了。

后续章节会讨论其中的一些模块,如functools和itertools,下面是一些必须了解的标准库模块。

这个模块清单可以作为一个快速参考,帮助你了解各个库模块的作用。如果能记住一部分就更好了。花在查找标准库上的时间越少,意味着写实际代码的时间就越多。

提示

  整个标准库都是用Python写的,所以可以直接查看它模块和函数的源代码。有疑问时只需打开代码自己一探究竟。尽管文档中已经包含了你想知道的一切,但总还是有机会让你学一些有用的东西。

你是否有过这样的经历,收到一件不错的生日礼物或圣诞礼物,但是打开后却发现送你的人忘了买电池?Python的“内置电池”哲学让你作为程序员不会遇到这类问题,只要安装了Python,就拥有了完成任何功能所需的一切条件。

然而,Python标准库的开发者并不能预测你要实现的“任何”功能到底是什么。即使可以,大多数人也不想去处理一个几个GB的文件下载,即使可能只是需要写一个重命名文件的快速脚本。关键在于,即使拥有所有的扩展功能,仍然有许多功能是Python标准库没有涵盖的。不过,这并不是说有些事情是根本无法用Python实现的,这只是表明有些事情可能需要使用外部库。

Python标准库是安全且范围明确的:模块文档化程度很高,并且有足够多的人在经常使用它,从而可以保证在你想使用它时肯定不会遇到麻烦。而且,就算万一出了问题,也能确保在短时间内有人解决。但是,外部库就像是地图上标着“熊出没,请注意”的部分:可能缺少文档,功能有bug,更新较少或根本不更新。任何正式的项目都可能用到一些只有外部库提供的功能,但是需要谨记使用这些外部库可能带来的风险。

下面是来自一线的案例。OpenStack使用了SQLAlchemy(http://www.sqlalchemy.org/),一个Python数据库开发工具包。如果了解SQL的话会知道,数据库的结构是会发生变化的,所以OpenStack还使用了sqlalchemy-migrate(https://code.google.com/p/sqlalchemy-migrate/)来处理数据库模式的升级。一切运行良好,直到有一天它们不行了,开始出现大量bug,并且没有好转的迹象。而且,OpenStack在当时是想要支持Python 3的,然而没有任何迹象表明sqlalchemy-migrate要支持Python 3。因此,显然sqlalchemy-migrate已经死了,我们需要切换到其他替代方案。截止到作者写作时,OpenStack正准备升级到Alembic(https://pypi.python.org/pypi/alembic),虽然也有一些工作要做,但好在不是那么痛苦。

所有这些引出一个重要的问题:“如何保证我不会掉进同样的陷阱里?”很遗憾,没办法保证。程序员也是人,没什么办法可以确保目前维护良好的库在几个月后仍然维护良好。但是,在OpenStack中我们使用下列检查表来根据需要给出建议(我建议你也这么做)。

尽管可能工作量巨大,但这一检查表对于依赖同样适用。如果知道应用程序会大量依赖一个特定的库,那么至少应该对这个库的每一个依赖使用这个检查表。

不管最终使用哪个库,都要像其他工具一样对待,因为即使是有用的工具也可能会造成严重的损害。尽管不常发生,但问问你自己:如果你有一把锤子,你会拿着它满屋跑因而可能意外地损坏屋子里的东西,还是会把它放在工具架上或者车库里,远离那些贵重而易碎的东西,仅在需要的时候才拿出来?

对于外部库道理是一样的,不管它们多么有用,都需要注意避免让这些库和实际的源代码耦合过于紧密。否则,如果出了问题,你就需要切换库,这很可能需要重写大量的代码。更好的办法是写自己的API,用一个包装器对外部库进行封装,将其与自己的源代码隔离。自己的程序无需知道用了什么外部库,只要知道API提供了哪些功能即可。想要换一个不同的库?只需要修改包装器就可以了。只要它仍然提供同样的功能,那么完全不需要修改任何核心代码。也许会有例外,但应该不会太多。大部分库都被设计成只专注解决一定范围的问题,因此很容易隔离。

4.7.3节将会涉及如何使用入口点构建驱动系统(driver system),这个系统让你可以将项目的某些部分设计成可以根据需要切换的模块。

有许多不同的Python框架可用于开发不同的Python应用。如果是Web应用,可以使用Django(https://www.djangoproject.com/)、Pylons(http://www.pylonsproject.org/)、TurboGears(http://turbogears.org/)、Tornado(http://www.tornadoweb.org/)、Zope(http://www.zope.org/)或者Plone(http://plone.org/)。如果你正在找事件驱动的框架,可以使用Twisted(http://twistedmatrix.com/)或者Circuits(https://bitbucket.org/prologic/circuits/)等。

框架和外部库的主要不同在于,应用程序是建立在框架之上的,代码对框架进行扩展而不是反过来。而外部库更像是对代码的扩展,赋予你的代码更多额外的能力,而框架会为你的代码搭好架子,只需要通过某种方式完善这个架子就行了,尽管这可能是把双刃剑。使用框架有很多好处,如快速构建原型并开发,但也有一些明显的缺点,如锁定(lock-in)问题。因此,在决定使用某个框架前需要把这些都考虑在内。

这里推荐的为Python应用选择框架的方法很大程度上类似于前面介绍过的外部库的选择方法,适用于框架是通过一组Python库来进行分发的情况。有时它们还包含用于创建、运行以及部署应用的工具,但这并不影响你采用的标准。前面已经提到过,在已经写了大量代码之后更换外部库是十分痛苦的,但更换框架比这还要难受一千倍,因为通常需要完全重写你的应用程序。举例说明,前面提及的Twisted框架还不能完全支持Python 3。如果你基于Twisted的程序在几年之后想要支持Python 3,那么你将非常不幸,除非全部重写代码选用另一个框架或者有人最终为Twisted提供了Python 3的升级支持。

有些框架与其他框架相比更加轻量级。一个简单的比较就是,Django提供了内置的ORM功能,而Flask则没有。一个框架提供的功能越少,将来遇到问题的越少。然而,框架缺少的每个功能同时也是另一个需要去解决的问题,要么自己写,要么再千挑万选去找另一个能提供这个功能的库。愿意处理哪种场景取决于个人的选择,但需慎重选择。当问题出现时从一个框架升级至其他框架是极其艰巨的任务,就算Python再强大,对于这类问题也没有什么好办法。

我曾经有幸和Doug Hellmann一起工作过数月。他在DreamHost是一位非常资深的软件开发工程师,同时他也是OpenStack项目的贡献者。他发起过关于Python的网站Python Module of the Week(http://pymotw.com/),也出版过一本很有名的Pyhton书The Python Standard Library By Examplehttp://doughellmann.com/python-standard-library-by-example),同时他也是Python的核心开发人员。我曾经咨询过Doug关于标准库以及库的设计与应用等方面的问题。

当你从头开发一个Python应用时,如何迈出第一步呢?它和开发一个已有的应用程序有什么不同?

从抽象角度看步骤都差不多,但是细节上有所不同。相对于对比开发新项目和已有项目,我个人在对应用程序和库开发的处理方式上有更多的不同。

当我要修改已有代码时,特别是这些代码是其他人创建的时,起初我需要研究代码是如何工作的,我需要改进哪些代码。我可能会添加日志或是输出语句,或是用pdb,利用测试数据运行应用程序,以便我理解它是如何工作的。我经常会做一些修改并测试它们,并在每次提交代码前添加可能的自动化测试。

创建一个新应用时,我会采取相同的逐步探索方法。我先创建一些代码,然后手动运行它们,在这个功能可以基本调通后,再编写测试用例确保我已经覆盖了所有的边界情况。创建测试用例也可以让代码重构更容易。

这正是smiley(https://pypi.python.org/pypi/smiley)的情况。在开发正式应用程序前,我先尝试用Python的trace API写一些临时脚本。对于smiley我最初的设想包括一个仪表盘并从另一个运行的应用程序收集数据,另一部分用来接收通过网络发送过来的数据并将其保存。在添加几个不同的报告功能的过程中,我意识到重放已收集的数据的过程和在一开始收集数据的过程基本是一样的。于是我重构了一些类,并针对数据收集,数据库访问和报告生成器创建了基类。通过让这些类遵循同样的API使我可以很容易地创建数据收集应用的一个版本,它可以直接将数据写入数据库而无需通过网络发送数据。

当设计一个应用程序时,我会考虑用户界面是如何工作的,但对于库,我会专注于开发人员如何使用其API。通过先写测试代码而不是库代码,可以让思考如何通过这个新库开发应用程序变得更容易一点儿。我通常会以测试的方式创建一系列示例程序,然后依照其工作方式去构建这个库。

我还发现,在写任何库的代码之前先写文档让我可以全面考虑功能和流程的使用,而不需要提交任何实现的细节。它还让我可以记录对于设计我所做出的选择,以便读者不仅可以理解如何使用这个库,还可以了解在创建它时我的期望是什么。这就是我用在stevedore上的方法。

我知道我想让stevedore能够提供一组类用来管理应用程序的插件。在设计阶段,我花了些时间思考我见过的使用插件的通用模式,并且写了几页粗略的文档描述这些类应该如何使用。我意识到,如果我在类的构造函数中放最复杂的参数,方法map()几乎是可互换的。这些设计笔记直接写进了stevedore官方文档的简介里,用来解释在应用程序中使用插件的不同模式和准则。

将一个模块加入Python标准库的流程是什么?

完整的流程和规范可以在Python Developer's Guide(http://docs.python.org/devguide/stdlibchanges.html)中找到。

一个模块在被加入Python标准库之前,需要被证明是稳定且广泛使用的。模块需要提供的功能要么是很难正确实现的,要么是非常有用以至于许多开发人员已经创建了他们自己不同的变种。API应该非常清晰并且它的实现不能依赖任何标准库之外的库。

提议一个新模块的第一步是在社区通过python-ideas邮件列表非正式地了解一下大家对此的感兴趣程度。如果回应很积极,下一步就是创建一个Python增强提案(PythonEnhancement Proposal,PEP),它包括添加这个模块的动因,以及如何过渡的一些实现细节。

因为包的管理和发现工作已经非常稳定了,尤其是pip和Python Package Index(PyPI),因此在标准库之外维护一个新的库可能更实用。单独的发布使得对于新功能和bug修复(bugfix)的更新可以更频繁,对于处理新技术或API的库来说这尤其重要。

标准库中的哪三个模块是你最想人们深入了解并开始使用的?

最近我做了许多关于应用程序中动态加载扩展方面的工作。我使用abc模块为那些作为抽象基类进行的扩展定义API,以帮助扩展的作者们了解API的哪些方法是必需的,哪些是可选的。抽象基类已经在其他一些语言中内置了,但我发现很多Python程序员并不知道Python也有。

bisect模块中的二分查找算法是个很好的例子,一个广泛使用但不容易正确实现的功能,因此它非常适合放到标准库中。我特别喜欢它可以搜索稀疏列表,且搜索的值可能并不在其中。

collections模块中有许多有用的数据结构并没有得到广泛使用。我喜欢用namedtuple来创建一些小的像类一样的数据结构来保存数据但并不需要任何关联逻辑。如果之后需要添加逻辑的话,可以很容易将namedtuple转换成一个普通的类,因为namedtuple支持通过名字访问属性。另一个有意思的数据结构是ChainMap,它可以生成良好的层级命名空间。ChainMap能够用来为模板解析创建上下文或者通过清晰的流程定义来管理不同来源的配置。

许多项目(包括OpenStack)或者外部库,会在标准库之上封装一层自己的抽象。例如,我特别想了解对于日期/时间的处理。对此你有什么建议吗?程序员应该坚持使用标准库,还是应该写他们自己的函数,切换到其他外部库或是开始给Python提交补丁?

所有这些都可以。我倾向于避免重复造轮子,所以我强烈主张贡献补丁和改进那些能够用来作为依赖的项目。但是,有时创建另外的抽象并单独维护代码也是合理的,不管在应用程序内还是作为一个新的库。

你提到的例子中,OpenStack里的timeutils模块就是对Python的datetime模块的一层很薄的封装。大部分功能都简短且简单,但通过将这些最常见的操作封装为一个模块,我们可以保证它们在OpenStack项目中以一致的方式进行处理。因为许多函数都是应用相关的,某种意义上它们强化了一些问题决策,例如,字符串时间戳格式或者“现在”意味着什么,它们不太适合作为Python标准库的补丁或者作为一个通用库发布以及被其他项目采用。

与之相反,我目前正致力于将OpenStack的API服务项目从早期创建时使用的WSGI框架转成采用一个第三方Web开发框架。在Python中开发WSGI应用有很多选择,并且当我们可能需要增强其中一个以便其可以完全适应OpenStack API服务器的需要时,将这些可重用的修改贡献对于维护一个“私有的”框架似乎更可取。

当从标准库或其他地方导入并使用大量模块时,关于该做什么你有什么特别的建议吗?

我没有什么硬性限制,但是如果我有过多的导入时,我会重新考虑这个模块的设计并考虑将其拆到一个包中。与上层模块或者应用程序模块相比,对底层模块的这种拆分可能会发生得更快,因为对于上层模块我期望将更多片段组织在一起。

关于Python 3,有什么模块是值得一提而且能令开发人员有兴趣深入了解的?

支持Python 3的第三方库的数量已经到了决定性的时刻。针对Python 3开发新库或应用程序从未如此简单过,而且幸亏有3.3中加入的兼容性功能使同时维护对Python 2.7的支持也很容易。主要的Linux发行版正在致力于将Python 3默认安装。任何人要用Python创建新项目都应该认真考虑对Python 3的支持,除非有尚未移植的依赖。目前来说,不能运行在Python 3上的库基本会被视为“不再维护”。

许多开发人员将所有的代码都写入到应用程序中,但有些情况下可能有必要将代码封装成一个库。关于设计、规划、迁移等,做这些最好的方式是什么?

应用程序就是“胶水代码”的集合用来将库组织在一起完成特定目的。起初设计时可以将这些功能实现为一个库,然后在构建应用程序时确保库的代码能够很好地组织到逻辑单元中,这会让测试变得更简单。这还意味着应用程序的功能可以通过库进行访问,并且能够被重新组合以构建其他应用程序。未能采用这种方法的话意味着应用程序的功能和用户界面的绑定过于紧密,导致很难修改和重用。

对于计划开始构建自己的Python库的人们有什么样的建议呢?

我通常建议自顶向下设计库和API,对每一层应用单一职责原则(Single Responsibility Principle,SRP)(http://en.wikipedia.org/wiki/Single_responsibility_principle)这样的设计准则。考虑调用者如何使用这个库,并创建一个API去支持这些功能。考虑什么值可以存在一个实例中被方法使用,以及每个方法每次都要传入哪些值。最后,考虑实现以及是否底层的代码的组织应该不同于公共API。

SQLAlchemy是应用这些原则的绝好例子。声明式ORM、数据映射和表达式生成层都是单独的。开发人员可以自行决定对于API访问的正确的抽象程度,并基于他们的需求而不是被库的设计强加的约束去使用这个库。

当你随机看Python程序员的代码时遇到的最常见的编程错误是什么?

Python的习惯用法和其他语言的一个较大的不同在于循环和迭代。例如,我见过的最常见的反模式是使用for循环过滤一个列表并将元素加入到一个新的列表中,然后再在第二个循环中处理这个结果(可能将列表作为参数传给一个函数)。我通常建议将过滤循环改成生成器表达式,因为生成器表达式,更有效也更容易理解。列表的组合也很常见,以便它们的内容可以以某种方式一起被处理,但却没有使用itertools.chain()

还有一些我在代码评审时给出的更细小的建议,例如,使用dict()而不是长的if:then:else块作为查找表,确保函数总是返回相同的类型(如一个空列表而不是None),通过使用元组和新类将相关的值合并到一个对象中从而减少函数的参数,以及在公共API中定义要使用的类而不是依赖于字典。

有没有关于选择了一个“错误”的依赖的具体的例子是你亲身经历或目睹过的?

最近,我有个例子,pyparsing(https://pypi.python.org/pypi/pyparsing)的一个新发布取消了对Python 2的支持,这给我正在维护的一个库带来了一点儿小麻烦。对pyparsing的更新是个重大的修改,而且是明确标识成这样的,但是因为我没有在对cliff(https://pypi.python.org/pypi/cliff)的设置中限制依赖版本号,所以pyparsing的新发布给cliff的用户造成了问题。解决方案就是在cliff的依赖列表中对Python 2和Python 3提供不同的版本边界。这种情况突显了理解依赖管理和确保持续集成测试中适当的测试配置的重要性。

你怎么看待框架?

框架像任何工具类型一样。它们确实有帮助,但在选择框架时要特别谨慎,应确保它能够很好地完成当前的工作。

通过抽取公共部分到一个框架中,你可以将你的开发精力专注于应用中独特的方面。通过提供许多类似运行在开发模式或者写一个测试套件这样的引导代码,它们还可以帮你让一个应用程序迅速达到一个可用的状态而不是从头开发。它们还可以激励你在应用程序开发过程中保持一致,这意味着最终你的代码将更易于理解且更可重用。

虽然使用框架时还有其他一些潜在的缺点需要注意。决定使用某个特定框架通常能够反映应用程序本身的设计。如果设计的限制不能从根本上符合应用程序的需求,那么选择错误的框架会令应用的实现变得更难。如果你试着使用与框架建议不同的模式或惯用方式,你最终将不得不同框架做斗争。

在构造API时很难一蹴而就。API需要不断演化、添加、删除或者修改所提供的功能。

在后面的段落中women将讨论如何管理公共API的变化。公共API是指将应用程序或库暴露给终端用户的API。内部API则有另外的考虑,并且由于它们在内部(也就是说用户不需要直接操作这些API),因而可以任意处理它们:分解、调整或者根据需要任意使用。

这两种API很容易区分。Python的传统是用下划线作为私有API的前缀,如foo是公共API,而_bar是私有的。

在构建API时,最糟糕的事情莫过于API被突然破坏。Linus Torvalds就因对Linux内核公共API破坏的零容忍而闻名。考虑到如此多的人依赖Linux,可以说他的选择是非常明智的。

Unix平台的库管理系统很复杂,它依赖于soname(http://en.wikipedia.org/wiki/Soname)和细粒度的版本标识符。Python中没有这样的系统,也没有对应的转换。因此完全取决于维护者如何选择正确的版本号和策略。但是,关于如何定义自己的库或应用程序的版本,你依然可以将Unix系统作为你的灵感来源。通常,版本号应该反映出API对用户的影响,大部分开发人员通过主版本号的增加来表示此类变化,但这取决于你对版本号管理的方法,你也可以采用增加小版本号的方式。

不管如何决定,最重要的一步就是在修改API时要通过文档对修改进行详细地记录,包括:

旧接口不要立刻删除。实际上,应该尽量长时间地保留旧接口。因为已经明确标识为作废,所以新用户不会去使用它。在维护实在太麻烦时再移除旧接口。API变化的记录见示例2.2.

示例2.2API变化的记录

class Car(object):  
    def turn_left(self):  
        """Turn the car left.  

        .. deprecated:: 1.1  
           Use :func:`turn` instead with the direction argument set to left  
        """  
        self.turn(direction='left')  

    def turn(self, direction):  
        """Turn the car in some direction.  

        :param direction: The direction to turn to.  
        :type direction: str  
        """  
        # Write actual code here instead  
        pass

使用Sphinx标记强调修改是个好主意。在构建文档时,用户应该能清楚地知道某个功能不应该再被使用,并且可以直接访问到新功能,并随之解释如何升级旧代码。这个方法的缺点就是,你不能指望开发人员在升级你的Python包到新版本时会去读你的修改日志或者文档。

Python提供了一个很有意思的名为warnings的模块用来解决这一问题。这一模块允许代码发出不同类型的警告信息,如PendingDeprecationWarningDeprecationWarning。这些警告能够用来通知开发人员某个正在调用的函数已经废弃或即将废弃。这样,开发人员就能够看到他们正在使用旧接口并且应该相应地进行处理[5]

回到之前的例子,我们可以利用它向用户发出警告,如示例2.3所示。

示例2.3 带警告的API变化的记录

import warnings  

class Car(object):  
    def turn_left(self):  
        """Turn the car left.  

        .. deprecated:: 1.1  
           Use :func:`turn` instead with the direction argument set to "left".  
        """  
        warnings.warn("turn_left is deprecated, use turn instead",  
                      DeprecationWarning)  
        self.turn(direction='left')  

    def turn(self, direction):  
        """Turn the car in some direction.  

        :param direction: The direction to turn to.  
        :type direction: str  
        """  
        # Write actual code here instead  
        pass

任何调用了废弃的turn_left函数的代码,都将引发一个警告:

>>> Car().turn_left()  
__main__:8: DeprecationWarning: turn_left is deprecated, use turn instead

注意

  从Python 2.7开始,DeprecationWarning默认将不显示。可以通过在调用python时指定-W all选项来禁用这一过滤器。关于-W的可用值的更多信息可以参考python手册。

让你的代码告诉开发人员他们的程序正在使用某些最终将要停止工作的东西是明智的,因为这其实也可以自动化。当运行他们的测试集合时,开发人员可以在执行python时使用-W error选项,它会将警告转换为异常。这意味着一个废弃的函数每次被调用时都会有一个错误被抛出,这样使用你的库的开发人员就可以很容易知道如何具体修改他们的代码。

示例2.4 运行python -W error

>>> import warnings  
>>> warnings.warn("This is deprecated", DeprecationWarning)  
Traceback (most recent call last):  
  File "<stdin>", line 1, in <module>  
DeprecationWarning: This is deprecated

Christophe是一名Python程序员,并且是WSME(Web Services Made Easy)的作者。开发人员可以使用这个框架定义Python风格的Web服务,并且支持多种丰富的API,且允许作为插件被集成到其他Web框架中。

在设计Python的API时开发人员常犯的错误是什么?

有许多我在设计Python API时试图避免的错误。

考虑到WSME可能运行在多种不同的框架之上,什么样的API是它必须支持的?

实际上并没有那么多,因为它能够运行在其上的框架很多方面是类似的。它们使用装饰器(decorator)给外面的世界暴露函数和方法,它们都是基于WSGI标准的(所以它们的请求对象看上去非常类似),而且它们多少都以彼此作为灵感来源。也就是说,我们还没有试图将其插入一个异步的Web框架中,如Twisted。

我处理过最大的不同是上下文信息的访问方式。在Web框架中,上下文主要是可以从中导出或附加(身份信息、会话数据、数据连接等)信息的请求,以及一些全局的东西,如全局配置、连接池等。大部分Web框架会假设它们运行在多线程服务器上并且将所有这些信息看作是线程独立数据(Thread-Specific Data,TSD)的。这使得它们可以通过导入来自一个模块的请求代理对象来访问当前的请求并与其一起工作。尽管它使用起来很容易,但它也暗含了一点小魔法并且使全局对象缺乏特定上下文数据。

例如,Pyramid框架的工作方式就不太一样。取而代之的是,上下文被显式地注入到使用它的代码段中。这就是为什么视图会接收一个“request”作为参数,它封装了WSGI的环境变量并提供对应用程序全局上下文的访问。

它们的优缺点各是什么?

类似Pyramid的API风格有个很大的优点,它允许一个单独的程序以非常自然的方式运行在几个完全不同的环境中。缺点就是学习曲线有点儿陡。

Python是怎样让库API的设计更简单或更难的呢?

缺乏内置的定义哪部分公共及哪部分私有的方式,这是个(小)问题也是个优点。

当开发人员对哪部分是他们的API哪部分不是的问题欠考虑的时候,它就会是问题。但是,通过一点规则、文档和(如果需要的话)类似zope.interface的工具,它就将不再是问题了。

它的优点在于能够让API的重构快速而简单,同时保持对前面版本的兼容。

对于API的演化、废弃、移除等你的经验法则是什么?

在做决定的时候我会用下面这几个标准去衡量。

对为Python中的API建立你有什么建议吗?

文档可以让新用户更容易采用你的库。忽视文档会赶走很多潜在用户,而且还不止是初学者。但问题在于,写文档是很难的,所以它经常被忽略。

尽早写文档,并在持续集成中包含文档构建。现在我们有Read the Docs(https://readthedocs.org/),没理由在不包含文档生成和发布(至少对开源软件来说如此)。

使用docstring对API的类和函数进行文档化。遵循PEP 257规范(http://www.python.org/dev/peps/pep-0257/[6],以便开发人员不必读你的源代码就能理解你的API是做什么的。从docstring生成HTML文档,并且不要限制对API的引用。

自始至终提供实用的例子。至少包括一个“入门指南”,向新手展示如何构建一个可以运行的例子。文档的第一页应该提供一个关于API基本情况的快速概览和有代表性的用例。

在文档中一个版本接一个版本地体现API演进的细节。(只有VCS日志是不够的!)

让你的文档可访问,可能的话,让它读起来更舒服些。你的用户需要能够很容易地找到文档,并从中获取他们需要的信息而没有任何被折磨的感觉。通过PyPI发布你的文档就可以实现这一点,通过Read the Docs发布也是很不错的方法,因为用户会希望能够在那里找到你的文档。

最后,选择一个高效且吸引人的主题。我为WSME选择了“Cloud”Sphinx主题,但实际上有大量的主题可供选择。没必要为了做出好看的文档而成为Web专家。

[1]说几乎是因为路径并不会被放在列表的同一级上,尽管根据你的使用情况它可能并不重要。

[2] Hy是Python上的Lisp实现,会在9.1节介绍。

[3]在Python 2.3版本实现的新的带入钩子机制。

[4]它在Python 3.3中又被加了回来。

[5]对要和C打交道的Python开发人员来说,这是一个很方便的与__attribute__((deprecated)) GCC扩展的对应物。

[6] Docstring Conventions, David Goodger, Guido van Rossum, 29 May 2001


重磅消息!现在居然还有人在自己的项目中没有测试策略。本书的目的不是试图说服你开始单元测试。如果你想被说服的话,建议你从了解测试驱动开发的好处开始。编写未经测试的代码是毫无用处的,因为没有办法能最终证明它是可以工作的。

本章将介绍可以用来构建良好测试集的Python工具。我们还将讨论如何利用这些工具增强你的软件,让软件更加健壮,避免引入回归问题。

  unittest在Python 2.7中已经做了较大改进,如果正在支持Python的早期版本,那么可能需要使用它的向后移植的名字unittest2(https://pypi.python.org/pypi/unittest2/)。如果需要支持Python 2.6,可以使用下面的代码段在运行时为任何Python版本导入正确的模块:

和你了解的也许不同,在Python中编写和运行单元测试是非常简单的。它不但不会干扰或者破坏现有程序,还会极大地帮助你和其他开发人员维护软件。

try:
    import unittest2 as unittest
except ImportError:
    import unittest

测试应该保存在应用程序或库的tests子模块中。这可以使测试代码随模块一同分发,以便只要软件被安装了,它们就可以被任何其他人运行或重用而无需使用源代码包。同时,这也可以避免这些测试代码被错误地安装在顶层tests模块。

通常比较简单的方式是采用模块树的层次结构作为测试树的层级结构。也就是说,覆盖代码mylib/foobar.py的测试应该存储在mylib/tests/test_foobar.py中,这样在查找与某个特定文件相关联的测试时会比较方便,如示例6.1所示。

示例6.1 test_true.py中的一个真实的简单测试

def test_true():
    assert True

这是能够写出来的最简单的单元测试。要运行它,只需加载test_true.py文件并运行其中定义的test_true函数。

显然,对于你的所有测试文件都这么做肯定是太痛苦了。这就是nose(https://nose. readthedocs.org/en/latest/)这个包要解决的—安装之后,它将提供nosetests命令,该命令会加载所有以test_开头的文件,然后执行其中所有以test_开头的函数。

因此,针对我们的源代码树中的test_true.py文件运行nosetests将得到以下结果:

$ nosetests -v
test_true.test_true ... ok

---------------------------------------------------------
Ran 1 test in 0.003s OK

但是,一旦测试失败,输出就会相应改变,以体现这次失败,包括完整的跟踪回溯。

% nosetests -v
test_true.test_true ... ok
test_true.test_false ... FAIL

=========================================================
FAIL: test_true.test_false

Traceback (most recent call last):
  File "/usr/lib/python2.7/dist-packages/nose/case.py", line 197, in
     runTest
    self.test(*self.arg)
  File "/home/jd/test_true.py", line 5, in test_false
    assert False
AssertionError
---------------------------------------------------------
Ran 2 tests in 0.003s

FAILED (failures=1)

一旦有AssertionError异常抛出,测试就失败了;一旦assert的参数被判断为某些假值(FalseNone0等),它就会抛出AssertionError异常。如果有其他异常抛出,测试也会出错退出。

很简单,对吗?这种方法尽管简单,但却在很多小的项目中广泛使用且工作良好。除了nose,它们不需要其他工具或库,而且只依赖assert就足够了。

不过,在需要编写更复杂的测试时,只使用assert会让人很抓狂。设想一下下面这个测试:

def test_key():
    a = ['a', 'b']
    b = ['b']
    assert a == b

当运行nosetests时,它会给出如下输出:

$ nosetests -v
test_complicated.test_key ... FAIL

==========================================================
FAIL: test_complicated.test_key
Traceback (most recent call last):
  File "/usr/lib/python2.7/dist-packages/nose/case.py", line 197, in
     runTest
    self.test(*self.arg)
  File "/home/jd/test_complicated.py", line 4, in test_key
    assert a == b
AssertionError

---------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

显然,因为ab不同,所以测试不能通过。但是,它们到底有何不同呢?assert没有给出这一信息,而只是声称此断言是错误的—这是没什么用的。

而且,用这种基本的无框架方式实现一些高级的测试(如忽略某个测试,或者在每个测试之前或之后执行某些操作)也会非常痛苦。

这用unittest就比较方便了。它提供了解决上述问题的工具,而且unittest是Python标准库的一部分。

警告

如果用unittest重写前面的例子,看起来会是下面的样子:

import unittest

class TestKey(unittest.TestCase):
    def test_key(self):
         a = ['a', 'b']
         b = ['b']
         self.assertEqual(a, b)

如你所见,实现起来并没有更复杂。需要做的就只是创建一个继承自unittest.TestCase的类,并且写一个运行测试的方法。与使用assert不同,我们依赖unittest.TestCase类提供的一个方法,它提供了一个等价的测试器。运行时,其输出如下:

$ nosetests -v
test_key (test_complicated.TestKey) ... FAIL

=========================================================
FAIL: test_key (test_complicated.TestKey)
Traceback (most recent call last):
  File "/home/jd/Source/python-book/test_complicated.py", line 7, in
     test_key
    self.assertEqual(a, b)
AssertionError: Lists differ: ['a', 'b'] != ['b']

First differing element 0:
a
b

First list contains 1 additional elements.
First extra element 1:
b

- ['a', 'b']
+ ['b']

---------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

如你所见,这个输出结果很有用。仍然有断言错误被抛出,而且测试仍然失败了,但至少我们获得了为什么测试会失败的真正信息,它可以帮我们解决这个问题。这就是写测试用例时永远不应该使用assert的原因。任何人试图hack你的代码并最终遇到某个测试失败时都会感谢你没有使用assert,这同时也为他提供了调试信息。

unittest提供了一组测试函数,可以用来特化测试,如assertDictEqualassertEqualassertTrueassertFalseassertGreaterassertGreaterEqualassertInassertIsassertIsIntanceassertIsNonassertualIsNotassertIsNotNoneassertItemsEqualassertLessassertLessEqualassertListEqualassertMultiLineEqualassertNotAlmostEqualassertNotEqualssertTupleEqualassertRaisesassertRaisesRegexpassertRegexpMatches等。最好是通读一遍pydoc unittest,以便全面了解。

也可以使用fail(msg)方法有意让某个测试立刻失败。例如,已知代码的某个部分如果执行一定会抛出一个错误但没有特定的断言去检查时,这是很方便的,如示例6.2所示。

示例6.2 让测试失败

import unittest
class TestFail(unittest.TestCase):
    def test_range(self):
        for x in range(5):
            if x > 4:
                self.fail("Range returned a too big value: %d" % x)

有时候,某个测试如果不能运行,忽略它是很有用的。例如,希望根据某个库的存在与否有条件地运行某个测试。为此,可以抛出unitest.SkipTest异常。当该测试被执行时,它只是被简单地标注为已忽略。更便利的方法是使用unittest.TestCase.skipTest()而不是手工抛出这一异常,另外也可以使用unittest.skip装饰器,如示例6.3所示。

示例6.3 忽略测试

import unittest

try:
    import mylib
except ImportError:
    mylib = None

class TestSkipped(unittest.TestCase):
    @unittest.skip("Do not run this")
    def test_fail(self):
        self.fail("This should not be run")

    @unittest.skipIf(mylib is None, "mylib is not available")
    def test_mylib(self):
        self.assertEqual(mylib.foobar(), 42)

    def test_skip_at_runtime(self):
        if True:
            self.skipTest("Finally I don't want to run it")

执行后,该测试文件会输出下列内容:

$ python -m unittest -v test_skip
test_fail (test_skip.TestSkipped) ... skipped 'Do not run this'
test_mylib (test_skip.TestSkipped) ... skipped 'mylib is not available'
test_skip_at_runtime (test_skip.TestSkipped) ... skipped "Finally I don't
   want to run it"

---------------------------------------------------------
Ran 3 tests in 0.000s

OK (skipped=3)

提示

  在示例6.3中你可能已经注意到,unittest模块提供了一种执行包含测试的Python模
块的方式。它没有nosetests那么方便,因为它不会发现自己的测试文件,但它对于运行特定测试模块仍然是很有用的。

在许多场景中,需要在运行某个测试前后执行一组通用的操作。unittest提供了两个特殊的方法setUp和tearDown,它们会在类的每个测试方法调用前后执行一次,如示例6.4所示。

示例6.4 使用unittestsetUp方法

import unittest

class TestMe(unittest.TestCase):
    def setUp(self):
        self.list = [1, 2, 3]

    def test_length(self):
        self.list.append(4)
        self.assertEqual(len(self.list), 4)

    def test_has_one(self):
        self.assertEqual(len(self.list), 3)
        self.assertIn(1, self.list)

在这个示例中,setUp会在运行test_lengthtest_has_one之前被调用。它可以非常方便地创建在每个测试中要用到的对象,但你需要保证它们在运行每个测试之前,在干净的状态下被重建。这对于创建测试环境是非常有用的,经常被称为fixture(参见6.2节)。

提示

  使用nosetests时,经常会只想运行某个特定的测试。你可以选择要运行的测试作为参数,语法是path.to.your.module:ClassOfYourTest.test_method。确保在模块路径和类名之前有一个冒号。也可以指定path.to.your.module:ClassOfYourTest来执行整个类,或者指定path.to.your.module来执行整个模块。

提示

  通过同时运行多个测试可以加快速度。只需为nosetests调用加上--process=N选项即可创建多个nosetests进程。不过,testrepository是更好的选择(这会在6.5节中讨论)。

在单元测试中,fixture表示“测试前创建,测试后销毁”的(辅助性)组件。比较好的方式是为它们构建一个特殊的组件,因为它们会在许多不同的地方被重用。例如,如果你需要一个对象来表示你的应用程序的配置状态,很可能你希望在每个测试前初始化它,并在测试结束后将其重置为默认值。对临时文件创建的依赖也需要该文件在测试开始前被创建,测试结束后被删除。

unittest只为我们提供了已经提及的setUptearDown函数。不过,是有机制可以hook这两个函数的。fixtures(https://pypi.python.org/pypi/fixtures)Python模块(并非标准库的一部分)提供了一种简单的创建fixture类和对象的机制,如useFixture方法。

fixtures模块提供了一些内置的fixture,如fixtures.EnvironmentVariable,对于在os.environ中添加或修改变量很有用,并且变量会在测试退出后重置,如示例6.5所示。

示例6.5 使用fixtures.EnvironmentVariable

import fixtures
import os

class TestEnviron(fixtures.TestWithFixtures):
    def test_environ(self):
        fixture = self.useFixture(
            fixtures.EnvironmentVariable("FOOBAR", "42"))
        self.assertEqual(os.environ.get("FOOBAR"), "42")

    def test_environ_no_fixture(self):
        self.assertEqual(os.environ.get("FOOBAR"), None)

当你发现类似的通用模式时,最好创建一个fixture,以便它可以被你的所有其他测试用例重用。这极大地简化了逻辑,并且能准确地体现你在测试什么以及以何种方式测试。

注意

  本节的示例代码之所以没有用unittest.TestCase,是因为fixtures.TestWith-Fixtures继承自unittest.TestCase

mock对象即模拟对象,用来通过某种特殊和可控的方式模拟真实应用程序对象的行为。在创建精确地描述测试代码的状态的环境时,它们非常有用。

如果正在开发一个HTTP客户端,要想部署HTTP服务器并测试所有场景,令其返回所有可能值,几乎是不可能的(至少会非常复杂)。此外,测试所有失败场景也是极其困难的。

一种更简单的方式是创建一组根据这些特定场景进行建模的mock对象,并利用它们作为测试环境对代码进行测试。

Python标准库中用来创建mock对象的库名为mock(https://pypi.python.org/pypi/mock/ )。从Python 3.3开始,它被命名为unit.mock,合并到Python标准库。因此可以使用下面的代码片段:

try:
    from unittest import mock
except ImportError:
    import mock

要保持Python 3.3和之前版本之间的向后兼容。

它使用起来也非常简单,如示例6.6所示。

示例6.6 mock的基本用法

>>> import mock
>>> m = mock.Mock()
>>> m.some_method.return_value = 42
>>> m.some_method()
42
>>> def print_hello():
...
print("hello world!")
...
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
>>> def print_hello():
... print("hello world!")
... return 43
...
>>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
43
>>> m.some_method.call_count
3

即使只使用这一组功能,也应该可以模拟许多内部对象以用于不同的数据场景中。

模拟使用动作/断言模式,也就是说一旦测试运行,必须确保模拟的动作被正确地执行,如示例6.7所示。

示例6.7 确认方法调用

>>> import mock
>>> m = mock.Mock()
>>> m.some_method('foo', 'bar')
<Mock name='mock.some_method()' id='26144272'>
>>> m.some_method.assert_called_once_with('foo', 'bar')
>>> m.some_method.assert_called_once_with('foo', mock.ANY)
>>> m.some_method.assert_called_once_with('foo', 'baz')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/dist-packages/mock.py", line 846, in
      assert_called_once_with
    return self.assert_called_with(*args, **kwargs)
  File "/usr/lib/python2.7/dist-packages/mock.py", line 835, in
      assert_called_with
    raise AssertionError(msg)
AssertionError: Expected call: some_method('foo', 'baz')
Actual call: some_method('foo', 'bar')

显然,很容易传一个mock对象到代码的任何部分,并在其后检查代码是否按其期望的传入参数被调用。如果不知道该传入何种参数,可以使用mock.ANY作为参数值传入,它将会匹配传递给mock方法的任何参数。

有时可能需要来自外部模块的函数、方法或对象。mock库为此提供了一组补丁函数。

示例6.8 使用mock.patch

>>> import mock
>>> import os
>>> def fake_os_unlink(path):
...     raise IOError("Testing!")
...
>>> with mock.patch('os.unlink', fake_os_unlink):
...    os.unlink('foobar')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "<stdin>", line 2, in fake_os_unlink
IOError: Testing!

通过mock.pach方法,可以修改外部代码的任何部分,使其按照需要的方式对软件进行各种条件下的测试,如示例6.9所示。

示例6.9 使用mock.patch测试一组行为

import requests
import unittest
import mock

class WhereIsPythonError(Exception):
    pass

def is_python_still_a_programming_language():
    try:
        r = requests.get("http://python.org")
    except IOError:
        pass
    else:
        if r.status_code == 200:
            return 'Python is a programming language' in r.content
    raise WhereIsPythonError("Something bad happened")

def get_fake_get(status_code, content):
    m = mock.Mock()
    m.status_code = status_code
    m.content = content
    def fake_get(url):
        return m
    return fake_get

def raise_get(url):
    raise IOError("Unable to fetch url %s" % url)
class TestPython(unittest.TestCase):
    @mock.patch('requests.get', get_fake_get(
        200, 'Python is a programming language for sure'))
    def test_python_is(self):
        self.assertTrue(is_python_still_a_programming_language())

    @mock.patch('requests.get', get_fake_get(
        200, 'Python is no more a programming language'))
    def test_python_is_not(self):
        self.assertFalse(is_python_still_a_programming_language())

    @mock.patch('requests.get', get_fake_get(
        404, 'Whatever'))
    def test_bad_status_code(self):
        self.assertRaises(WhereIsPythonError,
                          is_python_still_a_programming_language)

    @mock.patch('requests.get', raise_get)
    def test_ioerror(self):
        self.assertRaises(WhereIsPythonError,
                          is_python_still_a_programming_language)

示例6.9使用了mock.patch的装饰器版本,这并不改变它的行为,但当需要在整个测试函数的上下文内使用模拟时这会更方便。

使用模拟可以很方便地模拟任何问题,如Web服务器返回404错误,或者发生网络问题。我们可以确定代码返回的是正确的值,或在每种情况下抛出正确的异常,总之确保代码总是按照预期行事。

在进行单元测试时,对某个对象的不同版本运行一组测试是较常见的需求。你也可能想对一组不同的对象运行同一个错误处理测试去触发这个错误,又或者想对不同的驱动执行整个测试集。

最后一种情况在OpenStack Ceilometer[1](https://launchpad.net/ceilometer)项目中被大量使用。Ceilometer中提供了一个调用存储API的抽象类。任何驱动都可以实现这个抽象类,并将自己注册成为一个驱动。Ceilometer可以按需要加载被配置的存储驱动,并且利用实现的存储API保存和提取数据。这种情况下就需要对每个实现了存储API的驱动调用一类单元测试,以确保它们按照调用者的期望执行。

实现这一点的一种自然方式是使用混入类(mixin class):一方面你将拥有一个包含单元测试的类,另一方面这个类还会包含对特定驱动用法的设置。

import unittest

class MongoDBBaseTest(unittest.TestCase):
    def setUp(self):
        self.connection = connect_to_mongodb()

class MySQLBaseTest(unittest.TestCase):
    def setUp(self):
        self.connection = connect_to_mysql()

class TestDatabase(unittest.TestCase):
    def test_connected(self):
        self.assertTrue(self.connection.is_connected())

class TestMongoDB(TestDatabase, MongoDBBaseTest):
    pass

class TestMySQL(TestDatabase, MySQLBaseTest):
    pass

然而,从长期维护的角度看,这种方法的实用性和可扩展性都不好。

更好的技术是有的,可以使用testscenarios包(https://pypi.python.org/pypi/ testscenarios)。它提供了一种简单的方式针对一组实时生成的不同场景运行类测试。这里使用testscenarios重写了示例6.9的部分代码来说明6.3节中介绍过的模拟,具体见示例6.10。

示例6.10 testscenarios的基本用法

import mock
import requests
import testscenarios

class WhereIsPythonError(Exception):
    pass

def is_python_still_a_programming_language():
    r = requests.get("http://python.org")
    if r.status_code == 200:
        return 'Python is a programming language' in r.content
    raise WhereIsPythonError("Something bad happened")

def get_fake_get(status_code, content):
    m = mock.Mock()
    m.status_code = status_code
    m.content = content
    def fake_get(url):
        return m
    return fake_get

class TestPythonErrorCode(testscenarios.TestWithScenarios):
    scenarios = [
        ('Not found', dict(status=404)),
        ('Client error', dict(status=400)),
        ('Server error', dict(status=500)),
    ]

    def test_python_status_code_handling(self):
        with mock.patch('requests.get',
                        get_fake_get(
                            self.status,
                            'Python is a programming language for sure')):
            self.assertRaises(WhereIsPythonError,
                              is_python_still_a_programming_language)

尽管看上去只定义了一个测试,但是testscenarios会运行这个测试三次,因为这里定义了三个场景。

 % python -m unittest -v test_scenario
 test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok
 test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok
 test_python_status_code_handling (test_scenario.TestPythonErrorCode) ... ok

 ---------------------------------------------------------
 Ran 3 tests in 0.001s
 OK

如上所示,为构建一个场景列表,我们需要的只是一个元组列表,其将场景名称作为第一个参数,并将针对此场景的属性字典作为第二个参数。

很容易联想到另一种使用方式:可以实例化一个特定的驱动并针对它运行这个类的所有测试,而不是为每个测试存储一个单独的值作为属性。具体如示例6.11所示。

示例6.11 使用testscenarios测试驱动

import testscenarios
From myapp import storage

class TestPythonErrorCode(testscenarios.TestWithScenarios):
    scenarios = [
        ('MongoDB', dict(driver=storage.MongoDBStorage())),
        ('SQL', dict(driver=storage.SQLStorage())),
        ('File', dict(driver=storage.FileStorage())),
    ]

    def test_storage(self):
        self.assertTrue(self.driver.store({'foo': 'bar'}))

    def test_fetch(self):
        self.assertEqual(self.driver.fetch('foo'), 'bar')

注意

  这里之所以不需要使用前面示例中使用的基类unittest.TestCase,是因为test-scenarios.TestWithScenarios继承自unittest.TestCase

在执行大量测试时,按它们被运行的情况进行分析是很有用的。类似nosetests这样的工具只是将结果输出到stdout,即标准输出,但这对测试结果的解析或分析并不方便。

subunit(https://pypi.python.org/pypi/python-subunit)是用来为测试结果提供流协议(streaming protocol)的一个Python模块。它支持很多有意思的功能,如聚合测试结果[2]或者对测试的运行进行记录或归档等。

使用subunit运行测试非常简单:

$ python -m subunit.run test_scenario

这条命令的输出是二进制数据,所以除非有能力直接阅读subunit协议,否则在这里直接再现它的输出结果实在是没什么意义。不过,subunit还支持一组将其二进制流转换为其他易读格式的工具,如示例6.12所示。

示例6.12 使用subunit2pyunit

 $ python -m subunit.run test_scenario | subunit2pyunit
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Not 
found)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Not 
found) ... ok
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Client 
error)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Client 
error) ... ok
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Server 
error)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Server 
error) ... ok

 ---------------------------------------------------------
 Ran 3 tests in 0.061s
 OK

这样的结果就容易理解了。你应该可以认出这个关于场景测试的测试集来自6.4节。其他值得一提的工具还有subunit2csvsubunit2gtksubunit2junitxml

subunit还可以通过传入discover参数支持自动发现哪个测试要运行。

 $ python -m subunit.run discover | subunit2pyunit
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Not 
found)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Not 
found) ... ok
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Client 
error)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Client 
error) ... ok
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Server 
error)
 test_scenario.TestPythonErrorCode.test_python_status_code_handling(Server 
error) ... ok

 ---------------------------------------------------------
 Ran 3 tests in 0.061s
 OK

也可以通过传入参数--list只列出测试但不运行。要查看这一结果,可以使用subunit-ls

$ python -m subunit.run discover --list | subunit-ls --exists
test_request.TestPython.test_bad_status_code
test_request.TestPython.test_ioerror
test_request.TestPython.test_python_is
test_request.TestPython.test_python_is_not
test_scenario.TestPythonErrorCode.test_python_status_code_handling

提示

  可以使用--load-list选项指定要运行的测试的清单而不是运行所有的测试。

在大型应用程序中,测试用例的数量可能会多到难以应付,因此让程序处理测试结果序列是非常有用的。testrepository包(https://pypi.python.org/pypi/testrepository)目的就是解决这一问题,它提供了testr程序,可以用来处理要运行的测试数据库。

$testr init
$ touch .testr.conf
% python -m subunit.run test_scenario | testr load
Ran 4 tests in 0.001s
PASSED (id=0)
$ testr failing
PASSED (id=0)
$ testr last
Ran 3 tests in 0.001s
PASSED (id=0)
$ testr slowest
Test id                                         Runtime (s)

----------------------------------------------  -----------
test_python_status_code_handling(Not found)  0.000
test_python_status_code_handling(Server error)  0.000
test_python_status_code_handling(Client error)  0.000
$ testr stats
runs=1

一旦subunit的测试流被运行并加载到testrepository,接下来就很容易使用testr命令来操作了。

显然,每次手工处理要运行的测试是很烦人的。因此,应该“教会”testr如何执行要运行的测试,以便它可以自己去加载测试结果。这可以通过编辑项目的根目录中的.testr.conf文件(见示例6.13)来实现。

示例6.13 .testr.conf文件

[DEFAULT]   
test_command=python -m subunit.run discover . $LISTOPT $IDOPTION
test_id_option=--load-list $IDFILE
test_list_option=--list 

执行testr run时要运行的命令。

加载测试列表要运行的命令。

列出测试要运行的命令。

第一行的test_command是最关键的。现在只需要运行testr run就可以将测试加载到testrepository中并执行。

注意

  如果习惯用noseteststestr run现在是等效的命令。

另外两个选项可以支持测试的并行运行。通过给testr run加上--prallel选项即可轻松实现,如示例6.14所示。并行运行测试可以极大地加速测试过程。

示例6.14 运行testr run --parallel

$ testr run --parallel
running=python -m subunit.run discover .  --list 
running=python -m subunit.run discover .  --load-list /tmp/tmpiMq5Q1
running=python -m subunit.run discover .  --load-list /tmp/tmp7hYEkP
running=python -m subunit.run discover .  --load-list /tmp/tmpP_9zBc
running=python -m subunit.run discover .  --load-list /tmp/tmpTejc5J
Ran 26 (+10) tests in 0.029s (-0.001s)
PASSED (id=7, skips=3)

在后台,testr运行测试列出操作,然后将测试列表分成几个子列表,并分别创建Python进程运行测试的每个子列表。默认情况下,子列表的数量与当前使用的机器的CPU数目相等。可以通过加入--concurrency标志设置进程的数目。

$ testr run --parallel --concurrency=2

可以想象,类似subunittestrepository这样的工具将为测试效率的提升带来更多可能,而本节只是一个大概介绍。熟悉这些工具是非常值得的,因为测试会极大地影响你将要开发和发布的软件的质量。利用这些有力的工具能够节省很多时间。

testrepository也可以同setuptools集成,并且为其部署testr命令。这使得与基于setup.py工作流的集成更加容易,例如,可以围绕setup.py记录整个项目。setup.py testr命令可以接受一些选项,如--testr-args(通过它可以为testr加入更多选项)或者--coverage(这将在下一节介绍)。

测试覆盖是完善单元测试的工具。它通过代码分析工具和跟踪钩子来判断代码的哪些部分被执行了。在单元测试期间使用时,它可以用来展示代码的哪些部分被测试所覆盖而哪些没有。

编写测试当然有用,但是知道代码的哪些部分没有被测试到才是关键所在。

显然,要做的第一件事就是在系统中安装Python的coverage模块(https://pypi.python.org/pypi/coverage)。安装之后就可以通过shell使用coverage程序[3]

单独使用coverage非常简单且有用,它可以指出程序的哪些部分从来没有被运行过,以及哪些可能是“僵尸代码”。此外,在单元测试中使用的好处也显而易见,可以知道代码的哪些部分没有被测试过。前面谈到的测试工具都可以与coverage集成。

使用nose时,只需要加入很少的选项就可以生成一份不错的代码覆盖报告,如示例6.15所示。

示例6.15 使用nosetests --with-coverage

 $ nosetests --cover-package=ceilometer --with-coverage tests/test_pipeline.py
 ..............................................
 Name                              Stmts    Miss   Cover   Missing
 ceilometer                            0       0     100%   
 ceilometer.pipeline                 152      20      87%  49, 59, 113, 
127-128, 188-192, 275-280, 350-362
 ceilometer.publisher                 12       3      75%  32-34
 ceilometer.sample                    31       4      87%  81-84
 ceilometer.transformer               15       3      80%  26-32, 35
 ceilometer.transformer.accumulator   17       0     100% 
 ceilometer.transformer.conversions   59       0     100% 
 TOTAL                               888     393      56%   

 ---------------------------------------------------------
 Ran 46 tests in 0.170s
 OK

加上--cover-package选项是很重要的,否则就会看到每个被用到的Python包,包括标准库和第三方库。这个输出包括没有被运行的代码行,也就是没有被测试的代码行。所有需要做的只是打开你喜欢的文本编辑器然后开始写点儿什么。

但是也可以做得更好一点儿,让coverage生成漂亮的HTML报表。只需要加上--cover-html标志,这个cover目录就会在HTML页面中打开,然后每一页都会显示源代码的哪些部分运行与否如图6-1所示。

如果愿意的话,可以使用--cover-min-percentage=COVER_MIN_PERCENTAGE选项,如果测试集运行时被执行的代码没有达到指定的最低百分比,这将会让测试集失败。

警告

  代码覆盖率是100%并不意味着代码已经被全部测试可以休息了。它只表明整个代码路径都被运行了,并不意味着每一个可能的条件都被测试到了。也就是说,这是个值得追求的目标,但并不意味着这是终点。

使用testrepository时,可以使用setuptools集成运行coverage

示例6.16 使用coverage和testrepository

$ python setup.py testr --coverage

这样可以结合coverage自动运行测试集,并在cover目录中生成HTML报告。

接下来你应该利用这些信息来巩固测试集,并为当前没有被运行过的任何代码添加测试。这是非常重要的,因为它有利于项目的后期维护,并有利于提升代码的整体质量。

图6-1 ceilometer.publisher的覆盖率

在第5章中,已经介绍并讨论了虚拟环境的使用。它的主要用途之一便是为单元测试提供干净的环境。当你认为你的测试工作正常但是实际上不正常时是相当郁闷的,如涉及依赖列表的情况。

可以写一个脚本去部署虚拟环境,安装setuptools,然后安装应用程序/库的运行时或者单元测试所需要的所有依赖。但这是非常常见的用例,所以已经有专门针对这一需求的应用程序了,即tox

tox的目标是自动化和标准化Python中运行测试的方式。基于这一目标,它提供了在一个干净的虚拟环境中运行整个测试集的所有功能,并安装被测试的应用程序以检查其安装是否正常。

使用tox之前,需要提供一个配置文件。这个文件名为tox.ini,需要放在被测试项目的根目录,与setup.py同级。

$ touch tox.ini

现在可以成功运行tox:

% tox
GLOB sdist-make: /home/jd/project/setup.py
python create: /home/jd/project/.tox/python
python inst: /home/jd/project/.tox/dist/project-1.zip
____________________ summary _____________________
  python: commands succeeded
  congratulations :)

显然这本身并不是很有用。在上面的例子中,tox使用默认的Python版本在.tox/python中创建了一个虚拟环境,使用setup.py创建了应用程序的一个分发包并在这个虚拟环境中进行了安装。接下来并没有命令运行,因为该配置文件中并没有指定任何命令。

可以通过添加一个要在测试环境中运行的命令来改变其默认行为。编辑tox.ini。让它包含以下内容:

[testenv]
commands=nosetests

要执行的nosetests命令很可能会失败,因为在该虚拟环境中我们并没有安装nosetests。因此需要将其作为(将被安装的)依赖的一部分列出来。

[testenv]
deps=nose
commands=nosetests

再次运行,tox会重建虚拟环境,安装新的依赖并运行nosetests命令,它将执行所有单元测试。显然,我们可能需要添加更多的依赖,这可以通过配置项deps列出,也可以使用-rfile语法从文件中读取。如果正在使用pbr管理setup.py文件,那么应该知道它是从一个名为requirements.txt的文件中读取所有依赖的。因此,让tox使用这个文件是一个好主意:

[testenv]
deps=nose
     -rrequirements.txt
commands=nosetests

文件中[testenv]一节定义的是被tox管理的所有虚拟环境参数。但正如前面所提及的,tox能够真正地管理多个Python虚拟环境,通过向tox传入-e标志就可以将测试运行在某个特定Python版本之上而不是运行在默认的版本之上。

 % tox -e py26
 GLOB sdist-make: /home/jd/project/setup.py
 py26 create: /home/jd/project/.tox/py26
 py26 installdeps: nose
 py26 inst: /home/jd/project/.tox/dist/rebuildd-1.zip
 py26 runtests: commands[0] | nosetests
 .......

 ---------------------------------------------------------
 Ran 7 tests in 0.029s

 OK
 ____________________ summary _____________________
   py26: commands succeeded
   congratulations :)

默认情况下,tox可以模拟多种环境:py24py25py26py27py30py31py32py33jythonpypy!你甚至可以加入自定义的环境。要添加一个环境或者创建一个新环境,只需添加一个新的配置节[testenv:_envname_]。如果要针对其中的某个环境运行不同的命令,使用下面的tox.ini文件是很容易实现的:

[testenv]
deps=nose
commands=nosetests
[testenv:py27]
commands=pytest

这只覆盖了针对py27环境的命令,所以当运行tox -e py27时nose仍然会被作为依赖的一部分安装,但会执行pytest命令。

也可以使用Python不支持的版本创建新环境:

[testenv]
deps=nose
commands=nosetests

[testenv:py21]
basepython=python2.1

这里试图使用Python 2.1运行测试集,尽管我并不认为它能运行得起来。

如今,通常你可能希望应用程序能支持多个Python版本。让tox为想要默认支持的Python版本运行所有测试是非常有用的。这可以通过指定要使用的环境列表来实现,而在tox运行时无须提供参数。

[tox]
envlist=py26,py27,py33,pypy

[testenv]
deps=nose
commands=nosetests

当不指定任何参数运行tox时,列出的所有4种环境都将被创建,继而安装依赖和应用程序,然后运行命令nosetests

也可以使用tox来集成其他测试,如flake8,正如1.4节中讨论过的。

[tox]
envlist=py26,py27,py33,pypy,pep8

[testenv]
deps=nose
commands=nosetests

[testenv:pep8]
deps=flake8
commands=flake8

在这个示例中,使用默认的Python版本运行pep8环境,不过这应该问题不大[4]

提示

  当运行tox时,你会发现所有的环境会按顺序创建并运行。这通常会令整个过程耗时很长。但因为虚拟环境都是隔离的,所以可以并行运行tox命令。这正是detox包(https://pypi.python.org/pypi/detox)要做的,即通过detox命令能够并行运行envlist中指定的所有默认环境。你应该运行pip install安装它。

在项目中包含测试代码当然很好,但是如何运行这些测试也相当重要。实际上,在许多项目中尽管包含了测试代码,但是测试代码却由于各种原因无法运行。

尽管这个主题并不局限于Python,但是考虑到其重要性,这里还是要强调一下:要对未测试代码零容忍。没有一组合适的单元测试覆盖的代码是不应该被合并的。

最低目标是保证每次代码提交都能通过所有测试,最好是能以自动的方式实现。

例如,OpenStack会依赖基于Gerrit(https://code.google.com/p/gerrit/)、Jenkins(http://jenkins-ci.org/)和Zuul(http://ci.openstack.org/zuul/)的一个特定工作流程。每次代码提交都会经过基于Gerrit的代码评审系统,同时Zuul负责通过Jenkins运行一组测试任务。Jenkins会针对各个项目运行单元测试以及各种更高级别的功能测试。这可以保证提交的代码能通过所有测试。由众多开发人员完成的代码评审保证所有被提交的代码都是具有相应的单元测试的。

如果正在使用流行的GitHub托管服务,Travis CI(https://travis-ci.org/)提供了一种在代码的签入(push)、合并(merge)或签出(pull)请求后运行测试的方式。尽管在提交后执行测试有些差强人意,但这仍然是针对回归问题的一种不错的方式。Travis支持所有主要的Python版本,并可以高度定制。一旦通过它们的Web界面在项目中激活了Travis,就可以通过加入一个简单的.travis.yml文件(如示例6.17所示)来完成后续工作。

示例6.17 .travis.yml文件的例子

language: python
python:
  - "2.7"
  - "3.3"

# command to install dependencies
install: "pip install -r requirements.txt --use-mirrors"
# command to run tests
script: nosetests

无论你的代码托管在哪里,都应该尽可能实现软件测试的自动化,进而保证项目不断向前推进而不是引入更多bug而倒退。

尽管你可能不知道Robert是谁,但你很可能已经用过他写的程序,别的暂且不提,他是分布式版本管理系统Bazaar(http://bazaar.canonical.com/)的最初作者之一。目前,他是惠普云服务的“杰出技术专家”,从事OpenStack相关的工作。Robert还开发过本书中介绍的很多Python工具,如fixtures、testscenarios、testrepository和python-subunit。

你会建议使用什么样的测试策略?什么情况下不进行代码测试是可以接受的?

我认为这是一个软件工程上的取舍问题—需要考虑问题被引入未经检测产品的可能性,组件中未发现的问题产生的成本,从事这一工作的团队的规模和凝聚力……以OpenStack(http://openstack.org)为例,它拥有超过1600名贡献者,因此很难有细致入微的策略,因为很多人会有不同的意见。总体上来讲,应该有一些自动检查的方式作为代码并入主干时的组成部分,保证代码实现的正是其要做的,以及代码要做的也正是需要其完成的。通常来说,功能测试可能会放在不同的代码库中。单元测试的执行速度非常快,而且可以用来定义比较生僻的测试用例。我认为在已经有了测试的情况下,在测试的不同风格之间稍做平衡是完全可以的。

尽管测试的成本很高而回报较低,同时我也觉得在知情的情况下不测试是可以接受的,但这只是相对较少的情况。大部分事情都可以进行低成本的测试,而在早期发现问题的回报都相当高。

在编写Python代码时,有哪些令测试更容易且可提高代码质量的切实可行的最佳策略?

分而治之—不要在一个地方做多件事情。这便于重用,也可以让测试的重复运行更容易。尽可能使方法的功能单一,例如,对于单个方法要么用来计算,要么改变状态,但不要两者都做。这样就可以测试所有的计算行为而无须处理状态改变之类的操作,如写入数据库、与HTTP服务器交互等。相关的其他方式也同样受益,可以通过替换测试计算逻辑来触发生僻的测试用例的行为,并且通过仿真/测试进一步确认期望的状态传播是否如预期那样发生。测试IME最恶心的是深层栈,它们具有复杂的跨层的行为依赖。这里就需要不断改进代码,以使层之间的关系保持简单、可预测且对测试最有用的—可替换。

依你看来,在源代码中组织单元测试的最佳方式是什么?

使用类似$ROOT/$PACKAGE/tests的层次结构,但我对于整个源代码树只创建一个(相对于这种$ROOT/$PACKAGE/$SUBPACKAGE/tests)。

在测试文件夹内部,我经常镜像源代码的其余部分的结构,如$ROOT/$PACKAGE/foo.py将会在$ROOT/$PACKAGE/tests/test_foo.py中被测试。

应该避免从源代码树的其余部分导入测试包,除非是在顶层的__init__中放一个test_suite/load_tests函数。这样可以在小规模安装时很容易将测试分离。

Python中有哪些库可以用来做功能测试?

我只是用项目中使用的unittest的某些部分,它能灵活地满足大部分需求,尤其是同testresources和并行运行的其他方式结合。

你能展望一下未来Python中单元测试库和框架的发展吗?

我能看到的一些大的挑战包括以下几个。

[1]作者是OpenStack中监控项目Ceilometer的前项目技术主管(Project Technical Lead)。—译者注

[2]甚至可以支持来自不同源程序或语言的测试结果。

[3]如果通过操作系统的软件安装程序进行安装的话,命令名也可能是python-coverage。例如,Debian系统中就叫python-coverage

[4]如果想修改它,还是可以指定basepython


相关图书

深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
动手学自然语言处理
动手学自然语言处理
Web应用安全
Web应用安全
Python高性能编程(第2版)
Python高性能编程(第2版)
图像处理与计算机视觉实践——基于OpenCV和Python
图像处理与计算机视觉实践——基于OpenCV和Python
Python数据科学实战
Python数据科学实战

相关文章

相关课程