Python 测试基础

你怎么知道自己编写的程序管用呢?能指望你在任何时候编写的代码都没有缺陷吗?恕我直言,我想这不太可能。诚然,在大多数情况下使用 Python 都很容易编写出正确的代码,但代码出现 bug 并非没有可能。

调试是程序员躲不开的宿命,是编程工作的有机组成部分。然而,要调试就必须运行程序,而仅仅运行程序可能还不够。例如,如果你编写了一个处理文件的程序,就必须有用来处理的文件。如果你编写了一个包含数学函数的工具库,就必须向这些函数提供参数,才能让其中的代码运行。

程序员无时无刻不在做这样的事情,在编译型语言中,将不断重复编辑、编译、运行的循环。在有些情况下,编译程序就会出现问题,程序员不得不在编译和编辑之间来回切换。在 Python 中,不存在编译阶段,只有编辑和运行阶段。测试就是运行程序。

我将告诉你如何养成在编程中进行测试的习惯,并介绍一些可帮助编写测试的工具。

先测试再编码

要避免代码在开发途中被淘汰,必须能够应对变化并具备一定的灵活性,因此为程序的各个部分编写测试至关重要(这称为单元测试),而且是应用程序设计工作的重要组成部分。极限编程先锋引入了“测试一点点,再编写一点点代码”的理念。这种理念与直觉不太相符,却很管用,胜过与直觉一致的“编写一点点代码,在测试一点点”的做法。

换而言之,测试在先,编码在后。这也称为测试驱动的编程。对于这种方法,你一开始可能不太习惯,但它有很多优点,而且随着时间的推移,你就会慢慢习惯。习惯了测试驱动的编程后,在没有测试的情况下编写代码真的让人觉得别扭。

准确的需求说明

开发软件时,必须先知道软件要解决什么问题——要实现什么样的目标。要阐明程序的目标,可编写需求说明,也就是描述程序必须满足何种需求的文档(或便条)。这样以后就很容易核实需求是否确实得到了满足。不过很多程序员不喜欢撰写报告,更愿意让计算机替他们完成尽可能多的工作。好消息是,你可使用 Python 来描述需求,并让解释器检查是否满足了这些需求!

注意 需求类型众多,包括诸如客户满意度这样模糊的概念。现在的重点是功能需求,即程序必须提供哪些功能。

这里的理念是先编写测试,在编写让测试通过的程序。测试程序就是需求说明,可帮助确保程序开发过程紧扣这些需求。

来看一个简单的示例。假设你要编写一个模块,其中只包含一个根据矩形的宽度和高度计算面积的函数。动手编写代码前,编写一个单元测试,其中包含一些你知道答案的例子。这个测试程序可能类似于如下所示的代码。

from area import rect_area
height = 3
width = 4
correct_answer = 12
answer = rect_area(height, width)
if answer == correct_answer:
    print('Test passed')
else:
    print('Test failed')

在这个示例中,我调用(尚未编写的)函数 rect_area,并将参数 height 和 width 分别设置为 3 和 4,再将结果与正确的答案(12)进行比较(当然,只测试这样一种情况并不能让你确信代码是正确的。真正的测试程序可能要详尽得多)。

如果接下来(在文件 area.py 中)不小心将函数实现为下面这样,并尝试运行程序,将会出现一条错误信息。

def rect_area(height, width):
    return height*height  # 这不对……

接下来,你可能检查代码,看看问题出在什么地方,并将返回的表达式替换为 height*width。

先编写测试再编写代码并不是为了发现 bug,而是为了检查代码是否管用。这有点像古老的禅语所说:如果没有人听到,就认为森林中树木倒下时没有发出声音吗?当然不是,但发出的声音对任何人都没有影响。对代码而言,问题就是:“如果不测试,就认为它什么都没做吗?”抛开其中的哲理不谈,采取下面的态度大有裨益:除非有相应的测试,否则该功能就并不存在,或者说不是真正意义上的功能。这样你就能名正言顺的证明它确实存在,而且做了它应该做的。这不仅对最初开发程序有帮助,对以后扩展和维护代码也有帮助。

做好应对变化的准备

