使用 pytest 进行 Python 测试:Fixture 和覆盖率

进一步改进您的 Python 测试。

在我的前两篇文章中,我介绍了 pytest,一个用于测试 Python 代码的库(参见“使用 Python 的 pytest 测试您的代码”第一部分第二部分)。pytest 已经变得非常流行,这在很大程度上是因为它非常容易编写测试并将这些测试集成到您的软件开发流程中。我已经成为了它的忠实粉丝,主要是因为多年来我一直说我应该在测试我的软件方面做得更好,而 pytest 最终使这成为可能。

因此,在本文中,我将回顾 pytest 的两个我还没有机会介绍的功能:fixture 和代码覆盖率,我希望这将说服您 pytest 值得探索并融入到您的工作中。

Fixture

当您编写测试时,您很少只编写一两个。相反,您将编写一个完整的“测试套件”,其中每个测试都旨在检查代码的不同路径。在许多情况下,这意味着您将有一些具有相似特征的测试,pytest 使用“参数化测试”来处理这种情况。

但在其他情况下,事情会更复杂一些。您希望有一些对象可供您的所有测试使用。这些对象可能包含您想要在测试之间共享的数据,或者它们可能涉及网络或文件系统。这些在测试领域通常被称为“fixture”,它们有各种不同的形式。

在 pytest 中,您可以使用 pytest.fixture 装饰器和函数定义相结合来定义 fixture。例如,假设您有一个文件,它从文件中返回一个行列表,其中每行都是反转的


def reverse_lines(f):
   return [one_line.rstrip()[::-1] + '\n'
           for one_line in f]

请注意,为了避免换行符放置在行首,您需要在反转之前从字符串中删除它,然后在每个返回的字符串中添加一个 '\n'。另请注意,尽管使用生成器表达式而不是列表推导可能是一个好主意,但我在这里试图保持事情相对简单。

如果您要测试此函数,您需要向其传递一个类文件对象。在我的上一篇文章中,我展示了如何为此类事情使用 StringIO 对象,情况仍然如此。但是,您可以创建一个 fixture,而不是在测试文件中定义全局变量,该 fixture 将在正确的时间为您的测试提供适当的对象。

以下是在 pytest 中它的样子


@pytest.fixture
def simple_file():
   return StringIO('\n'.join(['abc', 'def', 'ghi', 'jkl']))

从表面上看,这看起来像一个简单的函数——一个返回您稍后想要使用的值的函数。在许多方面,它类似于您定义一个名为“simple_file”的全局变量所获得的效果。

同时,fixture 的使用方式与全局变量不同。例如,假设您想在一个测试中包含此 fixture。然后,您可以在测试的参数列表中提及它。然后,在测试内部,您可以按名称访问 fixture。例如


def test_reverse_lines(simple_file):
   assert reverse_lines(simple_file) == ['cba\n', 'fed\n',
 ↪'ihg\n', 'lkj\n']

但情况变得更好了。您的 fixture 可能像数据一样工作,您无需使用括号调用它。但它实际上是一个底层函数,这意味着每次您使用该 fixture 调用测试时,它都会执行。这意味着 fixture 与普通的旧数据相比,可以进行计算和决策。

您还可以决定 fixture 的运行频率。例如,按照现在的编写方式,此 fixture 将在每次提及它的测试中运行一次。当您想要与列表或类文件结构进行比较时,这在这种情况下非常棒。但是,如果您想设置一个对象,然后在不再次创建它的情况下多次使用它怎么办?您可以通过设置 fixture 的“作用域”来做到这一点。例如,如果您将 fixture 的作用域设置为“module”,它将在您的所有测试中可用,但只会执行一次。您可以通过将 scope 参数传递给 @pytest.fixture 装饰器来做到这一点


@pytest.fixture(scope='module')
def simple_file():
   return StringIO('\n'.join(['abc', 'def', 'ghi', 'jkl']))

我应该注意,给这个特定的 fixture “module”作用域是一个坏主意,因为第二个测试最终会得到一个 StringIO,其位置指针(使用 file.tell 检查)已经在末尾。

这些 fixture 的工作方式与其他许多测试系统使用的传统 setup/teardown 系统截然不同。但是,pytest 的人员绝对让我相信这是一种更好的方法。