自动化测试不仅可在你编写程序时提供极大的帮助,还有助于你在修改代码时避免累积错误,这在程序规模很大时尤其重要。你必须做好修改代码的心理准备,而不是固守既有代码,但修改是有风险的。修改代码时,常常会引入意想不到的 bug。如果程序设计良好(使用了合适的抽象和封装),修改带来的影响将是局部的,只会影响很小一段代码。这意味着你能够确定 bug 的范围,因此调试起来更容易。

代码覆盖率

覆盖率(converage)是一个重要的测试概念。运行测试时,很可能达不到运行所有代码的理想状态。(实际上,最理想的情况是,使用各种可能的输入检查每种可能的程序状态,但这根本不可能做到。)优秀测试套件的目标之一是确保较高的覆盖率,为此可使用覆盖率工具,它们测量测试期间实际运行的代码所占的比例。如果在网上使用“Python 测试覆盖率”之类的关键字进行搜索,可找到一些相关的工具,其中之一是 Python 自带的程序 trace.py。你可从命令行运行它(可以使用开关-m,这样可避免查找文件的麻烦),也可将其作为模块导入。要获取有关其用法的帮助信息,可使用开关-help 来运行它,也可在解释器中导入这个模块,再执行命令 help(trace)。

你可能觉得详尽地测试各个方面让人不堪重负。不用担心,你无需测试数百种输入和状态变量的组合,至少开始的时候不用。在测试驱动的编程中,最重要的一点是在编码器将反复的运行方法(函数或脚本),以不断获得有关你做法优劣的反馈。如果以后要进一步确信代码是正确的(覆盖率也很高),可随时添加测试。

关键在于,如果没有详尽的测试集,可能无法及时发现你引入的 bug,等你发现时已不知道它们是怎么引入的。因此,如果没有良好的测试套件,要找出错误在什么地方要困难得多。看不到打过来的拳头,你就无法避开它。要确保较高的测试覆盖率,方法之一是秉承测试驱动开发的理念。只要能确保先编写测试再编写函数,就能肯定每个函数都是经过测试的。

测试四部曲

在深入介绍编写测试的细节之前,先来看看测试驱动开发过程的各个阶段(至少有个版本是这样的)。

  1. 确定需要实现的新功能。可将其记录下来,再为之编写一个测试。
  2. 编写实现功能的框架代码,让程序能够运行(不存在语法错误之类的问题),但测试依然无法通过。测试失败是很重要的,因为这样你才能确定它可能失败。如果测试有错误,导致在任何情况下都能成功(这样的情况我遇到过很多次),那么它实际上什么都没有测试。不断重复这个过程:确定测试失败后,再试图让它成功
  3. 编写让测试刚好能够通过的代码。在这个阶段,无需完全实现所需的功能,而只要让测试能够通过即可。这样,在整个开发阶段,都能够让所有的测试通过(首次运行测试时除外)。即便是刚着手实现功能时亦如此。
  4. 改进(重构)代码以全面而准确地实现所需的功能,同时确保测试依然能够成功。

提交代码时,必须确保它们处于健康状态,即没有任何测试是失败的。测试驱动编程倡导者都是这么说的。我有时会在当前正在编写的代码处留下一个失败的测试,作为提醒自己的待办事项或未完事项。然而,与人合作开发时,这种做法真的很糟糕。在任何情况下,都不应将存在失败的测试代码提交到公共代码库。

测试工具

你可能觉得,编写大量测试来确保程序的每个细节都没问题很繁琐。好消息是标准库可助你一臂之力。有两个杰出的模块可替你自动完成测试过程。

  • unittest:一个通用的测试框架。
  • doctest:一个更简单的模块,是为检查文档而设计的,但也非常适合用来编写单元测试。

下面先来看看 doctest,从它开始是个非常不错的选择。

doctest

我发现,在演示工作原理方面,交互式解释器是一种卓有成效的方式;而且很容易对这样的示例进行测试。实际上,交互式会话是一种很有用的文档,可将其放在文档字符串中。例如,假设我编写了一个计算平方的函数,并在其文档字符串中添加示例。

def square(factor):
    """
    计算平方并返回结果
    >>> square(2)
    4
    >>> square(3)
    9
    """
    return factor*factor

如你所见,我还在文档字符串中添加一些文字。这与测试有什么关系呢?假设函数 square 是在模块 my_math(即文件 my_math.py)中定义的,就可在模块末尾添加如下代码:

if __name__ == '__main__':
    import doctest
    my_math = __import__('my_math')
    doctest.testmod(my_math)

添加的代码不多,只是导入模块 doctest 和模块 my_math 本身,在运行模块 doctest 中的函数 testmod(表示对模块进行测试)。这有什么用呢?我们来试一试。

看起来什么都没发生,但这是件好事。函数 doctest.testmod 读取模块中的所有文档字符串,查找看起来像是从交互式解释器中摘取的示例,再检查这些事例是否反映了实际情况。

注意 如果这里编写的是真实函数,我将(或者说应该)根据前面制定的规则先编写文档字符串,再使用 doctest 运行脚本看看是否会失败,然后添加刚好让测试得以通过的代码(如使用测试语句来处理文档字符串中的具体输入),接下来确保实现是正确的。另一方面,如果完全践行“先测试在编码”的编程理念,框架 unittest(将在后面讨论)可能能够更好地满足你的需求。

为了获得更多的输出,可在运行脚本时指定开关-v(verbose,意为详尽)。

如你所见,幕后发生了很多事情。函数 testmod 检查模块的文档字符串(如你所见,其中未包含任何测试)和函数的文档字符串(包含两个测试,它们都成功了)。

有测试在手,就可放心地修改代码了。假设要使用 Python 的幂运算符而不是乘法运算符,即将 factor*factor 替换为 factor**2。你对代码进行编辑,但不小心忘记了把第 2 个 factor 改为 2,结果变成了 factor**factor。请尝试这样做,再运行脚本对代码进行测试,结果如何呢?输出如下:

捕捉到了 bug,并清楚地指出错误出在什么地方。现在修复这个问题应该不难。

警告 不要盲目信任测试,而且务必要测试足够多的情形。如你所见,使用 square(2)的测试没有捕捉到 bug,因为 x == 2 时,x**2 与 x**x 等价!

有关模块 doctest 的详细信息,请参阅“Python 库参考手册”。

unittest

虽然 doctest 使用起来更容易,但 unittest 更灵活,更强大。尽管相比于 doctest,unittest 的学习门槛可能更高,但还是建议你看看这个模块,因为它让你能够以结构化方式编写庞大而详尽的测试集。

这里只进行简要的介绍。unittest 包含的一些功能在大多数测试中都不需要。

提示 标准库包含另外两个有趣的单元测试工具:pytest(pytest.org)和 nose(nose.readthed.ocs.io)。

下面来看一个简单的实例,假设你要编写一个名为 my_math 的模块,其中包含一个计算乘积的函数 product。从哪里着手呢?当然是先使用模块 unittest 中的 TestCase 类编写一个测试(存储在文件 test_my_math.py 中)。

import unittest
import my_math


class ProductTestCase(unittest.TestCase):
    def test_integers(self):
        for x in range(-10, 10):
            for y in range(-10, 10):
                p = my_math.product(x, y)
                self.assertEqual(p, x*y, 'Integer multiplication failed')

    def test_floats(self):
        for x in range(-10, 10):
            for y in range(-10, 10):
                x = x/10
                y = y/10
                p = my_math.product(x, y)
                self.assertEqual(p, x*y, 'Float multiplication failed')


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

函数 unittest.main 负责替你运行测试:实例化所有的 TestCase 子类,并运行所有名称以 test 打头的方法。

提示 如果你重写了方法 setUp 和 tearDown,它们将分别在每个测试方法之前和之后执行。你可使用这些方法来执行适用于所有测试的初始化代码和清理代码,这些代码称为测试夹具(test fixture)。

当然,运行这个测试脚本将引发异常,指出模块 my_math 不存在。诸如 assertEqual 等方法检查指定的条件,以判断指定的测试是成功还是失败了。TestCase 类还包含很多与之类似的方法,如 assertTure、assertIsNotNone 和 assertAlmostEqual。

模块 unittest 区分错误失败。错误指的是引发了异常,而失败是调用 failUnless 等方法的结果。接下来需要编写框架代码,以消除错误,只留下失败。这意味着只需创建包含如下内容的模块 my_math(即文件 my_math.py):

def product(factor1, factor2):
    pass