但是等等——也许您可以看到这些 fixture 中“setup”功能存在的位置。那么,“teardown”功能在哪里呢?答案既简单又优雅。如果您的 fixture 使用“yield”而不是“return”,pytest 会理解 yield 之后的代码用于拆卸对象和连接。是的,如果您的 fixture 具有“module”作用域,pytest 将等待作用域中的所有函数执行完毕后再将其拆卸。

覆盖率

这一切都很棒,但是如果您曾经做过任何测试,您就会知道总是存在一个问题,即您对代码的测试有多彻底。毕竟,假设您编写了五个函数,并且您为所有函数编写了测试。您能确定您实际上已经测试了这些函数的所有可能路径吗?

例如,假设您有一个非常奇怪的函数 only_odd_mul,它仅乘以奇数


def only_odd_mul(x, y):
   if x%2 and y%2:
       return x * y
   else:
       raise NoEvenNumbersHereException(f'{x} and/or {y}
 ↪not odd')

这是一个您可以在其上运行的测试


def test_odd_numbers():
   assert only_odd_mul(3, 5) == 15

果然,测试通过了。它工作正常!软件非常棒!

哦,但是等等——正如您可能已经注意到的,这不是一个很好的测试工作。该函数可能会给出完全不同的结果(例如,引发异常),而测试没有检查到。

在这个例子中可能很容易看到这一点,但是当软件变得更大更复杂时,就不那么容易用肉眼观察了。这就是您想要拥有“代码覆盖率”的地方,检查您的测试是否已经运行了所有代码。

现在,100% 的代码覆盖率并不意味着您的代码是完美的或没有错误。但它确实让您对代码以及它至少运行过一次的事实更有信心。

那么,如何在 pytest 中包含代码覆盖率呢?事实证明,PyPI 上有一个名为 pytest-cov 的软件包,您可以下载并安装。完成此操作后,您可以使用 --cov 选项调用 pytest。如果您没有说更多内容,您将获得程序使用的每个 Python 库部分的覆盖率报告,因此我强烈建议您为 --cov 提供一个参数,指定您要测试的程序。并且,您应该指示报告应写入的目录。所以在这种情况下,您会说


pytest --cov=mymul .

完成此操作后,您需要将覆盖率报告转换为人类可读的内容。我建议使用 HTML,尽管还有其他输出格式可用


coverage html

这会创建一个名为 htmlcov 的目录。使用浏览器打开此目录中的 index.html 文件,您将获得一个基于 Web 的报告,其中(红色)显示您的程序仍然缺少覆盖率的位置。果然,在这种情况下,它显示偶数路径未被覆盖。让我们添加一个测试来做到这一点


def test_even_numbers():
   with pytest.raises(NoEvenNumbersHereException):
       only_odd_mul(2,4)

正如预期的那样,覆盖率现在已上升到 100%!这绝对值得欣赏和庆祝,但这并不意味着您已经达到了最佳测试状态。您可以并且应该覆盖不同的参数组合以及传递参数时会发生什么。

总结

如果您没有从我对 pytest 的三部分关注中猜到,我已经被这个测试系统的设计方式所折服。在多年来谈论测试时感到羞愧之后,我已经开始将其融入到我的代码中,包括在我的在线“每周 Python 练习”课程中。如果可以开始进行测试,那么您也可以。尽管我还没有介绍 pytest 提供的所有内容,但您现在应该对它是什么以及如何开始使用它有一个很好的了解。

资源
  • pytest 网站位于 https://pytest.cn
  • 关于该主题的一本优秀书籍是 Brian Okken 的 Python testing with pytest,由 Pragmatic Programmers 出版。他还拥有许多其他关于 pytest 和代码测试的资源,网址为 http://pythontesting.net
  • Brian 关于 pytest 的 fixture 的博客文章内容丰富,对任何想要开始使用它们的人都很有用。

Reuven Lerner 在世界各地的公司教授 Python、数据科学和 Git。您可以订阅他的免费、每周“更好的开发者”电子邮件列表,并从他的书籍和课程中学习,网址为 http://lerner.co.il。Reuven 与他的妻子和孩子住在以色列的莫迪因。

加载 Disqus 评论