都是框架代码,没什么意思。如果现在运行前面的测试,将出现两条 FAIL 信息,如下所示:

这完全在意料之中,没什么好担心的。现在你至少知道,测试真的与代码关联起来了——代码不对,因此测试失败。好极了。

接下来需要让代码管用。就这个示例而言,需要做的工作不多:

def product(factor1, factor2):
    return factor1*factor2

现在输出如下:

开头的两个句点表示测试。如果你仔细观察失败时乱七八糟的输出,将发现开头也有两个字符:两个 F,表示两次失败。

出于好玩,请修改函数 product,使其在参数为 7 和 9 时不能通过测试。

def product(factor1, factor2):
    if factor1 == 7and factor2 == 9:
        return 'An insidious bug has surfaced!'
    else:
        return factor1*factor2

如果再次运行前面的测试脚本,将有一个测试失败。

提示 有关更复杂的面向对象代码测试,请参阅模块 unittest.mock。

超越单元测试

测试固然重要,而对于有些复杂的项目来说,测试绝对是生死攸关的。就算你不想编写结构化的单元测试套件,也必须以某种方式运行程序,看看它是否管用。编写大量代码前具备这种能力可在以后避免大量的工作和麻烦。

要探索程序,还有其他一些方式,下面将介绍两个工具:源代码检查和性能分析。源代码检查是一种发现代码中常见错误或问题的方式(有点像静态类型语言中的编译器的作用,但做的事情要多得多)。性能分析指的是搞清楚程序的运行速度到底有多快。之所以按这里的顺序讨论这些主题,是为了遵循“使其管用,使其更好,使其更快”这条古老的规则。单元测试可让程序管用,源代码检查可让程序更好,而性能分析可让程序更快。

使用 PyChecker 和 PyLint 检查源代码

长期以来,PyChecker(pychecker.sf.net)都是用于检查 Python 源代码的唯一工具,能够找出诸如给函数提供的参数不对等的错误。(当然,标准库中还有 tabnanny,但没那么强大,只检查缩进是否正确。)之后出现了 PyLint(pylint.org),它支持 PyChecker 提供的大部分功能,还有很多其他的功能,如变量名是否符合指定的命名约定、你是否遵循了自己的编码标准等。

安装这些工具很容易。很多包管理系统(如 Debian APT 和 Gentoo Portage)都提供了它们,可直接从相应的网站下载。要使用 Distutils 来安装,可使用如下标准命令:

python setup.py install

对于 PyLint,也可使用 pip 来安装。

安装这些工具后,可以命令行脚本方式运行它们(PyChecker 和 PyLint 对应上的脚本分别为 pychecker 和 pylint),也可将其作为 Python 模块(名称与前面相同)。

注意 在 Windows 中,从命令行运行这两个工具时,将分别使用批处理文件 pychecker.bat 和 pylint.bat。因此,你可能需要将这两个文件加入环境变量 PATH 中,这样才能从命令行执行命令 pychecker 和 pylint。

要使用 PyChecker 检查文件,可运行这个脚本并将文件名作为参数,如下所示:

pychecker file1.py file2.py ...

使用 PyLint 检查文件时,需要将模块(或包)名作为参数:

pylint module

要获悉有关这两个工具的详细信息,可使用命令行开关-h 运行它们。运行这两个命令时,输出可能非常多(pylint 的输出通常比 pychecker 的多)。这两个工具都是可高度配置的,你可指定要显示或隐藏哪些类型的警告;有关这方面的详细信息,请参阅相关的文档。

结束对检查器的讨论之前,来看看如何结合使用检查器和单元测试。毕竟,如果能够将它们(或其中之一)作为测试套件中的测试自动运行,并在没有错误时悄无声息的指出测试成功了,那就太好了。这样,测试套件不仅测试了功能,还测试了代码质量。

PyChecker 和 PyLint 都可作为模块(分别是 pychecker.checker 和 pylint.lint)导入,但它们并不是为了以编程方式使用而设计的。导入 pychecker.checker 时,它会检查后续代码(包括导入的模块),并将警告打印到标准输出。模块 pylint.lint 包含一个文档中没有介绍的函数 Run,这个函数是供脚本 pylint 本身使用的。它也将警告打印出来,而不是以某种方式将其返回。我建议不去解决这些问题,就以原本的方式使用 PyChecker 和 PyLint,即将其作为命令行工具使用。在 Python 中,可通过模块 subprocess 来使用命令行工具。下面的代码在前面的测试脚本示例中添加了两个代码检查测试。

import unittest
import my_math
from subprocess import Popen, PIPE


class ProductTestCase(unittest.TestCase):
    # 在这里插入以前的测试
    def test_with_PyChecker(self):
        cmd = 'pychecker', '-Q', my_math.__file__.rstrip('c')
        pychecker = Popen(cmd, stdout=PIPE, stderr=PIPE)
        self.assertEqual(pychecker.stdout.read(), b'')

    def test_with_PyLint(self):
        cmd = 'pylint', '-rn', 'my_math'
        pylint = Popen(cmd, stdout=PIPE, stderr=PIPE)
        self.assertEqual(pylint.stdout.read(), b'')


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

调用检查器脚本时,我指定了一些命令行开关,以免无关的输出干扰测试。对于 pychecker,我制定了开关-Q(quiet,意为静默);对于 pylint,我指定了开关-rn(其中 n 表示 no)以关闭报告,这意味着将只显示警告和错误。

命令 pylint 直接将模块名作为参数,因此执行起来很简单。

为让 pychecker 正确地运行,我们需要获取文件名。为此,我使用了模块 my_math 的属性__file__,并使用 rstrip 将文件名末尾可能包含的 c 删掉(因为模块可能存储在.pyc 文件中)。

为让 PyLint 噤声,我稍微修改了模块 my_math(而不是通过配置,让 PyLint 在面对变量名太短、缺失修订号和文档字符串等情况时一声不吭)。

"""
一个简单的数学模块
"""
__revision__ = '0.1'


def product(factor1, factor2):
    """The product of two numbers"""
    return factor1*factor2

如果现在运行这些测试,将不会出现任何错误。请随意尝试这些代码,看看能否让检查器报告错误,同时确保功能测试依然管用(可以不使用 PyChecker 或 PyLint——使用其中一个可能就足够了)。例如,尝试将参数改成 x 和 y,PyLint 将抗议变量名太短。或者在 return 语句后面添加 print('Hello,world!'),进而两个检查器都将抗议(抗议的理由可能不同),这合情合理。

自动检查的局限性:有结束的时候吗

虽然 PyChecker 和 PyLint 等自动检查器在发现问题方面很出色,但也存在局限性。它们虽然能够发现各种错误,但也存在局限性。它们虽然能够发现各种错误和问题,但并不知道程序的终极目标,因此总是需要量身定制的单元测试。然而,除了这个显而易见的局限外,自动检查器还有一些其他的局限。只要你喜欢有些奇怪的理论,就可能对根据终止定理这一计算理论得出的结论感兴趣。来看一个可以像下面这样运行的虚构的检查程序:

halts.py myprog.py data.txt

你可能猜到了,这个检查器检查程序 myprog.py 将 data.txt 作为输入时的行为。我们只想检查一点:无限循环(或与之等价的情况)。换而言之,程序 halts.py 需要判断 myprog.py 将 data.txt 作为输入时是否会停止(终止)。鉴于市面上的检查程序能够分析代码,并确定各种变量必须是什么类型才能正确运行,检测像无限循环这样的情况不是小菜一碟吗?不是这样的,至少总体而言不是这样的。

别光听我说——推理其实很简单。假设终止检查器 halts 管用;为简单起见,同时假设它是一个 Python 模块。现在,假设我们编写了下面这个暗藏机关的小程序(trouble.py)。

它使用模块 halts 的功能检查通过第一个命令行参数指定程序将自身作为输入时是否会终止。例如,可以像下面这样运行它:

trouble.py myprog.py

这将判断 myprog.py 将 myprog.py(即自身)作为输入时是否会终止。如果结论是会终止,trouble.py 将进入无限循环;否则它将就此结束(即终止)。

现在来看下面的情形:

halts.py trouble.py trouble.py

这里检查 trouble.py 将 trouble.py(即自身)作为输入时是否会终止。这本身不难理解。但结论是什么呢?如果 halts.py 说“会”,即 trouble.py trouble.py 会终止,则根据定义 trouble.py trouble.py 将不会终止。如果说“不会”,也将遇到同样(相悖)的问题。无论 halts.py 怎么说,都注定是错的,并且没法解决这个问题。我们最初假设这个检查器管用,而现在遇到了矛盾,这为意味着最初的假设是错的。

当然,这并不意味着无法检测出任何类型的无限循环(例如,没有 break、raise 或 return 的 while True 循环肯定是无限循环),而只是说无法检测出所有的无限循环。遗憾的是,很多与此类似的情况也无法全部自动分析出来(请参阅 David Harel 的著作 Computers Ltd:What They Really Can't Do,其中包含大量有关这个主题的有趣内容)。因此,即便有 PyChecker 和 PyLint 这样出色的工具,依然需要依赖于手工调试,而这要求我们知道程序的特殊之处。另外,我们可能应该尽力避免 trouble.py 这样暗藏机关的程序。

性能分析

让代码管用,还可能让它比最初更好之后,也许该来让它更快了。然而,或许不该这样做。正如高德纳转述 C.A.R.Hoare 的话时指出的:在编程中,不成熟的优化是万恶之源。不论优化诀窍在巧妙,如果根本用不着,就不用关心了。如果程序的速度已经足够快,代码清晰、简单易懂的价值可能远远胜过细微的速度提升。毕竟几个月后就可能有速度更快的硬件面世。

但如果程序的速度达不到你的要求,必须优化,就必须首先对齐进行性能分析。这是因为除非程序非常简单,否则很难猜到瓶颈在什么地方。如果不知道是什么让程序运行缓慢,优化就可能南辕北辙。

标准库包含一个卓越的性能分析模块 profile,还有一个速度更快的 C 语言版本,名为 cProfile。这个性能分析模块使用起来很简单,只需调用其方法 run 并提供一个字符串参数。

>>> import cProfile

>>> from my_math import product

>>> cProfile.run('product(1, 2)')

这将输出如下信息:各个函数和方法被调用多少次以及执行它们花费了多长时间。如果通过第二个参数向 run 提供了一个文件名(如'my_math.profile'),分析结果将保存到这个文件中。然后,就可使用模块 pstats 来研究分析结果了。

>>> import pstats

>>> pstats.Stats('my_math.profile')

通过使用这个 Stats 对象,可以编程方式研究分析结果。有关这个 API 的详情,请参阅标准库文档。

提示 标准库还包含一个名为 timeit 的模块,提供了一种对一小段 Python 代码的运行时间进行测试的简单方式。在进行详尽的性能分析方面,模块 timeit 的用处不大,但在确定一段代码花了多长时间才执行完毕时,这是一个很不错的工具。手工测量的结果通常不准确(除非你对这方面了如指掌),因此使用 timeit 是更好的选择。

如果你非常在乎程序的速度,可添加一个这样的单元测试:对程序进行性能分析并要求满足特定的要求(如程序执行时间超过 1 秒时,测试就将失败)。这做起来可能很有趣,但不推荐这样做,因为迷恋性能分析很可能让你忽略真正重要的事情,如清晰而易于理解的代码。如果程序速度非常慢,你迟早会发现,因为测试将需要很久才能运行完毕。

小结

  • 测试驱动编程:大致而言,测试驱动编程意味着先测试再编码。有了测试,你就能信心满满地修改代码,这让开发和维护工作更加灵活。
  • 模块 doctest 和 unittest:需要在 Python 中进行单元测试时,这些工具必不可少。模块 doctest 设计用于检查文档字符串中的示例,但也可以轻松地使用它来设计测试套件。为让测试套件更灵活、结构化程度更高,框架 unittest 很有帮助。
  • PyChecker 和 PyLint:这两个工具查看源代码并指出潜在(和实际)的问题。它们检查代码的方方面面——从变量名太短到永远不会执行的代码段。你只需编写少量的代码,就可将它们加入测试套件,从而确保所有的修改和重构都遵循了你采用的编码标准。
  • 性能分析:如果你很在乎速度,并想对程序进行优化(仅当绝对必要时才这样做),应首先进行性能分析:使用模块 profile 或 cProfile 来找出代码中的瓶颈。

原文发布于微信公众号 - 小陈学Python(gh_a29b1ed16571)

原文发表时间:2019-06-08

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券