PyTest-快速启动指南-全-

PyTest 快速启动指南(全)

原文:zh.annas-archive.org/md5/ef4cd099dd041b2b3c7ad8b8d5fa4114

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自动化测试是开发人员工具中非常重要的工具。拥有一组自动化测试不仅可以提高生产力和软件质量;它还可以作为开发人员的安全网,并在代码重构方面带来信心。Python 自带了一个标准的unittest模块,用于编写自动化测试,但也有一个替代方案:pytest。pytest 框架简单易用,从简单的单元测试一直扩展到复杂的集成测试。许多人认为它在方法上是真正 Pythonic 的,具有简单的功能、简单的断言、固定装置、插件和一整套功能。越来越多的开发人员正在采用全面的测试方法,那么为什么不使用一个既简单又强大的框架,许多人认为它是一种真正的乐趣呢?

这本书适合谁

这本书适合任何希望开始使用 pytest 来提高其日常工作中测试技能的人。它涵盖了从安装 pytest 及其更重要的功能,一直到将现有的基于unittest的测试套件转换为 pytest 的技巧和技巧。还有一些基于作者多年的测试和 pytest 经验的技巧和讨论。在本书中,我们将通过几个代码示例,并且只需要中级水平的 Python,尽管如果您有一些unittest经验,您会更喜欢这本书。

本书涵盖内容

第一章,介绍 pytest,讨论了为什么测试很重要,快速概述了标准的unittest模块,最后介绍了 pytest 的主要特性。

第二章,编写和运行测试,涵盖了 pytest 的安装,pytest 如何仅使用assert语句来检查值,测试模块组织,以及一些非常有用的命令行选项,以提高生产力。

第三章,标记和参数化,解释了 pytest 的标记如何工作,如何根据特定条件跳过测试,并讨论了预期失败和不稳定测试之间的区别(以及如何处理)。最后,我们将学习如何使用parametrize标记将不同的输入集应用于相同的测试代码片段,避免重复,并邀请我们覆盖更多的输入情况。

第四章,固定装置,探讨了 pytest 更受欢迎的功能之一,固定装置。我们还将介绍一些内置的固定装置,最后介绍一些技巧和诀窍,以更充分地利用测试套件中的固定装置。

第五章,插件,介绍了如何在丰富的插件生态系统中安装和搜索有用的插件,还介绍了一系列作者在日常工作中发现有趣和/或必须的各种插件。

第六章,将 unittest 套件转换为 pytest,介绍了一系列技术,将帮助您开始使用 pytest,即使您所有的测试都是使用unittest框架编写的。它涵盖了从无需更改即可运行测试套件,一直到使用经过时间考验的技术将其转换为利用 pytest 功能。

第七章,总结,介绍了如果您想巩固 pytest 技能的可能下一步。我们还将看看友好的 pytest 社区以及如何更多地参与其中。

要充分利用本书

以下是您开始所需的简短清单:

  • 台式电脑或笔记本电脑:pytest 适用于 Linux、Windows 和 macOS-X,因此选择您喜欢的任何系统。

  • Python 3:所有示例都是用 Python 3.6 编写的,但它们应该可以在 Python 3.4 或更高版本上使用,如果有的话,可以进行轻微修改。大多数示例也可以移植到 Python 2,但需要更多的努力,但强烈建议使用 Python 3。

  • 您喜欢的文本编辑器或 IDE 来处理代码。

  • 熟悉 Python:不需要太高级的知识,但是 Python 概念,如with语句和装饰器是很重要的。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/pytest-Quick-Start-Guide。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自丰富图书和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本中的代码单词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。这是一个例子:“在命令提示符中键入此命令以创建virtualenv。”

代码块设置如下:

 # contents of test_player_mechanics.py
    def test_player_hit():
        player = create_player()
        assert player.health == 100
        undead = create_undead()
        undead.hit(player)
        assert player.health == 80

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

def test_empty_name():
    with pytest.raises(InvalidCharacterNameError):
        create_character(name='', class_name='warrior')

def test_invalid_class_name():
    with pytest.raises(InvalidClassNameError):
        create_character(name='Solaire', class_name='mage')

任何命令行输入或输出都以以下方式编写:

λ pip install pytest

第一章:编写和运行测试

在上一章中,我们讨论了为什么测试如此重要,并简要概述了unittest模块。我们还粗略地看了 pytest 的特性,但几乎没有尝试过。

在本章中,我们将开始使用 pytest。我们将实用主义,这意味着我们不会详尽地研究 pytest 的所有可能功能,而是为您提供快速概述基础知识,以便您能够迅速提高生产力。我们将看看如何编写测试,如何将它们组织到文件和目录中,以及如何有效地使用 pytest 的命令行。

本章涵盖以下内容:

  • 安装 pytest

  • 编写和运行测试

  • 组织文件和包

  • 有用的命令行选项

  • 配置:pytest.ini文件

在本章中,有很多示例在命令行中输入。它们由λ字符标记。为了避免混乱并专注于重要部分,将抑制 pytest 标题(通常显示 pytest 版本、Python 版本、已安装插件等)。

让我们直接进入如何安装 pytest。

安装 pytest

安装 pytest 非常简单,但首先让我们花点时间回顾 Python 开发的良好实践。

所有示例都是针对 Python 3 的。如果需要,它们应该很容易适应 Python 2。

pip 和 virtualenv

安装依赖项的推荐做法是创建一个virtualenvvirtualenvpackaging.python.org/guides/installing-using-pip-and-virtualenv/)就像是一个完全独立的 Python 安装,不同于操作系统自带的 Python,因此可以安全地安装应用程序所需的软件包,而不会破坏系统 Python 或工具。

现在我们将学习如何创建虚拟环境并使用 pip 安装 pytest。如果您已经熟悉virtualenv和 pip,可以跳过本节:

  1. 在命令提示符中键入以下内容以创建virtualenv
λ python -m venv .env
  1. 此命令将在当前目录中创建一个.env文件夹,其中包含完整的 Python 安装。在继续之前,您应该activate virtualenv
λ source .env/bin/activate

或者在 Windows 上:

λ .env\Scripts\activate

这将把virtualenv Python 放在$PATH环境变量的前面,因此 Python、pip 和其他工具将从virtualenv而不是系统中执行。

  1. 最后,要安装 pytest,请键入:
λ pip install pytest

您可以通过键入以下内容来验证一切是否顺利进行:

λ pytest --version
This is pytest version 3.5.1, imported from x:\fibo\.env36\lib\site-packages\pytest.py

现在,我们已经准备好可以开始了!

编写和运行测试

使用 pytest,您只需要创建一个名为test_*.py的新文件,并编写以test开头的测试函数:

    # contents of test_player_mechanics.py
    def test_player_hit():
        player = create_player()
        assert player.health == 100
        undead = create_undead()
        undead.hit(player)
        assert player.health == 80

要执行此测试,只需执行pytest,并传递文件名:

λ pytest test_player_mechanics.py

如果您不传递任何内容,pytest 将递归查找当前目录中的所有测试文件并自动执行它们。

您可能会在互联网上遇到使用命令行中的py.test而不是pytest的示例。原因是历史性的:pytest 曾经是py包的一部分,该包提供了几个通用工具,包括遵循以py.<TAB>开头的约定的工具,用于制表符补全,但自那时起,它已经被移动到自己的项目中。旧的py.test命令仍然可用,并且是pytest的别名,但后者是推荐的现代用法。

请注意,无需创建类;只需简单的函数和简单的assert语句就足够了,但如果要使用类来分组测试,也可以这样做:

    class TestMechanics:

        def test_player_hit(self):
            ...

        def test_player_health_flask(self):
            ...

当您想要将多个测试放在同一范围下时,分组测试可能很有用:您可以根据它们所在的类执行测试,对类中的所有测试应用标记(第三章,标记和参数化),并创建绑定到类的固定装置(第四章,固定装置)。

运行测试

Pytest 可以以多种方式运行您的测试。现在让我们快速了解基础知识,然后在本章后面,我们将转向更高级的选项。

您可以从简单地执行pytest命令开始:

λ pytest

这将递归地查找当前目录和以下所有test_*.py*_test.py模块,并运行这些文件中找到的所有测试:

  • 您可以将搜索范围缩小到特定目录:
 λ pytest tests/core tests/contrib
  • 您还可以混合任意数量的文件和目录:
 λ pytest tests/core tests/contrib/test_text_plugin.py
  • 您可以使用语法<test-file>::<test-function-name>执行特定的测试:
 λ pytest tests/core/test_core.py::test_regex_matching
  • 您可以执行test类的所有test方法:
 λ pytest tests/contrib/test_text_plugin.py::TestPluginHooks
  • 您可以使用语法<test-file>::<test-class>::<test-method-name>执行test类的特定test方法:
 λ pytest tests/contrib/
      test_text_plugin.py::TestPluginHooks::test_registration

上面使用的语法是 pytest 内部创建的,对于每个收集的测试都是唯一的,并称为“节点 ID”或“项目 ID”。它基本上由测试模块的文件名,类和函数通过::字符连接在一起。

pytest 将显示更详细的输出,其中包括节点 ID,使用-v标志:

 λ pytest tests/core -v
======================== test session starts ========================
...
collected 6 items

tests\core\test_core.py::test_regex_matching PASSED            [ 16%]
tests\core\test_core.py::test_check_options FAILED             [ 33%]
tests\core\test_core.py::test_type_checking FAILED             [ 50%]
tests\core\test_parser.py::test_parse_expr PASSED              [ 66%]
tests\core\test_parser.py::test_parse_num PASSED               [ 83%]
tests\core\test_parser.py::test_parse_add PASSED               [100%]

要查看有哪些测试而不运行它们,请使用--collect-only标志:

λ pytest tests/core --collect-only
======================== test session starts ========================
...
collected 6 items
<Module 'tests/core/test_core.py'>
 <Function 'test_regex_matching'>
 <Function 'test_check_options'>
 <Function 'test_type_checking'>
<Module 'tests/core/test_parser.py'>
 <Function 'test_parse_expr'>
 <Function 'test_parse_num'>
 <Function 'test_parse_add'>

=================== no tests ran in 0.01 seconds ====================

如果您想要执行特定测试但无法记住其确切名称,则--collect-only特别有用。

强大的断言

您可能已经注意到,pytest 利用内置的assert语句来检查测试期间的假设。与其他框架相反,您不需要记住各种self.assert*self.expect*函数。虽然一开始可能看起来不是很重要,但在使用普通断言一段时间后,您会意识到这使得编写测试更加愉快和自然。

再次,这是一个失败的示例:

________________________ test_default_health ________________________

    def test_default_health():
        health = get_default_health('warrior')
>       assert health == 95
E       assert 80 == 95

tests\test_assert_demo.py:25: AssertionError

pytest 显示了失败的行,以及涉及失败的变量和表达式。单独来看,这已经相当酷了,但 pytest 进一步提供了有关涉及其他数据类型的失败的专门解释。

文本差异

当显示短字符串的解释时,pytest 使用简单的差异方法:

_____________________ test_default_player_class _____________________

    def test_default_player_class():
        x = get_default_player_class()
>       assert x == 'sorcerer'
E       AssertionError: assert 'warrior' == 'sorcerer'
E         - warrior
E         + sorcerer

较长的字符串显示更智能的增量,使用difflib.ndiff快速发现差异:

__________________ test_warrior_short_description ___________________

    def test_warrior_short_description():
        desc = get_short_class_description('warrior')
>       assert desc == 'A battle-hardened veteran, can equip heavy armor and weapons.'
E       AssertionError: assert 'A battle-har... and weapons.' == 'A battle-hard... and weapons.'
E         - A battle-hardened veteran, favors heavy armor and weapons.
E         ?                            ^ ^^^^
E         + A battle-hardened veteran, can equip heavy armor and weapons.
E         ?                            ^ ^^^^^^^

多行字符串也会被特殊处理:


    def test_warrior_long_description():
        desc = get_long_class_description('warrior')
>       assert desc == textwrap.dedent('''\
            A seasoned veteran of many battles. Strength and Dexterity
            allow to yield heavy armor and weapons, as well as carry
            more equipment. Weak in magic.
            ''')
E       AssertionError: assert 'A seasoned v... \n' == 'A seasoned ve... \n'
E         - A seasoned veteran of many battles. High Strength and Dexterity
E         ?                                     -----
E         + A seasoned veteran of many battles. Strength and Dexterity
E           allow to yield heavy armor and weapons, as well as carry
E         - more equipment while keeping a light roll. Weak in magic.
E         ?               ---------------------------
E         + more equipment. Weak in magic. 

列表

列表的断言失败也默认只显示不同的项目:

____________________ test_get_starting_equiment _____________________

    def test_get_starting_equiment():
        expected = ['long sword', 'shield']
>       assert get_starting_equipment('warrior') == expected
E       AssertionError: assert ['long sword'...et', 'shield'] == ['long sword', 'shield']
E         At index 1 diff: 'warrior set' != 'shield'
E         Left contains more items, first extra item: 'shield'
E         Use -v to get the full diff

tests\test_assert_demo.py:71: AssertionError

请注意,pytest 显示了哪个索引不同,并且-v标志可用于显示列表之间的完整差异:

____________________ test_get_starting_equiment _____________________

    def test_get_starting_equiment():
        expected = ['long sword', 'shield']
>       assert get_starting_equipment('warrior') == expected
E       AssertionError: assert ['long sword'...et', 'shield'] == ['long sword', 'shield']
E         At index 1 diff: 'warrior set' != 'shield'
E         Left contains more items, first extra item: 'shield'
E         Full diff:
E         - ['long sword', 'warrior set', 'shield']
E         ?               ---------------
E         + ['long sword', 'shield']

tests\test_assert_demo.py:71: AssertionError

如果差异太大,pytest 足够聪明,只显示一部分以避免显示太多输出,显示以下消息:

E         ...Full output truncated (100 lines hidden), use '-vv' to show

字典和集合

字典可能是 Python 中最常用的数据结构之一,因此 pytest 为其提供了专门的表示:

_______________________ test_starting_health ________________________

    def test_starting_health():
        expected = {'warrior': 85, 'sorcerer': 50}
>       assert get_classes_starting_health() == expected
E       AssertionError: assert {'knight': 95...'warrior': 85} == {'sorcerer': 50, 'warrior': 85}
E         Omitting 1 identical items, use -vv to show
E         Differing items:
E         {'sorcerer': 55} != {'sorcerer': 50}
E         Left contains more items:
E         {'knight': 95}
E         Use -v to get the full diff

集合也具有类似的输出:

________________________ test_player_classes ________________________

    def test_player_classes():
>       assert get_player_classes() == {'warrior', 'sorcerer'}
E       AssertionError: assert {'knight', 's...r', 'warrior'} == {'sorcerer', 'warrior'}
E         Extra items in the left set:
E         'knight'
E         Use -v to get the full diff

与列表一样,还有-v-vv选项以显示更详细的输出。

pytest 是如何做到的?

默认情况下,Python 的 assert 语句在失败时不提供任何详细信息,但正如我们刚才看到的,pytest 显示了有关失败断言中涉及的变量和表达式的大量信息。那么 pytest 是如何做到的呢?

pytest 能够提供有用的异常,因为它实现了一种称为“断言重写”的机制。

断言重写通过安装自定义导入钩子来拦截标准 Python 导入机制。当 pytest 检测到即将导入测试文件(或插件)时,它首先将源代码编译成抽象语法树AST),使用内置的ast模块。然后,它搜索任何assert语句并重写它们,以便保留表达式中使用的变量,以便在断言失败时显示更有帮助的消息。最后,它将重写后的pyc文件保存到磁盘进行缓存。

这一切可能看起来非常神奇,但实际上这个过程是简单的、确定性的,而且最重要的是完全透明的。

如果您想了解更多细节,请参考pybites.blogspot.com.br/2011/07/behind-scenes-of-pytests-new-assertion.html,由此功能的原始开发者 Benjamin Peterson 编写。pytest-ast-back-to-python插件会准确显示重写过程后测试文件的 AST 是什么样子的。请参阅:github.com/tomviner/pytest-ast-back-to-python

检查异常:pytest.raises

良好的 API 文档将清楚地解释每个函数的目的、参数和返回值。优秀的 API 文档还清楚地解释了在何时引发异常。

因此,测试异常在适当情况下引发的情况,和测试 API 的主要功能一样重要。还要确保异常包含适当和清晰的消息,以帮助用户理解问题。

假设我们正在为一个游戏编写 API。这个 API 允许程序员编写mods,这是一种插件,可以改变游戏的多个方面,从新的纹理到完全新的故事情节和角色类型。

这个 API 有一个函数,允许模块编写者创建一个新的角色,并且在某些情况下可能会引发异常:

def create_character(name: str, class_name: str) -> Character:
    """
    Creates a new character and inserts it into the database.

    :raise InvalidCharacterNameError:
        if the character name is empty.

    :raise InvalidClassNameError:
        if the class name is invalid.

    :return: the newly created Character.
    """
    ...

Pytest 使得检查代码是否使用raises语句引发了适当的异常变得容易:

def test_empty_name():
    with pytest.raises(InvalidCharacterNameError):
        create_character(name='', class_name='warrior')

def test_invalid_class_name():
    with pytest.raises(InvalidClassNameError):
        create_character(name='Solaire', class_name='mage')

pytest.raises是一个 with 语句,它确保传递给它的异常类将在其执行块内被触发。更多细节请参阅(docs.python.org/3/reference/compound_stmts.html#the-with-statement)。让我们看看create_character如何实现这些检查:

def create_character(name: str, class_name: str) -> Character:
    """
    Creates a new character and inserts it into the database.
    ...
    """
    if not name:
        raise InvalidCharacterNameError('character name empty')

    if class_name not in VALID_CLASSES:
        msg = f'invalid class name: "{class_name}"'
        raise InvalidCharacterNameError(msg)
    ...

如果您仔细观察,您可能会注意到前面代码中的复制粘贴错误实际上应该为类名检查引发一个InvalidClassNameError

执行此文件:

======================== test session starts ========================
...
collected 2 items

tests\test_checks.py .F                                        [100%]

============================= FAILURES ==============================
______________________ test_invalid_class_name ______________________

 def test_invalid_class_name():
 with pytest.raises(InvalidCharacterNameError):
>           create_character(name='Solaire', class_name='mage')

tests\test_checks.py:51:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

name = 'Solaire', class_name = 'mage'

 def create_character(name: str, class_name: str) -> Character:
 """
 Creates a new character and inserts it into the database.

 :param name: the character name.

 :param class_name: the character class name.

 :raise InvalidCharacterNameError:
 if the character name is empty.

 :raise InvalidClassNameError:
 if the class name is invalid.

 :return: the newly created Character.
 """
 if not name:
 raise InvalidCharacterNameError('character name empty')

 if class_name not in VALID_CLASSES:
 msg = f'invalid class name: "{class_name}"'
>           raise InvalidClassNameError(msg)
E           test_checks.InvalidClassNameError: invalid class name: "mage"

tests\test_checks.py:40: InvalidClassNameError
================ 1 failed, 1 passed in 0.05 seconds =================

test_empty_name按预期通过。test_invalid_class_name引发了InvalidClassNameError,因此异常未被pytest.raises捕获,这导致测试失败(就像任何其他异常一样)。

检查异常消息

正如本节开头所述,API 应该在引发异常时提供清晰的消息。在前面的例子中,我们只验证了代码是否引发了适当的异常类型,但没有验证实际消息。

pytest.raises可以接收一个可选的match参数,这是一个正则表达式字符串,将与异常消息匹配,以及检查异常类型。更多细节,请访问:docs.python.org/3/howto/regex.html。我们可以使用它来进一步改进我们的测试:

def test_empty_name():
    with pytest.raises(InvalidCharacterNameError,
                       match='character name empty'):
        create_character(name='', class_name='warrior')

def test_invalid_class_name():
    with pytest.raises(InvalidClassNameError,
                       match='invalid class name: "mage"'):
        create_character(name='Solaire', class_name='mage')

简单!

检查警告:pytest.warns

API 也在不断发展。为旧功能提供新的更好的替代方案,删除参数,旧的使用某个功能的方式演变为更好的方式,等等。

API 编写者必须在保持旧代码正常工作以避免破坏客户端和提供更好的方法之间取得平衡,同时保持自己的 API 代码可维护。因此,通常采用的解决方案是开始在 API 客户端使用旧行为时发出warnings,希望他们更新其代码以适应新的结构。警告消息显示在当前用法不足以引发异常的情况下,只是发生了新的更好的方法。通常,在此更新期间会显示警告消息,之后旧的方式将不再受支持。

Python 提供了标准的 warnings 模块,专门用于此目的,可以轻松地警告开发人员关于 API 中即将发生的更改。有关更多详细信息,请访问:docs.python.org/3/library/warnings.html。它让您可以从多个警告类中进行选择,例如:

  • UserWarning: 用户警告(这里的“用户”指的是开发人员,而不是软件用户)

  • DeprecationWarning: features that will be removed in the future

  • ResourcesWarning: 与资源使用相关

(此列表不是详尽无遗的。请查阅警告文档以获取完整列表。有关更多详细信息,请访问:docs.python.org/3/library/warnings.html)。

警告类帮助用户控制应该显示哪些警告,哪些应该被抑制。

例如,假设一个电脑游戏的 API 提供了这个方便的函数,可以根据玩家角色的类名获取起始生命值:

def get_initial_hit_points(player_class: str) -> int:
    ...

时间在流逝,开发人员决定在下一个版本中使用enum而不是类名。有关更多详细信息,请访问:docs.python.org/3/library/enum.html,这更适合表示有限的一组值:

class PlayerClass(Enum):
    WARRIOR = 1
    KNIGHT = 2
    SORCERER = 3
    CLERIC = 4

但是突然更改这一点会破坏所有客户端,因此他们明智地决定在下一个版本中支持这两种形式:strPlayerClass enum。他们不想永远支持这一点,因此他们开始在将类作为str传递时显示警告:

def get_initial_hit_points(player_class: Union[PlayerClass, str]) -> int:
    if isinstance(player_class, str):
        msg = 'Using player_class as str has been deprecated' \
              'and will be removed in the future'
        warnings.warn(DeprecationWarning(msg))
        player_class = get_player_enum_from_string(player_class)
    ...

与上一节的pytest.raises类似,pytest.warns函数让您测试 API 代码是否产生了您期望的警告:

def test_get_initial_hit_points_warning():
    with pytest.warns(DeprecationWarning):
        get_initial_hit_points('warrior')

pytest.raises一样,pytest.warns可以接收一个可选的match参数,这是一个正则表达式字符串。将与异常消息匹配:有关更多详细信息,请访问:docs.python.org/3/howto/regex.html

def test_get_initial_hit_points_warning():
    with pytest.warns(DeprecationWarning,
                      match='.*str has been deprecated.*'):
        get_initial_hit_points('warrior')

比较浮点数:pytest.approx

比较浮点数可能会很棘手。有关更多详细信息,请访问:docs.python.org/3/tutorial/floatingpoint.html。在现实世界中我们认为相等的数字,在计算机硬件表示时并非如此:

>>> 0.1 + 0.2 == 0.3
False

在编写测试时,很常见的是将我们的代码产生的结果与我们期望的浮点值进行比较。如上所示,简单的==比较通常是不够的。一个常见的方法是使用已知的公差,然后使用abs来正确处理负数:

def test_simple_math():
    assert abs(0.1 + 0.2) - 0.3 < 0.0001

但是,除了难看和难以理解之外,有时很难找到适用于大多数情况的公差。所选的0.0001的公差可能适用于上面的数字,但对于非常大的数字或非常小的数字则不适用。根据所执行的计算,您需要为每组输入数字找到一个合适的公差,这是繁琐且容易出错的。

pytest.approx通过自动选择适用于表达式中涉及的值的公差来解决这个问题,还提供了非常好的语法:

def test_approx_simple():
    assert 0.1 + 0.2 == approx(0.3)

您可以将上述内容理解为断言 0.1 + 0.2 大约等于 0.3

但是approx函数并不止于此;它可以用于比较:

  • 数字序列:
      def test_approx_list():
          assert [0.1 + 1.2, 0.2 + 0.8] == approx([1.3, 1.0])
  • 字典values(而不是键):
      def test_approx_dict():
          values = {'v1': 0.1 + 1.2, 'v2': 0.2 + 0.8}
          assert values == approx(dict(v1=1.3, v2=1.0))
  • numpy数组:
      def test_approx_numpy():
          import numpy as np
          values = np.array([0.1, 0.2]) + np.array([1.2, 0.8])
          assert values == approx(np.array([1.3, 1.0]))

当测试失败时,approx提供了一个很好的错误消息,显示了失败的值和使用的公差:

    def test_approx_simple_fail():
>       assert 0.1 + 0.2 == approx(0.35)
E       assert (0.1 + 0.2) == 0.35 ± 3.5e-07
E        + where 0.35 ± 3.5e-07 = approx(0.35)

组织文件和包

Pytest 需要导入您的代码和测试模块,您可以自行决定如何组织它们。Pytest 支持两种常见的测试布局,我们将在下面讨论。

伴随您的代码的测试

您可以通过在模块旁边创建一个tests文件夹,将测试模块放在它们测试的代码旁边:

setup.py
mylib/
    tests/
         __init__.py
         test_core.py
         test_utils.py    
    __init__.py
    core.py
    utils.py

通过将测试放在测试代码附近,您将获得以下优势:

  • 在这种层次结构中更容易添加新的测试和测试模块,并保持它们同步

  • 您的测试现在是您包的一部分,因此它们可以在其他环境中部署和运行

这种方法的主要缺点是,有些人不喜欢额外模块增加的包大小,这些模块现在与其余代码一起打包,但这通常是微不足道的,不值一提。

作为额外的好处,您可以使用--pyargs选项来指定使用模块导入路径的测试。例如:

λ pytest --pyargs mylib.tests

这将执行在mylib.tests下找到的所有测试模块。

您可能考虑使用_tests而不是_test作为测试模块名称。这样可以更容易找到目录,因为前导下划线通常会使它们出现在文件夹层次结构的顶部。当然,随意使用tests或任何其他您喜欢的名称;pytest 不在乎,只要测试模块本身的名称为test_*.py*_test.py

测试与代码分离

与上述方法的替代方法是将测试组织在与主包不同的目录中:

setup.py
mylib/  
    __init__.py
    core.py
    utils.py
tests/
    __init__.py
    test_core.py
    test_utils.py 

有些人更喜欢这种布局,因为:

  • 它将库代码和测试代码分开

  • 测试代码不包含在源包中

上述方法的一个缺点是,一旦您有一个更复杂的层次结构,您可能希望保持测试目录内部的相同层次结构,这可能更难维护和保持同步:

mylib/  
    __init__.py
    core/
        __init__.py
        foundation.py
    contrib/
        __init__.py
        text_plugin.py
tests/
    __init__.py
    core/
        __init__.py
        test_foundation.py
    contrib/
        __init__.py
        test_text_plugin.py

那么,哪种布局最好呢?两种布局都有优点和缺点。Pytest 本身可以很好地与它们中的任何一个一起使用,所以请随意选择您更舒适的布局。

有用的命令行选项

现在我们将看一下命令行选项,这些选项将使您在日常工作中更加高效。正如本章开头所述,这不是所有命令行功能的完整列表;只是您将使用(并喜爱)最多的那些。

关键字表达式:-k

通常情况下,您可能不完全记得要执行的测试的完整路径或名称。在其他时候,您的套件中的许多测试遵循相似的模式,您希望执行所有这些测试,因为您刚刚重构了代码的一个敏感区域。

通过使用-k <EXPRESSION>标志(来自关键字表达式),您可以运行item id与给定表达式松散匹配的测试:

λ pytest -k "test_parse"

这将执行所有包含其项目 ID 中包含字符串parse的测试。您还可以使用布尔运算符编写简单的 Python 表达式:

λ pytest -k "parse and not num"

这将执行所有包含parse但不包含num的测试。

尽快停止:-x,--maxfail

在进行大规模重构时,您可能事先不知道如何或哪些测试会受到影响。在这种情况下,您可能会尝试猜测哪些模块会受到影响,并开始运行这些模块的测试。但是,通常情况下,您会发现自己破坏了比最初估计的更多的测试,并迅速尝试通过按下CTRL+C来停止测试会话,当一切开始意外地失败时。

在这些情况下,您可以尝试使用--maxfail=N命令行标志,该标志在N次失败或错误后自动停止测试会话,或者快捷方式-x,它等于--maxfail=1

λ pytest tests/core -x

这使您可以快速查看第一个失败的测试并处理失败。修复失败原因后,您可以继续使用-x来处理下一个问题。

如果您觉得这很棒,您不会想跳过下一节!

上次失败,首先失败:--lf,--ff

Pytest 始终记住以前会话中失败的测试,并可以重用该信息以直接跳转到以前失败的测试。如果您在大规模重构后逐步修复测试套件,这是一个好消息,如前一节所述。

您可以通过传递--lf标志(意思是上次失败)来运行以前失败的测试:

λ pytest --lf tests/core
...
collected 6 items / 4 deselected
run-last-failure: rerun previous 2 failures

当与-x--maxfail=1)一起使用时,这两个标志是重构的天堂:

λ pytest -x --lf 

这样你就可以开始执行完整的测试套件,然后 pytest 在第一个失败的测试停止。你修复代码,然后再次执行相同的命令行。Pytest 会直接从失败的测试开始,如果通过(或者如果你还没有成功修复代码,则再次停止)。然后它会在下一个失败处停止。反复进行,直到所有测试再次通过。

请记住,无论您在重构过程中执行了另一个测试子集,pytest 始终会记住哪些测试失败了,而不管执行的命令行是什么。

如果您曾经进行过大规模重构,并且必须跟踪哪些测试失败,以便不会浪费时间一遍又一遍地运行测试套件,那么您肯定会欣赏这种提高生产力的方式。

最后,--ff标志类似于--lf,但它将重新排序您的测试,以便首先运行以前失败的测试,然后是通过的测试或尚未运行的测试:

λ pytest -x --lf
======================== test session starts ========================
...
collected 6 items
run-last-failure: rerun previous 2 failures first

输出捕获:-s 和--capture

有时,开发人员会错误地留下print语句,甚至故意留下以供以后调试使用。有些应用程序也可能会在正常操作或日志记录的过程中写入stdoutstderr

所有这些输出会使理解测试套件的显示变得更加困难。因此,默认情况下,pytest 会自动捕获写入stdoutstderr的所有输出。

考虑这个函数来计算给定文本的哈希值,其中留下了一些调试代码:

import hashlib

def commit_hash(contents):
    size = len(contents)
    print('content size', size)
    hash_contents = str(size) + '\0' + contents
    result = hashlib.sha1(hash_contents.encode('UTF-8')).hexdigest()
    print(result)
    return result[:8]

我们对此有一个非常简单的测试:

def test_commit_hash():
    contents = 'some text contents for commit'
    assert commit_hash(contents) == '0cf85793'

在执行此测试时,默认情况下,您将看不到print调用的输出:

λ pytest tests\test_digest.py
======================== test session starts ========================
...

tests\test_digest.py .                                         [100%]

===================== 1 passed in 0.03 seconds ======================

这很干净。

但这些打印语句是为了帮助您理解和调试代码,这就是为什么 pytest 会在测试失败时显示捕获的输出。

让我们更改哈希文本的内容,但不更改哈希本身。现在,pytest 将在错误回溯后的单独部分显示捕获的输出:

λ pytest tests\test_digest.py
======================== test session starts ========================
...

tests\test_digest.py F                                         [100%]

============================= FAILURES ==============================
_________________________ test_commit_hash __________________________

 def test_commit_hash():
 contents = 'a new text emerges!'
>       assert commit_hash(contents) == '0cf85793'
E       AssertionError: assert '383aa486' == '0cf85793'
E         - 383aa486
E         + 0cf85793

tests\test_digest.py:15: AssertionError
----------------------- Captured stdout call ------------------------
content size 19
383aa48666ab84296a573d1f798fff3b0b176ae8
===================== 1 failed in 0.05 seconds ======================

在本地运行测试时,显示失败测试的捕获输出非常方便,甚至在 CI 上运行测试时也是如此。

使用-s 禁用捕获

在本地运行测试时,您可能希望禁用输出捕获,以查看实时打印的消息,或者捕获是否干扰了代码可能正在进行的其他捕获。

在这些情况下,只需向 pytest 传递-s以完全禁用捕获:

λ pytest tests\test_digest.py -s
======================== test session starts ========================
...

tests\test_digest.py content size 29
0cf857938e0b4a1b3fdd41d424ae97d0caeab166
.

===================== 1 passed in 0.02 seconds ======================

使用--capture 捕获方法

Pytest 有两种捕获输出的方法。可以使用--capture命令行标志选择使用哪种方法:

  • --capture=fd:在文件描述符级别捕获输出,这意味着所有写入文件描述符 1(stdout)和 2(stderr)的输出都会被捕获。这将捕获来自 C 扩展的输出,这也是默认值。

  • --capture=sys:捕获直接写入sys.stdoutsys.stderr的输出,而不尝试捕获系统级文件描述符。

通常情况下,您不需要更改这个,但在一些特殊情况下,根据您的代码正在执行的操作,更改捕获方法可能会有用。

为了完整起见,还有--capture=no,它与-s相同。

回溯模式和本地变量:--tb,--showlocals

Pytest 将显示失败测试的完整回溯,这是测试框架所期望的。但是,默认情况下,它不会显示大多数 Python 程序员习惯的标准回溯;它显示了不同的回溯:

============================= FAILURES ==============================
_______________________ test_read_properties ________________________

 def test_read_properties():
 lines = DATA.strip().splitlines()
> grids = list(iter_grids_from_csv(lines))

tests\test_read_properties.py:32:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests\test_read_properties.py:27: in iter_grids_from_csv
 yield parse_grid_data(fields)
tests\test_read_properties.py:21: in parse_grid_data
 active_cells=convert_size(fields[2]),
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

s = 'NULL'

 def convert_size(s):
> return int(s)
E ValueError: invalid literal for int() with base 10: 'NULL'

tests\test_read_properties.py:14: ValueError
===================== 1 failed in 0.05 seconds ======================

这种回溯仅显示回溯堆栈中所有帧的单行代码和文件位置,除了第一个和最后一个,其中还显示了一部分代码(加粗)。

虽然一开始有些人可能会觉得奇怪,但一旦你习惯了,你就会意识到它使查找错误原因变得更简单。通过查看回溯的起始和结束周围的代码,通常可以更好地理解错误。我建议您尝试在几周内习惯 pytest 提供的默认回溯;我相信您会喜欢它,永远不会回头。

然而,如果您不喜欢 pytest 的默认回溯,还有其他回溯模式,由--tb标志控制。默认值是--tb=auto,如前所示。让我们在下一节概览其他模式。

--tb=long

这种模式将显示失败回溯的所有帧代码部分,使其相当冗长。

============================= FAILURES ==============================
_______________________ t________

 def test_read_properties():
 lines = DATA.strip().splitlines()
>       grids = list(iter_grids_from_csv(lines))

tests\test_read_properties.py:32:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

lines = ['Main Grid,48,44', '2nd Grid,24,21', '3rd Grid,24,null']

 def iter_grids_from_csv(lines):
 for fields in csv.reader(lines):
>       yield parse_grid_data(fields)

tests\test_read_properties.py:27:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

fields = ['3rd Grid', '24', 'null']

 def parse_grid_data(fields):
 return GridData(
 name=str(fields[0]),
 total_cells=convert_size(fields[1]),
>       active_cells=convert_size(fields[2]),
 )

tests\test_read_properties.py:21:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

s = 'null'

 def convert_size(s):
>       return int(s)
E       ValueError: invalid literal for int() with base 10: 'null'

tests\test_read_properties.py:14: ValueError
===================== 1 failed in 0.05 seconds ======================

--tb=short

这种模式将显示失败回溯的所有帧的代码的一行,提供简短而简洁的输出:

============================= FAILURES ==============================
_______________________ test_read_properties ________________________
tests\test_read_properties.py:32: in test_read_properties
 grids = list(iter_grids_from_csv(lines))
tests\test_read_properties.py:27: in iter_grids_from_csv
 yield parse_grid_data(fields)
tests\test_read_properties.py:21: in parse_grid_data
 active_cells=convert_size(fields[2]),
tests\test_read_properties.py:14: in convert_size
 return int(s)
E   ValueError: invalid literal for int() with base 10: 'null'
===================== 1 failed in 0.04 seconds ======================

--tb=native

这种模式通常会输出 Python 用于报告异常的完全相同的回溯,受到纯粹主义者的喜爱:

_______________________ test_read_properties ________________________
Traceback (most recent call last):
 File "X:\CH2\tests\test_read_properties.py", line 32, in test_read_properties
 grids = list(iter_grids_from_csv(lines))
 File "X:\CH2\tests\test_read_properties.py", line 27, in iter_grids_from_csv
 yield parse_grid_data(fields)
 File "X:\CH2\tests\test_read_properties.py", line 21, in parse_grid_data
 active_cells=convert_size(fields[2]),
 File "X:\CH2\tests\test_read_properties.py", line 14, in convert_size
 return int(s)
ValueError: invalid literal for int() with base 10: 'null'
===================== 1 failed in 0.03 seconds ======================

--tb=line

这种模式将为每个失败的测试显示一行,仅显示异常消息和错误的文件位置:

============================= FAILURES ==============================
X:\CH2\tests\test_read_properties.py:14: ValueError: invalid literal for int() with base 10: 'null'

如果您正在进行大规模重构并且预计会有大量失败,之后打算使用--lf -x标志进入重构天堂模式,则此模式可能会有用。

--tb=no

这不会显示任何回溯或失败消息,因此在运行套件以获取有多少失败的概念后,也可以使用--lf -x标志逐步修复测试:

tests\test_read_properties.py F                                [100%]

===================== 1 failed in 0.04 seconds ======================

--showlocals(-l)

最后,虽然这不是一个特定的回溯模式标志,--showlocals(或-l作为快捷方式)通过显示在使用--tb=auto--tb=long--tb=short模式时的本地变量及其值列表来增强回溯模式。

例如,这是--tb=auto--showlocals的输出:

_______________________ test_read_properties ________________________

 def test_read_properties():
 lines = DATA.strip().splitlines()
>       grids = list(iter_grids_from_csv(lines))

lines      = ['Main Grid,48,44', '2nd Grid,24,21', '3rd Grid,24,null']

tests\test_read_properties.py:32:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
tests\test_read_properties.py:27: in iter_grids_from_csv
 yield parse_grid_data(fields)
tests\test_read_properties.py:21: in parse_grid_data
 active_cells=convert_size(fields[2]),
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

s = 'null'

 def convert_size(s):
>       return int(s)
E       ValueError: invalid literal for int() with base 10: 'null'

s          = 'null'

tests\test_read_properties.py:14: ValueError
===================== 1 failed in 0.05 seconds ======================

请注意,这样做会更容易看出坏数据来自哪里:在测试开始时从文件中读取的'3rd Grid,24,null'字符串。

--showlocals在本地运行测试和 CI 时都非常有用,深受喜爱。不过要小心,因为这可能存在安全风险:本地变量可能会暴露密码和其他敏感信息,因此请确保使用安全连接传输回溯,并小心使其公开。

使用--durations 进行缓慢测试

在项目开始时,您的测试套件通常运行非常快,只需几秒钟,生活很美好。但随着项目规模的增长,测试套件的测试数量和运行时间也在增加。

测试套件运行缓慢会影响生产力,特别是如果您遵循 TDD 并且一直运行测试。因此,定期查看运行时间最长的测试并分析它们是否可以更快是很有益的:也许您在一个需要更小(更快)数据集的地方使用了大型数据集,或者您可能正在执行不重要的冗余步骤,这些步骤对于实际进行的测试并不重要。

当发生这种情况时,您会喜欢--durations=N标志。此标志提供了N个运行时间最长的测试的摘要,或者使用零来查看所有测试的摘要:

λ pytest --durations=5
...
===================== slowest 5 test durations ======================
3.40s call CH2/tests/test_slow.py::test_corner_case
2.00s call CH2/tests/test_slow.py::test_parse_large_file
0.00s call CH2/tests/core/test_core.py::test_type_checking
0.00s teardown CH2/tests/core/test_parser.py::test_parse_expr
0.00s call CH2/tests/test_digest.py::test_commit_hash
================ 3 failed, 7 passed in 5.51 seconds =================

当您开始寻找测试以加快速度时,此输出提供了宝贵的信息。

尽管这个标志不是您每天都会使用的东西,因为许多人似乎不知道它,但它值得一提。

额外的测试摘要:-ra

Pytest 在测试失败时显示丰富的回溯信息。额外的信息很棒,但实际的页脚对于识别哪些测试实际上失败了并不是很有帮助:

...
________________________ test_type_checking _________________________

    def test_type_checking():
>       assert 0
E       assert 0

tests\core\test_core.py:12: AssertionError
=============== 14 failed, 17 passed in 5.68 seconds ================

可以传递 -ra 标志以生成一个漂亮的摘要,在会话结束时列出所有失败测试的完整名称:

...
________________________ test_type_checking _________________________

 def test_type_checking():
>       assert 0
E       assert 0

tests\core\test_core.py:12: AssertionError
====================== short test summary info ======================
FAIL tests\test_assert_demo.py::test_approx_simple_fail
FAIL tests\test_assert_demo.py::test_approx_list_fail
FAIL tests\test_assert_demo.py::test_default_health
FAIL tests\test_assert_demo.py::test_default_player_class
FAIL tests\test_assert_demo.py::test_warrior_short_description
FAIL tests\test_assert_demo.py::test_warrior_long_description
FAIL tests\test_assert_demo.py::test_get_starting_equiment
FAIL tests\test_assert_demo.py::test_long_list
FAIL tests\test_assert_demo.py::test_starting_health
FAIL tests\test_assert_demo.py::test_player_classes
FAIL tests\test_checks.py::test_invalid_class_name
FAIL tests\test_read_properties.py::test_read_properties
FAIL tests\core\test_core.py::test_check_options
FAIL tests\core\test_core.py::test_type_checking
=============== 14 failed, 17 passed in 5.68 seconds ================

当直接从命令行运行套件时,此标志特别有用,因为在终端上滚动以查找失败的测试可能很烦人。

实际上标志是 -r,它接受一些单字符参数:

  • f(失败):assert 失败

  • e(错误):引发了意外的异常

  • s(跳过):跳过(我们将在下一章中介绍)

  • x(预期失败):预期失败,确实失败(我们将在下一章中介绍)

  • X(预期通过):预期失败,但通过了(!)(我们将在下一章中介绍)

  • p(通过):测试通过

  • P(带输出的通过):即使是通过的测试也显示捕获的输出(小心 - 这通常会产生大量输出)

  • a:显示上述所有内容,但不包括 P;这是默认的,并且通常是最有用的。

该标志可以接收上述任何组合。因此,例如,如果您只对失败和错误感兴趣,可以向 pytest 传递 -rfe

总的来说,我建议坚持使用 -ra,不要想太多,您将获得最多的好处。

配置:pytest.ini

用户可以使用名为 pytest.ini 的配置文件自定义一些 pytest 行为。该文件通常放置在存储库的根目录,并包含一些应用于该项目的所有测试运行的配置值。它旨在保持在版本控制下,并与其余代码一起提交。

格式遵循简单的 ini 样式格式,所有与 pytest 相关的选项都在[pytest] 部分下。有关更多详细信息,请访问:docs.python.org/3/library/configparser.html

[pytest]

此文件的位置还定义了 pytest 称之为根目录rootdir)的内容:如果存在,包含配置文件的目录被视为根目录。

根目录用于以下内容:

  • 创建测试节点 ID

  • 作为存储有关项目信息的稳定位置(由 pytest 插件和功能)

没有配置文件,根目录将取决于您从哪个目录执行 pytest 以及传递了哪些参数(算法的描述可以在这里找到:docs.pytest.org/en/latest/customize.html#finding-the-rootdir)。因此,即使是最简单的项目,也始终建议在其中有一个 pytest.ini 文件,即使是空的。

始终定义一个 pytest.ini 文件,即使是空的。

如果您使用 tox,可以在传统的 tox.ini 文件中放置一个 [pytest] 部分,它将同样有效。有关更多详细信息,请访问:tox.readthedocs.io/en/latest/

[tox]
envlist = py27,py36
...

[pytest]
# pytest options

这对于避免在存储库根目录中放置太多文件很有用,但这实际上是一种偏好。

现在,我们将看一下更常见的配置选项。随着我们介绍新功能,将在接下来的章节中介绍更多选项。

附加命令行:addopts

我们学到了一些非常有用的命令行选项。其中一些可能会成为个人喜爱,但是不得不一直输入它们会很烦人。

addopts 配置选项可以用来始终向命令行添加一组选项:

[pytest]
addopts=--tb=native --maxfail=10 -v

有了这个配置,输入以下内容:

λ pytest tests/test_core.py

与输入以下内容相同:

λ pytest --tb=native --max-fail=10 -v tests/test_core.py

请注意,尽管它的名字是addopts,但实际上它是在命令行中输入其他选项之前插入选项。这使得在addopts中覆盖大多数选项成为可能,当显式传递它们时。

例如,以下代码现在将显示自动的回溯,而不是原生的回溯,如在pytest.ini中配置的那样:

λ pytest --tb=auto tests/test_core.py

自定义收集

默认情况下,pytest 使用以下启发式方法收集测试:

  • 匹配test_*.py*_test.py的文件

  • 在测试模块内部,匹配test*的函数和匹配Test*的类

  • 在测试类内部,匹配test*的方法

这个约定很容易理解,适用于大多数项目,但可以被这些配置选项覆盖:

  • python_files:用于收集测试模块的模式列表

  • python_functions:用于收集测试函数和测试方法的模式列表

  • python_classes:用于收集测试类的模式列表

以下是更改默认设置的配置文件示例:

[pytest]
python_files = unittests_*.py
python_functions = check_*
python_classes = *TestSuite

建议只在遵循不同约定的传统项目中使用这些配置选项,并对新项目使用默认设置。使用默认设置更少工作,避免混淆其他合作者。

缓存目录:cache_dir

--lf--ff选项之前显示的是由一个名为cacheprovider的内部插件提供的,它将数据保存在磁盘上的一个目录中,以便在将来的会话中访问。默认情况下,该目录位于根目录下,名称为.pytest_cache。这个目录不应该提交到版本控制中。

如果您想要更改该目录的位置,可以使用cache_dir选项。该选项还会自动扩展环境变量:

[pytest]
cache_dir=$TMP/pytest-cache

避免递归进入目录:norecursedirs

pytest 默认会递归遍历命令行给定的所有子目录。当递归进入从不包含任何测试的目录时,这可能会使测试收集花费比预期更多的时间,例如:

  • 虚拟环境

  • 构建产物

  • 文档

  • 版本控制目录

pytest 默认会聪明地不会递归进入具有模式.*builddistCVS_darcs{arch}*.eggvenv的文件夹。它还会尝试通过查看已知位置的激活脚本来自动检测 virtualenvs。

norecursedirs选项可用于覆盖 pytest 不应该递归进入的默认模式名称列表:

[pytest]
norecursedirs = artifacts _build docs

您还可以使用--collect-in-virtualenv标志来跳过virtualenv检测。

一般来说,用户很少需要覆盖默认设置,但如果发现自己在项目中一遍又一遍地添加相同的目录,请考虑提交一个问题。更多细节(github.com/pytest-dev/pytest/issues/new)。

默认情况下选择正确的位置:testpaths

如前所述,常见的目录结构是源代码之外的布局,测试与应用程序/库代码分开存放在一个名为tests或类似命名的目录中。在这种布局中,使用testpaths配置选项非常有用:

[pytest]
testpaths = tests

这将告诉 pytest 在命令行中没有给定文件、目录或节点 ID 时在哪里查找测试,这可能会加快测试收集的速度。请注意,您可以配置多个目录,用空格分隔。

使用-o/--override 覆盖选项

最后,一个鲜为人知的功能是,您可以使用-o/--override标志直接在命令行中覆盖任何配置选项。这个标志可以多次传递,以覆盖多个选项:

λ pytest -o python_classes=Suite -o cache_dir=$TMP/pytest-cache

总结

在本章中,我们介绍了如何使用virtualenvpip来安装 pytest。之后,我们深入讨论了如何编写测试,以及运行测试的不同方式,以便只执行我们感兴趣的测试。我们概述了 pytest 如何为不同的内置数据类型提供丰富的输出信息,以便查看失败的测试。我们学会了如何使用pytest.raisespytest.warns来检查异常和警告,以及使用pytest.approx来避免比较浮点数时的常见问题。然后,我们简要讨论了如何在项目中组织测试文件和模块。我们还看了一些更有用的命令行选项,以便我们可以立即提高工作效率。最后,我们介绍了pytest.ini文件如何用于持久的命令行选项和其他配置。

在下一章中,我们将学习如何使用标记来帮助我们在特定平台上跳过测试,如何让我们的测试套件知道代码或外部库中的错误已经修复,以及如何分组测试集,以便我们可以在命令行中有选择地执行它们。之后,我们将学习如何对不同的数据集应用相同的检查,以避免复制和粘贴测试代码。

第二章:标记和参数化

在学习了编写和运行测试的基础知识之后,我们将深入了解两个重要的 pytest 功能:标记参数化

首先,我们将学习标记,它允许我们根据应用的标记选择性地运行测试,并将一般数据附加到测试函数,这些数据可以被夹具和插件使用。在同一主题中,我们将看看内置标记及其提供的内容。

其次,我们将学习测试参数化,它允许我们轻松地将相同的测试函数应用于一组输入值。这极大地避免了重复的测试代码,并使得很容易添加随着软件发展可能出现的新测试用例。

总之,在本章中我们将涵盖以下内容:

  • 标记基础知识

  • 内置标记

  • 参数化

标记基础知识

Pytest 允许您使用元数据对函数和类进行标记。此元数据可用于选择性运行测试,并且也可用于夹具和插件,以执行不同的任务。让我们看看如何创建和应用标记到测试函数,然后再进入内置的 pytest 标记。

创建标记

使用@pytest.mark装饰器创建标记。它作为工厂工作,因此对它的任何访问都将自动创建一个新标记并将其应用于函数。通过示例更容易理解:

@pytest.mark.slow
def test_long_computation():
    ...

通过使用@pytest.mark.slow装饰器,您将标记命名为slow应用于test_long_computation

标记也可以接收参数

@pytest.mark.timeout(10, method="thread")
def test_topology_sort():
    ...

在上一个示例中使用的@pytest.mark.timeout来自 pytest-timeout 插件;有关更多详细信息,请访问pypi.org/project/pytest-timeout/。通过这样做,我们定义了test_topology_sort不应超过 10 秒,在这种情况下,应使用thread方法终止。为标记分配参数是一个非常强大的功能,为插件和夹具提供了很大的灵活性。我们将在下一章中探讨这些功能和pytest-timeout插件。

您可以通过多次应用@pytest.mark装饰器向测试添加多个标记,例如:

@pytest.mark.slow
@pytest.mark.timeout(10, method="thread")
def test_topology_sort():
    ...

如果您一遍又一遍地应用相同的标记,可以通过将其分配给一个变量一次并根据需要在测试中应用它来避免重复自己:

timeout10 = pytest.mark.timeout(10, method="thread")

@timeout10
def test_topology_sort():
    ...

@timeout10
def test_remove_duplicate_points():
    ...

如果此标记在多个测试中使用,可以将其移动到测试实用程序模块并根据需要导入:

from mylib.testing import timeout10

@timeout10
def test_topology_sort():
    ...

@timeout10
def test_remove_duplicate_points():
    ...

基于标记运行测试

您可以使用-m标志将标记作为选择因素运行测试。例如,要运行所有带有slow标记的测试:

λ pytest -m slow

-m标志还接受表达式,因此您可以进行更高级的选择。要运行所有带有slow标记的测试,但不运行带有serial标记的测试,您可以使用:

λ pytest -m "slow and not serial"

表达式限制为andnotor运算符。

自定义标记对于优化 CI 系统上的测试运行非常有用。通常,环境问题,缺少依赖项,甚至一些错误提交的代码可能会导致整个测试套件失败。通过使用标记,您可以选择一些快速和/或足够广泛以检测代码中大部分问题的测试,然后首先运行这些测试,然后再运行所有其他测试。如果其中任何一个测试失败,我们将中止作业,并避免通过运行注定会失败的所有测试来浪费大量时间。

我们首先将自定义标记应用于这些测试。任何名称都可以,但常用的名称是smoke,如烟雾探测器,以便在一切都燃烧之前检测问题。

然后首先运行烟雾测试,只有在它们通过后才运行完整的测试套件:

λ pytest -m "smoke"
...
λ pytest -m "not smoke"

如果任何烟雾测试失败,您不必等待整个套件完成以获得此反馈。

您可以通过创建测试的层次结构,从最简单到最慢,增加此技术。例如:

  • smoke

  • unittest

  • integration

  • <其余所有>

然后执行如下:

λ pytest -m "smoke"
...
λ pytest -m "unittest"
...
λ pytest -m "integration"
...
λ pytest -m "not smoke and not unittest and not integration"

确保包含第四步;否则,没有标记的测试将永远不会运行。

使用标记来区分不同 pytest 运行中的测试也可以用于其他场景。例如,当使用pytest-xdist插件并行运行测试时,我们有一个并行会话,可以并行执行大多数测试套件,但可能决定在单独的 pytest 会话中串行运行一些测试,因为它们在一起执行时很敏感或有问题。

将标记应用于类

您可以将@pytest.mark装饰器应用于一个类。这将使该标记应用于该类中的所有测试方法,避免了将标记代码复制粘贴到所有测试方法中:

@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

    def test_compute_tracers(self):
        ...

前面的代码本质上与以下代码相同:

class TestCore:

 @pytest.mark.timeout(10)
    def test_simple_simulation(self):
        ...

    @pytest.mark.timeout(10)
    def test_compute_tracers(self):
        ...

然而,有一个区别:将@pytest.mark装饰器应用于一个类意味着所有它的子类都会继承该标记。子类测试类的继承并不常见,但有时是一种有用的技术,可以避免重复测试代码,或者确保实现符合某个特定接口。我们将在本章后面和第四章 Fixture中看到更多这方面的例子。

与测试函数一样,装饰器可以应用多次:

@pytest.mark.slow
@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

    def test_compute_tracers(self):
        ...

将标记应用于模块

我们还可以将一个标记应用于模块中的所有测试函数和测试类。只需声明一个名为pytestmark全局变量

import pytest

pytestmark = pytest.mark.timeout(10)

class TestCore:

    def test_simple_simulation(self):
        ...

def test_compute_tracers():
    ...

以下是等效于这个的:

import pytest

@pytest.mark.timeout(10)
class TestCore:

    def test_simple_simulation(self):
        ...

@pytest.mark.timeout(10)
def test_compute_tracers():
    ...

您也可以使用tuplelist的标记来应用多个标记:

import pytest

pytestmark = [pytest.mark.slow, pytest.mark.timeout(10)]

自定义标记和 pytest.ini

通过应用@pytest.mark装饰器来动态声明新的标记是很方便的。这使得快速开始享受使用标记的好处变得轻而易举。

这种便利性是有代价的:用户可能会在标记名称中犯拼写错误,例如@pytest.mark.solw,而不是@pytest.mark.slow。根据被测试的项目,这种拼写错误可能只是一个小烦恼,也可能是一个更严重的问题。

因此,让我们回到我们之前的例子,其中一个测试套件根据标记的测试在 CI 上以层次结构执行:

  • smoke

  • unittest

  • integration

  • <所有其他>

λ pytest -m "smoke"
...
λ pytest -m "unittest"
...
λ pytest -m "integration"
...
λ pytest -m "not smoke and not unittest and not integration"

开发人员在为其中一个测试应用标记时可能会犯拼写错误:

@pytest.mark.smoek
def test_simulation_setup():
    ...

这意味着该测试将在最后一步执行,而不是与其他smoke测试一起在第一步执行。同样,这可能从一个小麻烦变成一个严重的问题,这取决于测试套件。

具有固定标记集的成熟测试套件可能会在pytest.ini文件中声明它们:

[pytest]
markers =
    slow
    serial
    smoke: quick tests that cover a good portion of the code
    unittest: unit tests for basic functionality
    integration: cover to cover functionality testing    

markers选项接受一个标记列表,格式为<name>: description,其中描述部分是可选的(最后一个示例中的slowserial没有描述)。

可以使用--markers标志显示所有标记的完整列表:

λ pytest --markers
@pytest.mark.slow:

@pytest.mark.serial:

@pytest.mark.smoke: quick tests that cover a good portion of the code

@pytest.mark.unittest: unit tests for basic functionality

@pytest.mark.integration: cover to cover functionality testing

...

--strict标志使得在pytest.ini文件中未声明的标记使用成为错误。使用我们之前的带有拼写错误的示例,现在会得到一个错误,而不是在使用--strict运行时 pytest 悄悄地创建标记:

λ pytest --strict tests\test_wrong_mark.py
...
collected 0 items / 1 errors

============================== ERRORS ===============================
_____________ ERROR collecting tests/test_wrong_mark.py _____________
tests\test_wrong_mark.py:4: in <module>
 @pytest.mark.smoek
..\..\.env36\lib\site-packages\_pytest\mark\structures.py:311: in __getattr__
 self._check(name)
..\..\.env36\lib\site-packages\_pytest\mark\structures.py:327: in _check
 raise AttributeError("%r not a registered marker" % (name,))
E AttributeError: 'smoek' not a registered marker
!!!!!!!!!!!!!! Interrupted: 1 errors during collection !!!!!!!!!!!!!!
====================== 1 error in 0.09 seconds ======================

希望确保所有标记都在pytest.ini中注册的测试套件也应该使用addopts

[pytest]
addopts = --strict
markers =
    slow
    serial
    smoke: quick tests that cover a good portion of the code
    unittest: unit tests for basic functionality
    integration: cover to cover functionality testing

内置标记

通过学习标记的基础知识以及如何使用它们,现在让我们来看一些内置的 pytest 标记。这不是所有内置标记的详尽列表,但是常用的标记。另外,请记住,许多插件也引入了其他标记。

@pytest.mark.skipif

您可能有一些测试在满足某些条件之前不应该被执行。例如,一些测试可能依赖于并非总是安装的某些库,或者一个可能不在线的本地数据库,或者仅在某些平台上执行。

Pytest 提供了一个内置标记skipif,可以根据特定条件跳过测试。如果条件为真,则跳过测试不会被执行,并且不会计入测试套件的失败。

例如,您可以使用skipif标记来在 Windows 上执行时始终跳过测试:

import sys
import pytest

@pytest.mark.skipif(
 sys.platform.startswith("win"),
 reason="fork not available on Windows",
)
def test_spawn_server_using_fork():
    ...

@pytest.mark.skipif的第一个参数是条件:在这个例子中,我们告诉 pytest 在 Windows 中跳过这个测试。reason=关键字参数是强制的,并且用于在使用-ra标志时显示为什么跳过测试:

 tests\test_skipif.py s                                        [100%]
====================== short test summary info ======================
SKIP [1] tests\test_skipif.py:6: fork not available on Windows
===================== 1 skipped in 0.02 seconds =====================

始终写入描述性消息是一个好的风格,包括适用时的票号。

另外,我们可以将相同的条件写成如下形式:

import os
import pytest

@pytest.mark.skipif(
 not hasattr(os, 'fork'), reason="os.fork not available"
)
def test_spawn_server_using_fork2():
    ...

后一种版本检查实际功能是否可用,而不是基于平台做出假设(Windows 目前没有os.fork函数,但也许将来 Windows 可能会支持该函数)。在测试库的功能时,通常也会出现相同的情况,而不是检查某些功能是否存在。我建议在可能的情况下,最好检查函数是否实际存在,而不是检查库的特定版本。

通常,检查功能和特性通常是更好的方法,而不是检查平台和库版本号。以下是完整的@pytest.mark.skipif签名:

@pytest.mark.skipif(condition, *, reason=None)

pytest.skip

@pytest.mark.skipif装饰器非常方便,但是标记必须在import/collection时间评估条件,以确定是否应该跳过测试。我们希望最小化测试收集时间,因为毕竟,如果使用-k--lf标志,我们最终可能甚至不会执行所有测试

有时,要在导入时检查测试是否应该跳过几乎是不可能的(除非进行一些令人讨厌的黑客)。例如,您可以根据图形驱动程序的功能来决定是否跳过测试,但只能在初始化底层图形库后才能做出这个决定,而初始化图形子系统绝对不是您希望在导入时执行的事情。

对于这些情况,pytest 允许您在测试主体中使用pytest.skip函数来强制跳过测试:

def test_shaders():
    initialize_graphics()
    if not supports_shaders():
 pytest.skip("shades not supported in this driver") # rest of the test code ... 

pytest.skip通过引发内部异常来工作,因此它遵循正常的 Python 异常语义,而且不需要为了正确跳过测试而做其他事情。

pytest.importorskip

通常,库的测试会依赖于某个特定库是否已安装。例如,pytest 自己的测试套件中有一些针对numpy数组的测试,如果没有安装numpy,则应该跳过这些测试。

处理这个问题的一种方法是手动尝试导入库,并在库不存在时跳过测试:

def test_tracers_as_arrays_manual():
    try:
        import numpy
    except ImportError:
        pytest.skip("requires numpy")
    ...

这可能很快就会过时,因此 pytest 提供了方便的pytest.importorskip函数:

def test_tracers_as_arrays():
    numpy = pytest.importorskip("numpy")
    ...

pytest.importorskip将导入模块并返回模块对象,或者如果无法导入模块,则完全跳过测试。

如果您的测试需要库的最低版本,pytest.importorskip也支持minversion参数:

def test_tracers_as_arrays_114():
    numpy = pytest.importorskip("numpy", minversion="1.14")
    ...

@pytest.mark.xfail

您可以使用@pytest.mark.xfail装饰器来指示测试预计会失败。像往常一样,我们将标记装饰器应用到测试函数或方法上:

@pytest.mark.xfail
def test_simulation_34():
    ...

这个标记支持一些参数,我们将在本节后面看到所有这些参数;但其中一个特别值得讨论:strict参数。此参数为标记定义了两种不同的行为:

  • 使用strict=False(默认值),如果测试通过,测试将被单独计数为XPASS(如果测试通过),或者XFAIL(如果测试失败),并且不会使测试套件失败

  • 使用strict=True,如果测试失败,测试将被标记为XFAIL,但如果测试意外地通过,它将使测试套件失败,就像普通的失败测试一样

但是为什么你想要编写一个你预计会失败的测试,这在哪些情况下有用呢?这一开始可能看起来很奇怪,但有一些情况下这是很方便的。

第一种情况是测试总是失败,并且您希望(大声地)得知它突然开始通过。这可能发生在:

  • 你发现你的代码中的一个 bug 的原因是第三方库中的问题。在这种情况下,你可以编写一个演示问题的失败测试,并用@pytest.mark.xfail(strict=True)标记它。如果测试失败,测试将在测试会话摘要中标记为XFAIL,但如果测试通过,它将失败测试套件。当你升级导致问题的库时,这个测试可能会开始通过,这将提醒你问题已经解决,并需要你的注意。

  • 你想到了一个新功能,并设计了一个或多个在你开始实施之前就对其进行测试的测试用例。你可以使用@pytest.mark.xfail(strict=True)标记提交测试,并在编写新功能时从测试中删除该标记。这在协作环境中非常有用,其中一个人提供了关于他们如何设想新功能/API 的测试,另一个人根据测试用例实现它。

  • 你发现应用程序中的一个 bug,并编写一个演示问题的测试用例。你可能现在没有时间解决它,或者另一个人更适合在代码的那部分工作。在这种情况下,将测试标记为@pytest.mark.xfail(strict=True)是一个很好的方法。

上述所有情况都有一个共同点:你有一个失败的测试,并想知道它是否突然开始通过。在这种情况下,测试通过的事实警告你需要注意的事实:一个带有 bug 修复的库的新版本已发布,部分功能现在按预期工作,或者已修复了一个已知的 bug。

xfail标记有用的另一种情况是当你有有时失败的测试,也称为不稳定测试。不稳定的测试是指有时会失败的测试,即使基础代码没有更改。测试失败看起来是随机的原因有很多;以下是其中一些:

  • 多线程代码中的时间问题

  • 间歇性的网络连接问题

  • 没有正确处理异步事件的测试

  • 依赖于不确定的行为

这只是列举了一些可能的原因。这种不确定性通常发生在范围更广的测试中,比如集成或 UI。事实上,你几乎总是需要处理大型测试套件中的不稳定测试。

不稳定的测试是一个严重的问题,因为测试套件应该是代码按预期工作并在发生真正问题时能够检测到的指标。不稳定的测试破坏了这一形象,因为开发人员经常会看到与最近的代码更改无关的不稳定的测试失败。当这种情况变得司空见惯时,人们开始再次运行测试套件,希望这次不稳定的测试通过(它经常会通过),但这会侵蚀对整个测试套件的信任,并给开发团队带来挫折。你应该把不稳定的测试视为一个应该被遏制和处理的威胁。

以下是关于如何处理开发团队中的不稳定测试的一些建议:

  1. 首先,你需要能够正确识别不稳定的测试。如果一个测试失败,显然与最近的更改无关,再次运行测试。如果之前失败的测试现在通过,这意味着测试是不稳定的。

  2. 在你的工单系统中创建一个处理特定不稳定测试的问题。使用命名约定或其他方式标记该问题与不稳定测试相关(例如 GitHub 或 JIRA 标签)。

  3. 应用@pytest.mark.xfail(reason="flaky test #123", strict=False)标记,确保包括问题票号或标识。如果愿意,可以在描述中添加更多信息。

  4. 确保定期将关于不稳定测试的问题分配给自己或其他团队成员(例如,在冲刺计划期间)。这样做的目的是以舒适的步伐处理不稳定的测试,最终减少或消除它们。

这些做法解决了两个主要问题:它们允许您避免破坏测试套件的信任,让不稳定的测试不会妨碍开发团队,并且它们制定了一项政策,以便及时处理不稳定的测试。

在涵盖了xfail标记有用的情况后,让我们来看一下完整的签名:

@pytest.mark.xfail(condition=None, *, reason=None, raises=None, run=True, strict=False)
  • condition:如果给定,第一个参数是一个True/False条件,类似于@pytest.mark.skipif中使用的条件:如果为False,则忽略xfail标记。它可用于根据外部条件(例如平台、Python 版本、库版本等)标记测试为xfail
@pytest.mark.xfail(
 sys.platform.startswith("win"), 
    reason="flaky on Windows #42", strict=False
)
def test_login_dialog():
    ...
  • reason:一个字符串,在使用-ra标志时将显示在短测试摘要中。强烈建议始终使用此参数来解释为什么将测试标记为xfail和/或包括一个票号。
@pytest.mark.xfail(
    sys.platform.startswith("win"), 
    reason="flaky on Windows #42", strict=False
)
def test_login_dialog():
    ...
  • raises:给定一个异常类型,它声明我们期望测试引发该异常的实例。如果测试引发了另一种类型的异常(甚至是AssertionError),测试将正常“失败”。这对于缺少功能或测试已知错误特别有用。
@pytest.mark.xfail(raises=NotImplementedError,
                   reason='will be implemented in #987')
def test_credential_check():
    check_credentials('Hawkwood') # not implemented yet
  • run:如果为False,则测试甚至不会被执行,并且将作为 XFAIL 失败。这对于运行可能导致测试套件进程崩溃的代码的测试特别有用(例如,由于已知问题,C/C++扩展导致分段错误)。
@pytest.mark.xfail(
    run=False, reason="undefined particles cause a crash #625"
)
def test_undefined_particle_collision_crash():
    collide(Particle(), Particle())
  • strict:如果为True,则通过的测试将使测试套件失败。如果为False,则无论结果如何,测试都不会使测试套件失败(默认为False)。这在本节开始时已经详细讨论过。

配置变量xfail_strict控制xfail标记的strict参数的默认值:

[pytest]
xfail_strict = True

将其设置为True意味着所有标记为 xfail 的测试,没有显式的strict参数,都被视为实际的失败期望,而不是不稳定的测试。任何显式传递strict参数的xfail标记都会覆盖配置值。

pytest.xfail

最后,您可以通过调用pytest.xfail函数在测试中强制触发 XFAIL 结果:

def test_particle_splitting():
    initialize_physics()
    import numpy
    if numpy.__version__ < "1.13":
        pytest.xfail("split computation fails with numpy < 1.13")
    ...

pytest.skip类似,当您只能在运行时确定是否需要将测试标记为xfail时,这是非常有用的。

参数化

一个常见的测试活动是将多个值传递给同一个测试函数,并断言结果。

假设我们有一个应用程序,允许用户定义自定义数学公式,这些公式将在运行时解析和评估。这些公式以字符串形式给出,并且可以使用诸如sincoslog等数学函数。在 Python 中实现这个非常简单的方法是使用内置的evaldocs.python.org/3/library/functions.html#eval),但由于它可以执行任意代码,我们选择使用自定义的标记器和评估器来确保安全。

让我们不要深入实现细节,而是专注于一个测试:

def test_formula_parsing():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

在这里,我们创建了一个Tokenizer类,我们的实现使用它来将公式字符串分解为内部标记,以供以后处理。然后,我们将公式字符串和标记器传递给Formula.from_string,以获得一个公式对象。有了公式对象,我们将输入值传递给formula.eval,并检查返回的值是否符合我们的期望。

但我们也想测试其他数学运算,以确保我们覆盖了Formula类的所有功能。

一种方法是通过使用多个断言来扩展我们的测试,以检查其他公式和输入值:

def test_formula_parsing():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

    formula = Formula.from_string("sin(x) + 2 * cos(x)", tokenizer)
 assert formula.eval(x=0.7) == pytest.approx(2.1739021)

    formula = Formula.from_string("log(x) + 3", tokenizer)
    assert formula.eval(x=2.71828182846) == pytest.approx(4.0)

这样做是有效的,但如果其中一个断言失败,测试函数内部的后续断言将不会被执行。如果有多个失败,我们将不得不多次运行测试来查看所有失败,并最终修复所有问题。

为了在测试运行中看到多个失败,我们可能决定明确为每个断言编写单独的测试:

def test_formula_linear():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("C0 * x + 10", tokenizer)
    assert formula.eval(x=1.0, C0=2.0) == pytest.approx(12.0)

def test_formula_sin_cos():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("sin(x) + 2 * cos(x)", tokenizer)
    assert formula.eval(x=0.7) == pytest.approx(2.1739021)

def test_formula_log():
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string("log(x) + 3", tokenizer)
    assert formula.eval(x=2.71828182846) == pytest.approx(4.0)

但现在我们到处都在重复代码,这将使维护更加困难。假设将来FormulaTokenizer被更新为明确接收可以在公式中使用的函数列表。这意味着我们将不得不在多个地方更新FormulaTokenzier的创建。

为了避免重复,我们可能决定改为这样写:

def test_formula_parsing2():
    values = [
 ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
 ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
 ("log(x) + 3", dict(x=2.71828182846), 4.0),
 ]
    tokenizer = FormulaTokenizer()
    for formula, inputs, result in values:
        formula = Formula.from_string(formula, tokenizer)
        assert formula.eval(**inputs) == pytest.approx(result)

这解决了重复代码的问题,但现在我们又回到了一次只看到一个失败的初始问题。

输入 @pytest.mark.parametrize

为了解决上述所有问题,pytest 提供了备受喜爱的@pytest.mark.parametrize标记。使用这个标记,您可以为测试提供一系列输入值,并且 pytest 会自动生成多个测试函数,每个输入值一个。

以下显示了这一点:

@pytest.mark.parametrize(
 "formula, inputs, result",
 [
 ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
 ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
 ("log(x) + 3", dict(x=2.71828182846), 4.0),
 ],
)
def test_formula_parsing(formula, inputs, result):
    tokenizer = FormulaTokenizer()
    formula = Formula.from_string(formula, tokenizer)
    assert formula.eval(**inputs) == pytest.approx(result)

@pytest.mark.parametrize 标记会自动生成多个测试函数,并使用标记给出的参数对它们进行参数化。调用接收两个参数:

  • argnames: 逗号分隔的参数名称字符串,将传递给测试函数。

  • argvalues: 一系列元组,每个元组生成一个新的测试调用。元组中的每个项目对应一个参数名称,因此第一个元组 ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0) 将生成一个对测试函数的调用,参数为:

  • formula = "C0 * x + 10"

  • inputs = dict(x=1.0, C0=2.0)

  • expected = 12.0

使用这个标记,pytest 将运行 test_formula_parsing 三次,每次传递由argvalues参数给出的一组参数。它还会自动生成不同的节点 ID 用于每个测试,使得很容易区分它们:

======================== test session starts ========================
...
collected 8 items / 5 deselected

test_formula.py::test_formula[C0 * x + 10-inputs0-12.0]
test_formula.py::test_formula[sin(x) + 2 * cos(x)-inputs1-2.1739021]
test_formula.py::test_formula[log(x) + 3-inputs2-4.0] 
============== 3 passed, 5 deselected in 0.05 seconds ===============

还要注意的是,函数的主体与本节开头的起始测试一样紧凑,但现在我们有多个测试,这使我们能够在发生多个失败时看到多个失败。

参数化测试不仅避免了重复的测试代码,使维护更容易,还邀请您和随后的开发人员随着代码的成熟添加更多的输入值。它鼓励开发人员覆盖更多的情况,因为人们更愿意向参数化测试的argvalues添加一行代码,而不是复制和粘贴整个新测试来覆盖另一个输入值。

总之,@pytest.mark.parametrize 将使您覆盖更多的输入情况,开销很小。这绝对是一个非常有用的功能,应该在需要以相同方式测试多个输入值时使用。

将标记应用于值集

通常,在参数化测试中,您会发现需要像对普通测试函数一样对一组参数应用一个或多个标记。例如,您想对一组参数应用timeout标记,因为运行时间太长,或者对一组参数应用xfail标记,因为它尚未实现。

在这些情况下,使用pytest.param来包装值集并应用您想要的标记:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ...
        ("log(x) + 3", dict(x=2.71828182846), 4.0),
        pytest.param(
 "hypot(x, y)", dict(x=3, y=4), 5.0,
 marks=pytest.mark.xfail(reason="not implemented: #102"),
 ),
    ],
)

pytest.param 的签名是这样的:

pytest.param(*values, **kw)

其中:

  • *values 是参数集:"hypot(x, y)", dict(x=3, y=4), 5.0

  • **kw 是选项作为关键字参数:marks=pytest.mark.xfail(reason="not implemented: #102")。它接受单个标记或一系列标记。还有另一个选项ids,将在下一节中显示。

在幕后,传递给@pytest.mark.parametrize的每个参数元组都会转换为一个pytest.param,没有额外的选项,因此,例如,在以下第一个代码片段等同于第二个代码片段:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
        ("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
    ]
)
@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        pytest.param("C0 * x + 10", dict(x=1.0, C0=2.0), 12.0),
        pytest.param("sin(x) + 2 * cos(x)", dict(x=0.7), 2.1739021),
    ]
)

自定义测试 ID

考虑以下示例:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        ("x + 3", dict(x=1.0), 4.0,),
        ("x - 1", dict(x=6.0), 5.0,),
    ],
)
def test_formula_simple(formula, inputs, result):
    ...

正如我们所见,pytest 会根据参数在参数化调用中使用的参数自动生成自定义测试 ID。运行pytest -v将生成这些测试 ID:

======================== test session starts ========================
...
tests/test_formula.py::test_formula_simple[x + 3-inputs0-4.0]
tests/test_formula.py::test_formula_simple[x - 1-inputs1-5.0]

如果你不喜欢自动生成的 ID,你可以使用pytest.paramid选项来自定义它:

@pytest.mark.parametrize(
    "formula, inputs, result",
    [
        pytest.param("x + 3", dict(x=1.0), 4.0, id='add'),
        pytest.param("x - 1", dict(x=6.0), 5.0, id='sub'),
    ],
)
def test_formula_simple(formula, inputs, result):
    ...

这产生了以下结果:

======================== test session starts ========================
...
tests/test_formula.py::test_formula_simple[add]
tests/test_formula.py::test_formula_simple[sub]

这也很有用,因为在使用-k标志时,选择测试变得更容易:

λ pytest -k "x + 3-inputs0-4.0"

对比:

λ pytest -k "add"

测试多个实现

设计良好的系统通常利用接口提供的抽象,而不是与特定实现绑定。这使得系统更能够适应未来的变化,因为要扩展它,你需要实现一个符合预期接口的新扩展,并将其集成到系统中。

经常出现的一个挑战是如何确保现有的实现符合特定接口的所有细节。

例如,假设我们的系统需要能够将一些内部类序列化为文本格式以保存和加载到磁盘。以下是我们系统中的一些内部类:

  • Quantity:表示一个值和一个计量单位。例如,Quantity(10, "m")表示10 米Quantity对象具有加法、减法和乘法——基本上,所有你从本机float期望的运算符,但考虑到计量单位。

  • Pipe:表示液体可以流过的管道。它有一个lengthdiameter,都是Quantity实例。

最初,在我们的开发中,我们只需要以JSON格式保存这些对象,所以我们继续实现一个直接的序列化器类,能够序列化和反序列化我们的类:

class JSONSerializer:

    def serialize_quantity(self, quantity: Quantity) -> str:
        ...

    def deserialize_quantity(self, data: str) -> Quantity:
        ...

    def serialize_pipe(self, pipe: Pipe) -> str:
        ...

    def deserialize_pipe(self, data: str) -> Pipe:
        ...

现在我们应该写一些测试来确保一切正常运行。

class Test:

    def test_quantity(self):
        serializer = JSONSerializer()
        quantity = Quantity(10, "m")
        data = serializer.serialize(quantity)
        new_quantity = serializer.deserialize(data)
        assert new_quantity == quantity

    def test_pipe(self):
        serializer = JSONSerializer()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize(pipe)
        new_pipe = serializer.deserialize(data)
        assert new_pipe == pipe

这样做效果很好,也是一个完全有效的方法,考虑到我们的需求。

一段时间过去了,新的需求出现了:现在我们需要将我们的对象序列化为其他格式,即XMLYAML。为了保持简单,我们创建了两个新类,XMLSerializerYAMLSerializer,它们实现了相同的serialize/deserialize方法。因为它们符合与JSONSerializer相同的接口,我们可以在系统中互换使用新类,这很棒。

但是我们如何测试不同的实现?

一个天真的方法是在每个测试中循环遍历不同的实现:

class Test:

    def test_quantity(self):
        for serializer in [
 JSONSerializer(), XMLSerializer(), YAMLSerializer()
 ]:
            quantity = Quantity(10, "m")
            data = serializer.serialize(quantity)
            new_quantity = serializer.deserialize(data)
            assert new_quantity == quantity

    def test_pipe(self):
        for serializer in [
 JSONSerializer(), XMLSerializer(), YAMLSerializer()
 ]:
            pipe = Pipe(
                length=Quantity(1000, "m"),
                diameter=Quantity(35, "cm"),
            )
            data = serializer.serialize(pipe)
            new_pipe = serializer.deserialize(data)
            assert new_pipe == pipe

这样做虽然有效,但并不理想,因为我们必须在每个测试中复制和粘贴循环定义,这样更难以维护。而且,如果其中一个序列化器失败,列表中的下一个序列化器将永远不会被执行。

另一种可怕的方法是每次复制和粘贴整个测试函数,替换序列化器类,但我们不会在这里展示。

一个更好的解决方案是在类级别使用@pytest.mark.parametrize。观察:

@pytest.mark.parametrize(
 "serializer_class",
 [JSONSerializer, XMLSerializer, YAMLSerializer],
)
class Test:

    def test_quantity(self, serializer_class):
        serializer = serializer_class()
        quantity = Quantity(10, "m")
        data = serializer.serialize(quantity)
        new_quantity = serializer.deserialize(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer_class):
        serializer = serializer_class()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize(pipe)
        new_pipe = serializer.deserialize(data)
        assert new_pipe == pipe

通过一个小改变,我们已经扩展了我们现有的测试,以覆盖所有新的实现:

test_parametrization.py::Test::test_quantity[JSONSerializer] PASSED
test_parametrization.py::Test::test_quantity[XMLSerializer] PASSED
test_parametrization.py::Test::test_quantity[YAMLSerializer] PASSED
test_parametrization.py::Test::test_pipe[JSONSerializer] PASSED
test_parametrization.py::Test::test_pipe[XMLSerializer] PASSED
test_parametrization.py::Test::test_pipe[YAMLSerializer] PASSED

@pytest.mark.parametrize装饰器还清楚地表明,新的实现应该添加到列表中,并且所有现有的测试必须通过。也需要为类添加的新测试对所有实现都通过。

总之,@pytest.mark.parametrize可以是一个非常强大的工具,以确保不同的实现符合接口的规范。

总结

在本章中,我们学习了如何使用标记来组织我们的代码,并帮助我们以灵活的方式运行测试套件。然后我们看了如何使用@pytest.mark.skipif来有条件地跳过测试,以及如何使用@pytest.mark.xfail标记来处理预期的失败和不稳定的测试。然后我们讨论了在协作环境中处理不稳定测试的方法。最后,我们讨论了使用@pytest.mark.parametrize的好处,以避免重复我们的测试代码,并使自己和其他人能够轻松地向现有测试添加新的输入案例。

在下一章中,我们将终于介绍 pytest 最受喜爱和强大的功能之一:fixtures

第三章:fixtures

在上一章中,我们学习了如何有效地使用标记和参数化来跳过测试,将其标记为预期失败,并对其进行参数化,以避免重复。

现实世界中的测试通常需要创建资源或数据来进行操作:一个临时目录来输出一些文件,一个数据库连接来测试应用程序的 I/O 层,一个用于集成测试的 Web 服务器。这些都是更复杂的测试场景中所需的资源的例子。更复杂的资源通常需要在测试会话结束时进行清理:删除临时目录,清理并断开与数据库的连接,关闭 Web 服务器。此外,这些资源应该很容易地在测试之间共享,因为在测试过程中我们经常需要为不同的测试场景重用资源。一些资源创建成本很高,但因为它们是不可变的或者可以恢复到原始状态,所以应该只创建一次,并与需要它的所有测试共享,在最后一个需要它们的测试完成时销毁。

pytest 最重要的功能之一是覆盖所有先前的要求和更多内容。

本章我们将涵盖以下内容:

  • 引入 fixtures

  • 使用conftest.py文件共享 fixtures

  • 作用域

  • 自动使用

  • 参数化

  • 使用 fixtures 中的标记

  • 内置 fixtures 概述

  • 提示/讨论

引入 fixtures

大多数测试需要某种数据或资源来操作:

def test_highest_rated():
    series = [
        ("The Office", 2005, 8.8),
        ("Scrubs", 2001, 8.4),
        ("IT Crowd", 2006, 8.5),
        ("Parks and Recreation", 2009, 8.6),
        ("Seinfeld", 1989, 8.9),
    ]
    assert highest_rated(series) == "Seinfeld"

这里,我们有一个(series name, year, rating)元组的列表,我们用它来测试highest_rated函数。在这里将数据内联到测试代码中对于孤立的测试效果很好,但通常你会有一个可以被多个测试使用的数据集。一种解决方法是将数据集复制到每个测试中:

def test_highest_rated():
    series = [
        ("The Office", 2005, 8.8),
        ...,
    ]
    assert highest_rated(series) == "Seinfeld"

def test_oldest():
    series = [
        ("The Office", 2005, 8.8),
        ...,
    ]
    assert oldest(series) == "Seinfeld"

但这很快就会变得老套—此外,复制和粘贴东西会在长期内影响可维护性,例如,如果数据布局发生变化(例如,添加一个新项目到元组或演员阵容大小)。

进入 fixtures

pytest 对这个问题的解决方案是 fixtures。fixtures 用于提供测试所需的函数和方法。

它们是使用普通的 Python 函数和@pytest.fixture装饰器创建的:

@pytest.fixture
def comedy_series():
    return [
        ("The Office", 2005, 8.8),
        ("Scrubs", 2001, 8.4),
        ("IT Crowd", 2006, 8.5),
        ("Parks and Recreation", 2009, 8.6),
        ("Seinfeld", 1989, 8.9),
    ]

在这里,我们创建了一个名为comedy_series的 fixture,它返回我们在上一节中使用的相同列表。

测试可以通过在其参数列表中声明 fixture 名称来访问 fixtures。然后测试函数会接收 fixture 函数的返回值作为参数。这里是comedy_series fixture 的使用:

def test_highest_rated(comedy_series):
    assert highest_rated(comedy_series) == "Seinfeld"

def test_oldest(comedy_series):
    assert oldest(comedy_series) == "Seinfeld"

事情是这样的:

  • pytest 在调用测试函数之前查看测试函数的参数。这里,我们有一个参数:comedy_series

  • 对于每个参数,pytest 获取相同名称的 fixture 函数并执行它。

  • 每个 fixture 函数的返回值成为一个命名参数,并调用测试函数。

请注意,test_highest_ratedtest_oldest各自获得喜剧系列列表的副本,因此如果它们在测试中更改列表,它们不会相互干扰。

还可以使用方法在类中创建 fixtures:

class Test:

    @pytest.fixture
    def drama_series(self):
        return [
            ("The Mentalist", 2008, 8.1),
            ("Game of Thrones", 2011, 9.5),
            ("The Newsroom", 2012, 8.6),
            ("Cosmos", 1980, 9.3),
        ]

在测试类中定义的 fixtures 只能被类或子类的测试方法访问:

class Test:
    ...

    def test_highest_rated(self, drama_series):
        assert highest_rated(drama_series) == "Game of Thrones"

    def test_oldest(self, drama_series):
        assert oldest(drama_series) == "Cosmos"

请注意,测试类可能有其他非测试方法,就像任何其他类一样。

设置/拆卸

正如我们在介绍中看到的,测试中使用的资源通常需要在测试完成后进行某种清理。

在我们之前的例子中,我们有一个非常小的数据集,所以在 fixture 中内联它是可以的。然而,假设我们有一个更大的数据集(比如,1000 个条目),那么在代码中写入它会影响可读性。通常,数据集在外部文件中,例如 CSV 格式,因此将其移植到 Python 代码中是一件痛苦的事情。

解决方法是将包含系列数据集的 CSV 文件提交到存储库中,并在测试中使用内置的csv模块进行读取;有关更多详细信息,请访问docs.python.org/3/library/csv.html

我们可以更改comedy_series fixture 来实现这一点:

@pytest.fixture
def comedy_series():
    file = open("series.csv", "r", newline="")
    return list(csv.reader(file))

这样做是有效的,但是我们作为认真的开发人员,希望能够正确关闭该文件。我们如何使用 fixtures 做到这一点呢?

Fixture 清理通常被称为teardown,并且可以使用yield语句轻松支持:

@pytest.fixture
def some_fixture():
    value = setup_value()
    yield value
    teardown_value(value)

通过使用yield而不是return,会发生以下情况:

  • fixture 函数被调用

  • 它执行直到 yield 语句,其中暂停并产生 fixture 值

  • 测试执行,接收 fixture 值作为参数

  • 无论测试是否通过,函数都会恢复执行,以执行其清理操作

对于熟悉它的人来说,这与上下文管理器docs.python.org/3/library/contextlib.html#contextlib.contextmanager)非常相似,只是您不需要用 try/except 子句将 yield 语句包围起来,以确保在发生异常时仍执行 yield 后的代码块。

让我们回到我们的例子;现在我们可以使用yield而不是return并关闭文件:

@pytest.fixture
def comedy_series():
    file = open("series.csv", "r", newline="")
    yield list(csv.reader(file))
    file.close()

这很好,但请注意,因为yield与文件对象的with语句配合得很好,我们可以这样写:

@pytest.fixture
def comedy_series():
    with open("series.csv", "r", newline="") as file:
        return list(csv.reader(file))

测试完成后,with语句会自动关闭文件,这更短,被认为更符合 Python 风格。

太棒了。

可组合性

假设我们收到一个新的 series.csv 文件,其中包含更多的电视系列,包括以前的喜剧系列和许多其他类型。我们希望为一些其他测试使用这些新数据,但我们希望保持现有的测试与以前一样工作。

在 pytest 中,fixture 可以通过声明它们为参数轻松依赖于其他 fixtures。利用这一特性,我们能够创建一个新的 series fixture,从series.csv中读取所有数据(现在包含更多类型),并将我们的comedy_series fixture 更改为仅过滤出喜剧系列:

@pytest.fixture
def series():
    with open("series.csv", "r", newline="") as file:
        return list(csv.reader(file))

@pytest.fixture
def comedy_series(series):
    return [x for x in series if x[GENRE] == "comedy"]

使用comedy_series的测试保持不变:

def test_highest_rated(comedy_series):
    assert highest_rated(comedy_series) == "Seinfeld"

def test_oldest(comedy_series):
    assert oldest(comedy_series) == "Seinfeld"

请注意,由于这些特性,fixtures 是依赖注入的一个典型例子,这是一种技术,其中函数或对象声明其依赖关系,但否则不知道或不关心这些依赖关系将如何创建,或者由谁创建。这使它们非常模块化和可重用。

使用 conftest.py 文件共享 fixtures

假设我们需要在其他测试模块中使用前一节中的comedy_series fixture。在 pytest 中,通过将 fixture 代码移动到conftest.py文件中,可以轻松共享 fixtures。

conftest.py文件是一个普通的 Python 模块,只是它会被 pytest 自动加载,并且其中定义的任何 fixtures 都会自动对同一目录及以下的测试模块可用。考虑一下这个测试模块的层次结构:

tests/
    ratings/
        series.csv
        test_ranking.py
    io/
        conftest.py
        test_formats.py 
    conftest.py

tests/conftest.py文件位于层次结构的根目录,因此在该项目中,任何在其中定义的 fixtures 都会自动对所有其他测试模块可用。在tests/io/conftest.py中定义的 fixtures 将仅对tests/io及以下模块可用,因此目前仅对test_formats.py可用。

这可能看起来不像什么大不了的事,但它使共享 fixtures 变得轻而易举:当编写测试模块时,能够从小处开始使用一些 fixtures,知道如果将来这些 fixtures 对其他测试有用,只需将 fixtures 移动到conftest.py中即可。这避免了复制和粘贴测试数据的诱惑,或者花费太多时间考虑如何从一开始组织测试支持代码,以避免以后进行大量重构。

作用域

夹具总是在测试函数请求它们时创建的,通过在参数列表上声明它们,就像我们已经看到的那样。默认情况下,每个夹具在每个测试完成时都会被销毁。

正如本章开头提到的,一些夹具可能很昂贵,需要创建或设置,因此尽可能少地创建实例将非常有帮助,以节省时间。以下是一些示例:

  • 初始化数据库表

  • 例如,从磁盘读取缓存数据,大型 CSV 数据

  • 启动外部服务

为了解决这个问题,pytest 中的夹具可以具有不同的范围。夹具的范围定义了夹具应该在何时清理。在夹具没有清理的情况下,请求夹具的测试将收到相同的夹具值。

@pytest.fixture装饰器的范围参数用于设置夹具的范围:

@pytest.fixture(scope="session")
def db_connection():
    ...

以下范围可用:

  • scope="session":当所有测试完成时,夹具被拆除。

  • scope="module":当模块的最后一个测试函数完成时,夹具被拆除。

  • scope="class":当类的最后一个测试方法完成时,夹具被拆除。

  • scope="function":当请求它的测试函数完成时,夹具被拆除。这是默认值。

重要的是要强调,无论范围如何,每个夹具都只会在测试函数需要它时才会被创建。例如,会话范围的夹具不一定会在会话开始时创建,而是只有在第一个请求它的测试即将被调用时才会创建。当考虑到并非所有测试都可能需要会话范围的夹具,并且有各种形式只运行一部分测试时,这是有意义的,正如我们在前几章中所看到的。

范围的作用

为了展示作用域,让我们看一下在测试涉及某种数据库时使用的常见模式。在即将到来的示例中,不要关注数据库 API(无论如何都是虚构的),而是关注涉及的夹具的概念和设计。

通常,连接到数据库和表的创建都很慢。如果数据库支持事务,即执行可以原子地应用或丢弃的一组更改的能力,那么可以使用以下模式。

首先,我们可以使用会话范围的夹具连接和初始化我们需要的表的数据库:

@pytest.fixture(scope="session")
def db():
    db = connect_to_db("localhost", "test") 
    db.create_table(Series)
    db.create_table(Actors)
    yield db
    db.prune()
    db.disconnect()

请注意,我们会在夹具结束时修剪测试数据库并断开与其的连接,这将在会话结束时发生。

通过db夹具,我们可以在所有测试中共享相同的数据库。这很棒,因为它节省了时间。但它也有一个缺点,现在测试可以更改数据库并影响其他测试。为了解决这个问题,我们创建了一个事务夹具,在测试开始之前启动一个新的事务,并在测试完成时回滚事务,确保数据库返回到其先前的状态:

@pytest.fixture(scope="function")
def transaction(db):
    transaction = db.start_transaction()
    yield transaction
    transaction.rollback()

请注意,我们的事务夹具依赖于db。现在测试可以使用事务夹具随意读写数据库,而不必担心为其他测试清理它:

def test_insert(transaction):
    transaction.add(Series("The Office", 2005, 8.8))
    assert transaction.find(name="The Office") is not None

有了这两个夹具,我们就有了一个非常坚实的基础来编写我们的数据库测试:需要事务夹具的第一个测试将通过db夹具自动初始化数据库,并且从现在开始,每个需要执行事务的测试都将从一个原始的数据库中执行。

不同范围夹具之间的可组合性非常强大,并且使得在现实世界的测试套件中可以实现各种巧妙的设计。

自动使用

可以通过将autouse=True传递给@pytest.fixture装饰器,将夹具应用于层次结构中的所有测试,即使测试没有明确请求夹具。当我们需要在每个测试之前和/或之后无条件地应用副作用时,这是有用的。

@pytest.fixture(autouse=True)
def setup_dev_environment():
    previous = os.environ.get('APP_ENV', '')
    os.environ['APP_ENV'] = 'TESTING'
    yield
    os.environ['APP_ENV'] = previous

自动使用的夹具适用于夹具可供使用的所有测试:

  • 与夹具相同的模块

  • 在方法定义的情况下,与装置相同的类。

  • 如果装置在conftest.py文件中定义,那么在相同目录或以下目录中的测试

换句话说,如果一个测试可以通过在参数列表中声明它来访问一个autouse装置,那么该测试将自动使用autouse装置。请注意,如果测试函数对装置的返回值感兴趣,它可能会将autouse装置添加到其参数列表中,就像正常情况一样。

@pytest.mark.usefixtures

@pytest.mark.usefixtures标记可用于将一个或多个装置应用于测试,就好像它们在参数列表中声明了装置名称一样。在您希望所有组中的测试始终使用不是autouse的装置的情况下,这可能是一种替代方法。

例如,下面的代码将确保TestVirtualEnv类中的所有测试方法在一个全新的虚拟环境中执行:

@pytest.fixture
def venv_dir():
    import venv

    with tempfile.TemporaryDirectory() as d:
        venv.create(d)
        pwd = os.getcwd()
        os.chdir(d)
        yield d
        os.chdir(pwd)

@pytest.mark.usefixtures('venv_dir')
class TestVirtualEnv:
    ...

正如名称所示,您可以将多个装置名称传递给装饰器:

@pytest.mark.usefixtures("venv_dir", "config_python_debug")
class Test:
    ...

参数化装置

装置也可以直接进行参数化。当一个装置被参数化时,所有使用该装置的测试现在将多次运行,每个参数运行一次。当我们有装置的变体,并且每个使用该装置的测试也应该与所有变体一起运行时,这是一个很好的工具。

在上一章中,我们看到了使用序列化器的多个实现进行参数化的示例:

@pytest.mark.parametrize(
    "serializer_class",
    [JSONSerializer, XMLSerializer, YAMLSerializer],
)
class Test:

    def test_quantity(self, serializer_class):
        serializer = serializer_class()
        quantity = Quantity(10, "m")
        data = serializer.serialize_quantity(quantity)
        new_quantity = serializer.deserialize_quantity(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer_class):
        serializer = serializer_class()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
       data = serializer.serialize_pipe(pipe)
       new_pipe = serializer.deserialize_pipe(data)
       assert new_pipe == pipe

我们可以更新示例以在装置上进行参数化:

class Test:

 @pytest.fixture(params=[JSONSerializer, XMLSerializer,
 YAMLSerializer])
 def serializer(self, request):
 return request.param()

    def test_quantity(self, serializer):
        quantity = Quantity(10, "m")
        data = serializer.serialize_quantity(quantity)
        new_quantity = serializer.deserialize_quantity(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer):
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize_pipe(pipe)
        new_pipe = serializer.deserialize_pipe(data)
        assert new_pipe == pipe

请注意以下内容:

  • 我们向装置定义传递了一个params参数。

  • 我们使用request对象的特殊param属性在装置内部访问参数。当装置被参数化时,这个内置装置提供了对请求测试函数和参数的访问。我们将在本章后面更多地了解request装置。

  • 在这种情况下,我们在装置内部实例化序列化器,而不是在每个测试中显式实例化。

可以看到,参数化装置与参数化测试非常相似,但有一个关键的区别:通过参数化装置,我们使所有使用该装置的测试针对所有参数化的实例运行,使它们成为conftest.py文件中共享的装置的绝佳解决方案。

当您向现有装置添加新参数时,看到自动执行了许多新测试是非常有益的。

使用装置标记

我们可以使用request装置来访问应用于测试函数的标记。

假设我们有一个autouse装置,它总是将当前区域初始化为英语:

@pytest.fixture(autouse=True)
def setup_locale():
    locale.setlocale(locale.LC_ALL, "en_US")
    yield
    locale.setlocale(locale.LC_ALL, None)

def test_currency_us():
    assert locale.currency(10.5) == "$10.50"

但是,如果我们只想为一些测试使用不同的区域设置呢?

一种方法是使用自定义标记,并在我们的装置内部访问mark对象:

@pytest.fixture(autouse=True)
def setup_locale(request):
    mark = request.node.get_closest_marker("change_locale")
    loc = mark.args[0] if mark is not None else "en_US"
    locale.setlocale(locale.LC_ALL, loc)
    yield
    locale.setlocale(locale.LC_ALL, None)

@pytest.mark.change_locale("pt_BR")
def test_currency_br():
    assert locale.currency(10.5) == "R$ 10,50"

标记可以用来将信息传递给装置。因为它有点隐式,所以我建议节俭使用,因为它可能导致难以理解的代码。

内置装置概述

让我们来看一些内置的 pytest 装置。

tmpdir

tmpdir装置提供了一个在每次测试结束时自动删除的空目录:

def test_empty(tmpdir):
    assert os.path.isdir(tmpdir)
    assert os.listdir(tmpdir) == []

作为function-scoped 装置,每个测试都有自己的目录,因此它们不必担心清理或生成唯一的目录。

装置提供了一个py.local对象(py.readthedocs.io/en/latest/path.html),来自py库(py.readthedocs.io),它提供了方便的方法来处理文件路径,比如连接,读取,写入,获取扩展名等等;它在哲学上类似于标准库中的pathlib.Path对象(docs.python.org/3/library/pathlib.html):

def test_save_curves(tmpdir):
    data = dict(status_code=200, values=[225, 300])
    fn = tmpdir.join('somefile.json')
    write_json(fn, data)
    assert fn.read() == '{"status_code": 200, "values": [225, 300]}'

为什么 pytest 使用py.local而不是pathlib.Path

pathlib.Path出现并被合并到标准库之前,Pytest 已经存在多年了,而py库是当时路径类对象的最佳解决方案之一。核心 pytest 开发人员正在研究如何使 pytest 适应现在标准的pathlib.PathAPI。

tmpdir_factory

tmpdir装置非常方便,但它只有function-scoped:这样做的缺点是它只能被其他function-scoped 装置使用。

tmpdir_factory装置是一个session-scoped装置,允许在任何范围内创建空的唯一目录。当我们需要在其他范围的装置中存储数据时,例如session-scoped 缓存或数据库文件时,这可能很有用。

为了展示它的作用,接下来显示的images_dir装置使用tmpdir_factory创建一个唯一的目录,整个测试会话中包含一系列示例图像文件:

@pytest.fixture(scope='session')
def images_dir(tmpdir_factory):
    directory = tmpdir_factory.mktemp('images')
    download_images('https://example.com/samples.zip', directory)
    extract_images(directory / 'samples.zip')
    return directory

因为这将每个会话只执行一次,所以在运行测试时会节省我们相当多的时间。

然后测试可以使用images_dir装置轻松访问示例图像文件:

def test_blur_filter(images_dir):
    output_image = apply_blur_filter(images_dir / 'rock1.png')
    ...

但请记住,此装置创建的目录是共享的,并且只会在测试会话结束时被删除。这意味着测试不应修改目录的内容;否则,它们可能会影响其他测试。

猴子补丁

在某些情况下,测试需要复杂或难以在测试环境中设置的功能,例如:

  • 对外部资源的客户端(例如 GitHub 的 API)需要在测试期间访问可能不切实际或成本太高

  • 强制代码表现得好像在另一个平台上,比如错误处理

  • 复杂的条件或难以在本地或 CI 中重现的环境

monkeypatch装置允许您使用其他对象和函数干净地覆盖正在测试的系统的函数、对象和字典条目,并在测试拆卸期间撤消所有更改。例如:

import getpass

def user_login(name):
    password = getpass.getpass()
    check_credentials(name, password)
    ...

在这段代码中,user_login使用标准库中的getpass.getpass()函数(docs.python.org/3/library/getpass.html)以系统中最安全的方式提示用户输入密码。在测试期间很难模拟实际输入密码,因为getpass尝试直接从终端读取(而不是从sys.stdin)。

我们可以使用monkeypatch装置来在测试中绕过对getpass的调用,透明地而不改变应用程序代码:

def test_login_success(monkeypatch):
    monkeypatch.setattr(getpass, "getpass", lambda: "valid-pass")
    assert user_login("test-user")

def test_login_wrong_password(monkeypatch):
    monkeypatch.setattr(getpass, "getpass", lambda: "wrong-pass")
    with pytest.raises(AuthenticationError, match="wrong password"):
        user_login("test-user")

在测试中,我们使用monkeypatch.setattr来用一个虚拟的lambda替换getpass模块的真实getpass()函数,它返回一个硬编码的密码。在test_login_success中,我们返回一个已知的好密码,以确保用户可以成功进行身份验证,而在test_login_wrong_password中,我们使用一个错误的密码来确保正确处理身份验证错误。如前所述,原始的getpass()函数会在测试结束时自动恢复,确保我们不会将该更改泄漏到系统中的其他测试中。

如何和在哪里修补

monkeypatch装置通过用另一个对象(通常称为模拟)替换对象的属性来工作,在测试结束时恢复原始对象。使用此装置的常见问题是修补错误的对象,这会导致调用原始函数/对象而不是模拟函数/对象。

要理解问题,我们需要了解 Python 中importimport from的工作原理。

考虑一个名为services.py的模块:

import subprocess

def start_service(service_name):
    subprocess.run(f"docker run {service_name}")

在这段代码中,我们导入subprocess模块并将subprocess模块对象引入services.py命名空间。这就是为什么我们调用subprocess.run:我们正在访问services.py命名空间中subprocess对象的run函数。

现在考虑稍微不同的以前的代码写法:

from subprocess import run

def start_service(service_name):
    run(f"docker run {service_name}")

在这里,我们导入了subprocess模块,但将run函数对象带入了service.py命名空间。这就是为什么run可以直接在start_service中调用,而subprocess名称甚至不可用(如果尝试调用subprocess.run,将会得到NameError异常)。

我们需要意识到这种差异,以便正确地monkeypatchservices.py中使用subprocess.run

在第一种情况下,我们需要替换subprocess模块的run函数,因为start_service就是这样使用它的:

import subprocess
import services

def test_start_service(monkeypatch):
    commands = []
    monkeypatch.setattr(subprocess, "run", commands.append)
    services.start_service("web")
    assert commands == ["docker run web"]

在这段代码中,services.pytest_services.py都引用了相同的subprocess模块对象。

然而,在第二种情况下,services.py在自己的命名空间中引用了原始的run函数。因此,第二种情况的正确方法是替换services.py命名空间中的run函数:

import services

def test_start_service(monkeypatch):
    commands = []
    monkeypatch.setattr(services, "run", commands.append)
    services.start_service("web")
    assert commands == ["docker run web"]

被测试代码导入需要进行 monkeypatch 的代码是人们经常被绊倒的原因,所以确保您首先查看代码。

capsys/capfd

capsys fixture 捕获了写入sys.stdoutsys.stderr的所有文本,并在测试期间使其可用。

假设我们有一个小的命令行脚本,并且希望在调用脚本时没有参数时检查使用说明是否正确:

from textwrap import dedent

def script_main(args):
    if not args:
        show_usage()
        return 0
    ...

def show_usage():
    print("Create/update webhooks.")
    print(" Usage: hooks REPO URL")

在测试期间,我们可以使用capsys fixture 访问捕获的输出。这个 fixture 有一个capsys.readouterr()方法,返回一个namedtuple(docs.python.org/3/library/collections.html#collections.namedtuple),其中包含从sys.stdoutsys.stderr捕获的文本。

def test_usage(capsys):
    script_main([])
    captured = capsys.readouterr()
    assert captured.out == dedent("""\
        Create/update webhooks.
          Usage: hooks REPO URL
    """)

还有capfd fixture,它的工作方式类似于capsys,只是它还捕获文件描述符12的输出。这使得可以捕获标准输出和标准错误,即使是对于扩展模块。

二进制模式

capsysbinarycapfdbinary是与capsyscapfd相同的 fixtures,不同之处在于它们以二进制模式捕获输出,并且它们的readouterr()方法返回原始字节而不是文本。在特殊情况下可能会有用,例如运行生成二进制输出的外部进程时,如tar

request

request fixture 是一个内部 pytest fixture,提供有关请求测试的有用信息。它可以在测试函数和 fixtures 中声明,并提供以下属性:

  • function:Python test函数对象,可用于function-scoped fixtures。

  • cls/instance:Python 类/实例的test方法对象,可用于functionclass-scoped fixtures。如果 fixture 是从test函数请求的,而不是测试方法,则可以为None

  • module:请求测试方法的 Python 模块对象,可用于modulefunctionclass-scoped fixtures。

  • session:pytest 的内部Session对象,它是测试会话的单例,代表集合树的根。它可用于所有范围的 fixtures。

  • node:pytest 集合节点,它包装了与 fixture 范围匹配的 Python 对象之一。

  • addfinalizer(func): 添加一个将在测试结束时调用的new finalizer函数。finalizer 函数将在不带参数的情况下调用。addfinalizer是在 fixtures 中执行拆卸的原始方法,但后来已被yield语句取代,主要用于向后兼容。

fixtures 可以使用这些属性根据正在执行的测试自定义自己的行为。例如,我们可以创建一个 fixture,使用当前测试名称作为临时目录的前缀,类似于内置的tmpdir fixture:

@pytest.fixture
def tmp_path(request) -> Path:
    with TemporaryDirectory(prefix=request.node.name) as d:
        yield Path(d)

def test_tmp_path(tmp_path):
    assert list(tmp_path.iterdir()) == []

在我的系统上执行此代码时创建了以下目录:

C:\Users\Bruno\AppData\Local\Temp\test_tmp_patht5w0cvd0

request fixture 可以在您想要根据正在执行的测试的属性自定义 fixture,或者访问应用于测试函数的标记时使用,正如我们在前面的部分中所看到的。

提示/讨论

以下是一些未适应前面部分的短话题和提示,但我认为值得一提。

何时使用 fixture,而不是简单函数

有时,您只需要为测试构造一个简单的对象,可以说这可以通过一个普通函数来完成,不一定需要实现为 fixture。假设我们有一个不接收任何参数的 WindowManager 类:

class WindowManager:
    ...

在我们的测试中使用它的一种方法是编写一个 fixture:

@pytest.fixture
def manager():
 return WindowManager()

def test_windows_creation(manager):
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

或者,您可以主张为这样简单的用法编写一个 fixture 是过度的,并且使用一个普通函数代替:

def create_window_manager():
    return WindowManager()

def test_windows_creation():
    manager = create_window_manager()
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

或者您甚至可以在每个测试中显式创建管理器:

def test_windows_creation():
    manager = WindowManager()
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

这是完全可以的,特别是如果在单个模块中的少数测试中使用。

然而,请记住,fixture 抽象了对象的构建和拆卸过程的细节。在决定放弃 fixture 而选择普通函数时,这一点至关重要。

假设我们的 WindowManager 现在需要显式关闭,或者它需要一个本地目录用于记录目的:

class WindowManager:

    def __init__(self, logging_directory):
        ...

    def close(self):
        """
        Close the WindowManager and all associated resources. 
        """
        ...

如果我们一直在使用像第一个例子中给出的 fixture,我们只需更新 fixture 函数,测试根本不需要改变

@pytest.fixture
def manager(tmpdir):
    wm = WindowManager(str(tmpdir))
    yield wm
 wm.close()

但是,如果我们选择使用一个普通函数,现在我们必须更新调用我们函数的所有地方:我们需要传递一个记录目录,并确保在测试结束时调用 .close()

def create_window_manager(tmpdir, request):
    wm = WindowManager(str(tmpdir))
    request.addfinalizer(wm.close)
    return wm

def test_windows_creation(tmpdir, request):
    manager = create_window_manager(tmpdir, request)
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

根据这个函数在我们的测试中被使用的次数,这可能是一个相当大的重构。

这个信息是:当底层对象简单且不太可能改变时,使用普通函数是可以的,但请记住,fixture 抽象了对象的创建/销毁的细节,它们可能在将来需要更改。另一方面,使用 fixture 创建了另一个间接层,稍微增加了代码复杂性。最终,这是一个需要您权衡的平衡。

重命名 fixture

@pytest.fixture 装饰器接受一个 name 参数,该参数可用于指定 fixture 的名称,与 fixture 函数不同:

@pytest.fixture(name="venv_dir")
def _venv_dir():
    ...

这是有用的,因为有一些烦恼可能会影响用户在使用在相同模块中声明的 fixture 时:

  • 如果用户忘记在测试函数的参数列表中声明 fixture,他们将得到一个 NameError,而不是 fixture 函数对象(因为它们在同一个模块中)。

  • 一些 linters 抱怨测试函数参数遮蔽了 fixture 函数。

如果之前的烦恼经常发生,您可能会将这视为团队中的一个良好实践。请记住,这些问题只会发生在测试模块中定义的 fixture 中,而不会发生在 conftest.py 文件中。

在 conftest 文件中优先使用本地导入

conftest.py 文件在收集期间被导入,因此它们直接影响您从命令行运行测试时的体验。因此,我建议在 conftest.py 文件中尽可能使用本地导入,以保持导入时间较短。

因此,不要使用这个:

import pytest
import tempfile
from myapp import setup

@pytest.fixture
def setup_app():
    ...

优先使用本地导入:

import pytest

@pytest.fixture
def setup_app():
 import tempfile
 from myapp import setup
    ...

这种做法对大型测试套件的启动有明显影响。

fixture 作为测试支持代码

您应该将 fixture 视为不仅提供资源的手段,还提供测试的支持代码。通过支持代码,我指的是为测试提供高级功能的类。

例如,一个机器人框架可能会提供一个 fixture,用于测试您的机器人作为黑盒:

def test_hello(bot):
    reply = bot.say("hello")
    assert reply.text == "Hey, how can I help you?"

def test_store_deploy_token(bot):
    assert bot.store["TEST"]["token"] is None
    reply = bot.say("my token is ASLKM8KJAN")
    assert reply.text == "OK, your token was saved"
    assert bot.store["TEST"]["token"] == "ASLKM8KJAN"

bot fixture 允许开发人员与机器人交谈,验证响应,并检查框架处理的内部存储的内容,等等。它提供了一个高级接口,使得测试更容易编写和理解,即使对于那些不了解框架内部的人也是如此。

这种技术对应用程序很有用,因为它将使开发人员轻松愉快地添加新的测试。对于库来说也很有用,因为它们将为库的用户提供高级测试支持。

总结

在本章中,我们深入了解了 pytest 最著名的功能之一:fixtures。我们看到了它们如何被用来提供资源和测试功能,以及如何简洁地表达设置/拆卸代码。我们学会了如何共享 fixtures,使用conftest.py文件;如何使用 fixture scopes,避免为每个测试创建昂贵的资源;以及如何自动使用 fixtures,这些 fixtures 会在同一模块或层次结构中的所有测试中执行。然后,我们学会了如何对 fixtures 进行参数化,并从中使用标记。我们对各种内置 fixtures 进行了概述,并在最后对 fixtures 进行了一些简短的讨论。希望您喜欢这一过程!

在下一章中,我们将探索一下广阔的 pytest 插件生态系统,这些插件都可以供您使用。

第四章:插件

在前一章中,我们探讨了 pytest 最重要的特性之一:fixture。我们学会了如何使用 fixture 来管理资源,并在编写测试时让我们的生活更轻松。

pytest 是以定制和灵活性为目标构建的,并允许开发人员编写称为插件的强大扩展。pytest 中的插件可以做各种事情,从简单地提供新的 fixture,到添加命令行选项,改变测试的执行方式,甚至运行用其他语言编写的测试。

在本章中,我们将做以下事情:

  • 学习如何查找和安装插件

  • 品尝生态系统提供的插件

查找和安装插件

正如本章开头提到的,pytest 是从头开始以定制和灵活性为目标编写的。插件机制是 pytest 架构的核心,以至于 pytest 的许多内置功能都是以内部插件的形式实现的,比如标记、参数化、fixture——几乎所有东西,甚至命令行选项。

这种灵活性导致了一个庞大而丰富的插件生态系统。在撰写本文时,可用的插件数量已经超过 500 个,而且这个数字以惊人的速度不断增加。

查找插件

考虑到插件的数量众多,如果有一个网站能够展示所有 pytest 插件以及它们的描述,那将是很好的。如果这个地方还能显示关于不同 Python 和 pytest 版本的兼容性信息,那就更好了。

好消息是,这样的网站已经存在了,并且由核心开发团队维护:pytest 插件兼容性(plugincompat.herokuapp.com/)。在这个网站上,你将找到 PyPI 中所有可用的 pytest 插件的列表,以及 Python 和 pytest 版本的兼容性信息。该网站每天都会从 PyPI 直接获取新的插件和更新,是一个浏览新插件的好地方。

安装插件

插件通常使用pip安装:

λ pip install <PLUGIN_NAME>

例如,要安装pytest-mock,我们执行以下操作:

λ pip install pytest-mock

不需要任何注册;pytest 会自动检测你的虚拟环境或 Python 安装中安装的插件。

这种简单性使得尝试新插件变得非常容易。

各种插件概述

现在,我们将看一些有用和/或有趣的插件。当然,不可能在这里覆盖所有的插件,所以我们将尝试覆盖那些涵盖流行框架和一般功能的插件,还有一些晦涩的插件。当然,这只是皮毛,但让我们开始吧。

pytest-xdist

这是一个非常受欢迎的插件,由核心开发人员维护;它允许你在多个 CPU 下运行测试,以加快测试运行速度。

安装后,只需使用-n命令行标志来使用给定数量的 CPU 来运行测试:

λ pytest -n 4

就是这样!现在,你的测试将在四个核心上运行,希望能够加快测试套件的速度,如果测试是 CPU 密集型的话,尽管 I/O 绑定的测试不会看到太多改进。你也可以使用-n auto来让pytest-xdist自动计算出你可用的 CPU 数量。

请记住,当你的测试并行运行,并且以随机顺序运行时,它们必须小心避免相互干扰,例如,读/写到同一个目录。虽然它们应该是幂等的,但以随机顺序运行测试通常会引起之前潜伏的问题。

pytest-cov

pytest-cov插件与流行的 coverage 模块集成,当运行测试时提供详细的覆盖报告。这让你可以检测到没有被任何测试代码覆盖的代码部分,这是一个机会,可以编写更多的测试来覆盖这些情况。

安装后,您可以使用--cov选项在测试运行结束时提供覆盖报告:

λ pytest --cov=src
...
----------- coverage: platform win32, python 3.6.3-final-0 -----------
Name                  Stmts   Miss  Cover
----------------------------------------
src/series.py           108      5   96%
src/tests/test_series    22      0  100%
----------------------------------------
TOTAL                   130      5   97%

--cov选项接受应生成报告的源文件路径,因此根据项目的布局,您应传递您的src或包目录。

您还可以使用--cov-report选项以生成各种格式的报告:XML,annotate 和 HTML。后者特别适用于本地使用,因为它生成 HTML 文件,显示您的代码,未覆盖的行以红色突出显示,非常容易找到这些未覆盖的地方。

此插件还可以与pytest-xdist直接使用。

最后,此插件生成的.coverage文件与许多提供覆盖跟踪和报告的在线服务兼容,例如coveralls.iocoveralls.io/)和codecov.iocodecov.io/)。

pytest-faulthandler

此插件在运行测试时自动启用内置的faulthandlerdocs.python.org/3/library/faulthandler.html)模块,该模块在灾难性情况下(如分段错误)输出 Python 回溯。安装后,无需其他设置或标志;faulthandler模块将自动启用。

如果您经常使用用 C/C++编写的扩展模块,则强烈建议使用此插件,因为这些模块更容易崩溃。

pytest-mock

pytest-mock插件提供了一个 fixture,允许 pytest 和标准库的unittest.mockdocs.python.org/3/library/unittest.mock.html)模块之间更顺畅地集成。它提供了类似于内置的monkeypatch fixture 的功能,但是unittest.mock产生的模拟对象还记录有关它们如何被访问的信息。这使得许多常见的测试任务更容易,例如验证已调用模拟函数以及使用哪些参数。

该插件提供了一个mocker fixture,可用于修补类和方法。使用上一章中的getpass示例,以下是您可以使用此插件编写它的方式:

import getpass

def test_login_success(mocker):
    mocked = mocker.patch.object(getpass, "getpass", 
                                 return_value="valid-pass")
    assert user_login("test-user")
    mocked.assert_called_with("enter password: ")

请注意,除了替换getpass.getpass()并始终返回相同的值之外,我们还可以确保getpass函数已使用正确的参数调用。

在使用此插件时,与上一章中如何以及在哪里修补monkeypatch fixture 的建议也适用。

pytest-django

顾名思义,此插件允许您使用 pytest 测试您的Djangowww.djangoproject.com/)应用程序。Django是当今最著名的 Web 框架之一。

该插件提供了大量功能:

  • 一个非常好的快速入门教程

  • 命令行和pytest.ini选项来配置 Django

  • pytest-xdist兼容

  • 使用django_db标记访问数据库,在测试之间自动回滚事务,以及一堆 fixture,让您控制数据库的管理方式

  • 用于向应用程序发出请求的 fixture:clientadmin_clientadmin_user

  • 在后台线程中运行Django服务器的live_server fixture

总的来说,这是生态系统中最完整的插件之一,具有太多功能无法在此处覆盖。对于Django应用程序来说,这是必不可少的,因此请务必查看其广泛的文档。

pytest-flakes

此插件允许您使用pyflakespypi.org/project/pyflakes/)检查您的代码,这是一个用于常见错误的源文件的静态检查器,例如丢失的导入和未知变量。

安装后,使用--flakes选项来激活它:

λ pytest pytest-flakes.py --flake
...
============================= FAILURES ==============================
__________________________ pyflakes-check ___________________________
CH5\pytest-flakes.py:1: UnusedImport
'os' imported but unused
CH5\pytest-flakes.py:6: UndefinedName
undefined name 'unknown'

这将在你的正常测试中运行 flake 检查,使其成为保持代码整洁和防止一些错误的简单而廉价的方法。该插件还保留了自上次检查以来未更改的文件的本地缓存,因此在本地使用起来快速和方便。

pytest-asyncio

asyncio (docs.python.org/3/library/asyncio.html)模块是 Python 3 的热门新功能之一,提供了一个新的用于异步应用程序的框架。pytest-asyncio插件让你编写异步测试函数,轻松测试你的异步代码。

你只需要将你的测试函数标记为async def并使用asyncio标记:

@pytest.mark.asyncio
async def test_fetch_requests():
    requests = await fetch_requests("example.com/api")
    assert len(requests) == 2

该插件还在后台管理事件循环,提供了一些选项,以便在需要使用自定义事件循环时进行更改。

当然,你可以在异步函数之外拥有正常的同步测试函数。

pytest-trio

Trio 的座右铭是“Pythonic async I/O for humans” (trio.readthedocs.io/en/latest/)。它使用与asyncio标准模块相同的async def/await关键字,但被认为更简单和更友好,包含一些关于如何处理超时和一组并行任务的新颖想法,以避免并行编程中的常见错误。如果你对异步开发感兴趣,它绝对值得一试。

pytest-trio的工作方式类似于pytest-asyncio:你编写异步测试函数,并使用trio标记它们。它还提供了其他功能,使测试更容易和更可靠,例如可控的时钟用于测试超时,处理任务的特殊函数,模拟网络套接字和流,以及更多。

pytest-tornado

Tornado (www.tornadoweb.org/en/stable/)是一个 Web 框架和异步网络库。它非常成熟,在 Python 2 和 3 中工作,标准的asyncio模块从中借鉴了许多想法和概念。

pytest-asynciopytest-tornado的启发,因此它使用相同的想法,使用gen_test来标记你的测试为协程。它使用yield关键字而不是await,因为它支持 Python 2,但除此之外它看起来非常相似:

@pytest.mark.gen_test
def test_tornado(http_client):
    url = "https://docs.pytest.org/en/latest"
    response = yield http_client.fetch(url)
    assert response.code == 200

pytest-postgresql

该插件允许你测试需要运行的 PostgreSQL 数据库的代码。

以下是它的一个快速示例:

def test_fetch_series(postgresql):
    cur = postgresql.cursor()
    cur.execute('SELECT * FROM comedy_series;')
    assert len(cur.fetchall()) == 5
    cur.close()

它提供了两个 fixtures:

  • postgresql:一个客户端 fixture,启动并关闭到正在运行的测试数据库的连接。在测试结束时,它会删除测试数据库,以确保测试不会相互干扰。

  • postgresql_proc:一个会话范围的 fixture,每个会话启动一次 PostgreSQL 进程,并确保在结束时停止。

它还提供了几个配置选项,用于连接和配置测试数据库。

docker-services

该插件启动和管理你需要的 Docker 服务,以便测试你的代码。这使得运行测试变得简单,因为你不需要手动启动服务;插件将在测试会话期间根据需要启动和停止它们。

你可以使用.services.yaml文件来配置服务;这里是一个简单的例子:

database:
    image: postgres
    environment:
        POSTGRES_USERNAME: pytest-user
        POSTGRES_PASSWORD: pytest-pass
        POSTGRES_DB: test
    image: regis:10 

这将启动两个服务:postgresredis

有了这个,剩下的就是用以下命令运行你的套件:

pytest --docker-services

插件会处理剩下的事情。

pytest-selenium

Selenium 是一个针对自动化浏览器的框架,用于测试 Web 应用程序 (www.seleniumhq.org/)。它可以做诸如打开网页、点击按钮,然后确保某个页面加载等事情。它支持所有主流浏览器,并拥有一个蓬勃发展的社区。

pytest-selenium提供了一个 fixture,让你编写测试来完成所有这些事情,它会为你设置Selenium

以下是如何访问页面,点击链接并检查加载页面的标题的基本示例:

def test_visit_pytest(selenium):
    selenium.get("https://docs.pytest.org/en/latest/")
    assert "helps you write better programs" in selenium.title
    elem = selenium.find_element_by_link_text("Contents")
    elem.click()
    assert "Full pytest documentation" in selenium.title

Seleniumpytest-selenium足够复杂,可以测试从静态页面到完整的单页前端应用程序的各种应用。

pytest-html

pytest-html 生成美丽的 HTML 测试结果报告。安装插件后,只需运行以下命令:

λ pytest --html=report.html

这将在测试会话结束时生成一个report.html文件。

因为图片胜过千言万语,这里有一个例子:

报告可以在 Web 服务器上进行服务以便更轻松地查看,而且它们包含了一些很好的功能,比如复选框来显示/隐藏不同类型的测试结果,还有其他插件如pytest-selenium甚至能够在失败的测试中附加截图,就像前面的图片一样。

它绝对值得一试。

pytest-cpp

为了证明 pytest 框架非常灵活,pytest-cpp插件允许你运行用 Google Test (github.com/google/googletest) 或 Boost.Test (www.boost.org)编写的测试,这些是用 C++语言编写和运行测试的框架。

安装后,你只需要像平常一样运行 pytest:

λ pytest bin/tests

Pytest 将找到包含测试用例的可执行文件,并自动检测它们是用Google Test还是Boost.Python编写的。它将正常运行测试并报告结果,格式整齐,熟悉 pytest 用户。

使用 pytest 运行这些测试意味着它们现在可以利用一些功能,比如使用pytest-xdist进行并行运行,使用-k进行测试选择,生成 JUnitXML 报告等等。这个插件对于使用 Python 和 C++的代码库特别有用,因为它允许你用一个命令运行所有测试,并且你可以得到一个独特的报告。

pytest-timeout

pytest-timeout插件在测试达到一定超时后会自动终止测试。

你可以通过在命令行中设置全局超时来使用它:

λ pytest --timeout=60

或者你可以使用@pytest.mark.timeout标记单独的测试:

@pytest.mark.timeout(600)
def test_long_simulation():
   ...

它通过以下两种方法之一来实现超时机制:

  • thread:在测试设置期间,插件启动一个线程,该线程休眠指定的超时时间。如果线程醒来,它将将所有线程的回溯信息转储到stderr并杀死当前进程。如果测试在线程醒来之前完成,那么线程将被取消,测试继续运行。这是在所有平台上都有效的方法。

  • signal:在测试设置期间安排了一个SIGALRM,并在测试完成时取消。如果警报被触发,它将将所有线程的回溯信息转储到stderr并失败测试,但它将允许测试继续运行。与线程方法相比的优势是当超时发生时它不会取消整个运行,但它不支持所有平台。

该方法会根据平台自动选择,但可以在命令行或通过@pytest.mark.timeoutmethod=参数来进行更改。

这个插件在大型测试套件中是不可或缺的,以避免测试挂起 CI。

pytest-annotate

Pyannotate (github.com/dropbox/pyannotate) 是一个观察运行时类型信息并将该信息插入到源代码中的项目,而pytest-annotate使得在 pytest 中使用它变得很容易。

让我们回到这个简单的测试用例:

def highest_rated(series):
    return sorted(series, key=itemgetter(2))[-1][0]

def test_highest_rated():
    series = [
        ("The Office", 2005, 8.8),
        ("Scrubs", 2001, 8.4),
        ("IT Crowd", 2006, 8.5),
        ("Parks and Recreation", 2009, 8.6),
        ("Seinfeld", 1989, 8.9),
    ]
    assert highest_rated(series) == "Seinfeld"

安装了pytest-annotate后,我们可以通过传递--annotations-output标志来生成一个注释文件:

λ pytest --annotate-output=annotations.json

这将像往常一样运行测试套件,但它将收集类型信息以供以后使用。

之后,你可以调用PyAnnotate将类型信息直接应用到源代码中:

λ pyannotate --type-info annotations.json -w
Refactored test_series.py
--- test_series.py (original)
+++ test_series.py (refactored)
@@ -1,11 +1,15 @@
 from operator import itemgetter
+from typing import List
+from typing import Tuple

 def highest_rated(series):
+    # type: (List[Tuple[str, int, float]]) -> str
 return sorted(series, key=itemgetter(2))[-1][0]

 def test_highest_rated():
+    # type: () -> None
 series = [
 ("The Office", 2005, 8.8),
 ("Scrubs", 2001, 8.4),
Files that were modified:
pytest-annotate.py

快速高效地注释大型代码库是非常整洁的,特别是如果该代码库已经有了完善的测试覆盖。

pytest-qt

pytest-qt插件允许您为使用Qt框架(www.qt.io/)编写的 GUI 应用程序编写测试,支持更受欢迎的 Python 绑定集:PyQt4/PyQt5PySide/PySide2

它提供了一个qtbot装置,其中包含与 GUI 应用程序交互的方法,例如单击按钮、在字段中输入文本、等待窗口弹出等。以下是一个快速示例,展示了它的工作原理:

def test_main_window(qtbot):
    widget = MainWindow()
    qtbot.addWidget(widget)

    qtbot.mouseClick(widget.about_button, QtCore.Qt.LeftButton)
    qtbot.waitUntil(widget.about_box.isVisible)
    assert widget.about_box.text() == 'This is a GUI App'

在这里,我们创建一个窗口,单击“关于”按钮,等待“关于”框弹出,然后确保它显示我们期望的文本。

它还包含其他好东西:

  • 等待特定Qt信号的实用程序

  • 自动捕获虚拟方法中的错误

  • 自动捕获Qt日志消息

pytest-randomly

测试理想情况下应该是相互独立的,确保在测试完成后进行清理,这样它们可以以任何顺序运行,而且不会以任何方式相互影响。

pytest-randomly通过随机排序测试,每次运行测试套件时更改它们的顺序,帮助您保持测试套件的真实性。这有助于检测测试是否具有隐藏的相互依赖性,否则您将无法发现。

它会在模块级别、类级别和函数顺序上对测试项进行洗牌。它还会在每个测试之前将random.seed()重置为一个固定的数字,该数字显示在测试部分的开头。可以在以后使用随机种子通过--randomly-seed命令行来重现失败。

作为额外的奖励,它还特别支持factory boyfactoryboy.readthedocs.io/en/latest/reference.html)、fakerpypi.python.org/pypi/faker)和numpywww.numpy.org/)库,在每个测试之前重置它们的随机状态。

pytest-datadir

通常,测试需要一个支持文件,例如一个包含有关喜剧系列数据的 CSV 文件,就像我们在上一章中看到的那样。pytest-datadir允许您将文件保存在测试旁边,并以安全的方式从测试中轻松访问它们。

假设您有这样的文件结构:

tests/
    test_series.py

除此之外,您还有一个series.csv文件,需要从test_series.py中定义的测试中访问。

安装了pytest-datadir后,您只需要在相同目录中创建一个与测试文件同名的目录,并将文件放在其中:

tests/
 test_series/
 series.csv
    test_series.py

test_series目录和series.csv应该保存到您的版本控制系统中。

现在,test_series.py中的测试可以使用datadir装置来访问文件:

def test_ratings(datadir):
    with open(datadir / "series.csv", "r", newline="") as f:
        data = list(csv.reader(f))
    ...

datadir是一个指向数据目录的 Path 实例(docs.python.org/3/library/pathlib.html)。

需要注意的一点是,当我们在测试中使用datadir装置时,我们并不是访问原始文件的路径,而是临时副本。这确保了测试可以修改数据目录中的文件,而不会影响其他测试,因为每个测试都有自己的副本。

pytest-regressions

通常情况下,您的应用程序或库包含产生数据集作为结果的功能。

经常测试这些结果是很繁琐且容易出错的,产生了这样的测试:

def test_obtain_series_asserts():
    data = obtain_series()
    assert data[0]["name"] == "The Office"
    assert data[0]["year"] == 2005
    assert data[0]["rating"] == 8.8
    assert data[1]["name"] == "Scrubs"
    assert data[1]["year"] == 2001
    ...

这很快就会变得老套。此外,如果任何断言失败,那么测试就会在那一点停止,您将不知道在那一点之后是否还有其他断言失败。换句话说,您无法清楚地了解整体失败的情况。最重要的是,这也是非常难以维护的,因为如果obtain_series()返回的数据发生变化,您将不得不进行繁琐且容易出错的代码更新任务。

pytest-regressions提供了解决这类问题的装置。像前面的例子一样,一般的数据是data_regression装置的工作:

def test_obtain_series(data_regression):
    data = obtain_series()
    data_regression.check(data)

第一次执行此测试时,它将失败,并显示如下消息:

...
E Failed: File not found in data directory, created:
E - CH5\test_series\test_obtain_series.yml

它将以一个格式良好的 YAML 文件的形式将传递给data_regression.check()的数据转储到test_series.py文件的数据目录中(这要归功于我们之前看到的pytest-datadir装置):

- name: The Office
  rating: 8.8
  year: 2005
- name: Scrubs
  rating: 8.4
  year: 2001
- name: IT Crowd
  rating: 8.5
  year: 2006
- name: Parks and Recreation
  rating: 8.6
  year: 2009
- name: Seinfeld
  rating: 8.9
  year: 1989

下次运行此测试时,data_regression现在将传递给data_regressions.check()的数据与数据目录中的test_obtain_series.yml中找到的数据进行比较。如果它们匹配,测试通过。

然而,如果数据发生了变化,测试将失败,并显示新数据与记录数据之间的差异:

E AssertionError: FILES DIFFER:
E ---
E
E +++
E
E @@ -13,3 +13,6 @@
E
E  - name: Seinfeld
E    rating: 8.9
E    year: 1989
E +- name: Rock and Morty
E +  rating: 9.3
E +  year: 2013

在某些情况下,这可能是一个回归,这种情况下你可以在代码中找到错误。

但在这种情况下,新数据是正确的;你只需要用--force-regen标志运行 pytest,pytest-regressions将为你更新数据文件的新内容:

E Failed: Files differ and --force-regen set, regenerating file at:
E - CH5\test_series\test_obtain_series.yml

现在,如果我们再次运行测试,测试将通过,因为文件包含了新数据。

当你有数十个测试突然产生不同但正确的结果时,这将极大地节省时间。你可以通过单次 pytest 执行将它们全部更新。

我自己使用这个插件,我数不清它为我节省了多少时间。

值得一提的是

有太多好的插件无法放入本章。前面的示例只是一个小小的尝试,我试图在有用、有趣和展示插件架构的灵活性之间取得平衡。

以下是一些值得一提的其他插件:

  • pytest-bdd:pytest 的行为驱动开发

  • pytest-benchmark:用于对代码进行基准测试的装置。它以彩色输出输出基准测试结果

  • pytest-csv:将测试状态输出为 CSV 文件

  • pytest-docker-compose:在测试运行期间使用 Docker compose 管理 Docker 容器

  • pytest-excel:以 Excel 格式输出测试状态报告

  • pytest-git:为需要处理 git 仓库的测试提供 git 装置

  • pytest-json:将测试状态输出为 json 文件

  • pytest-leaks:通过重复运行测试并比较引用计数来检测内存泄漏

  • pytest-menu:允许用户从控制台菜单中选择要运行的测试

  • pytest-mongo:MongoDB 的进程和客户端装置

  • pytest-mpl:测试 Matplotlib 输出的图形的插件

  • pytest-mysql:MySQL 的进程和客户端装置

  • pytest-poo:用"pile of poo"表情符号替换失败测试的F字符

  • pytest-rabbitmq:RabbitMQ 的进程和客户端装置

  • pytest-redis:Redis 的进程和客户端装置

  • pytest-repeat:重复所有测试或特定测试多次以查找间歇性故障

  • pytest-replay:保存测试运行并允许用户以后执行它们,以便重现崩溃和不稳定的测试

  • pytest-rerunfailures:标记可以运行多次以消除不稳定测试的测试

  • pytest-sugar:通过添加进度条、表情符号、即时失败等来改变 pytest 控制台的外观和感觉

  • pytest-tap:以 TAP 格式输出测试报告

  • pytest-travis-fold:在 Travis CI 构建日志中折叠捕获的输出和覆盖报告

  • pytest-vagrant:与 vagrant boxes 一起使用的 pytest 装置

  • pytest-vcr:使用简单的标记自动管理VCR.py磁带

  • pytest-virtualenv:提供一个虚拟环境装置来管理测试中的虚拟环境

  • pytest-watch:持续监视源代码的更改并重新运行 pytest

  • pytest-xvfb:为 UI 测试运行Xvfb(虚拟帧缓冲区)

  • tavern:使用基于 YAML 的语法对 API 进行自动化测试

  • xdoctest:重写内置的 doctests 模块,使得编写和配置 doctests 更加容易

请记住,在撰写本文时,pytest 插件的数量已经超过 500 个,所以一定要浏览插件列表,以便找到自己喜欢的东西。

总结

在本章中,我们看到了查找和安装插件是多么容易。我们还展示了一些我每天使用并且觉得有趣的插件。我希望这让你对 pytest 的可能性有所了解,但请探索大量的插件,看看是否有任何有用的。

创建自己的插件不是本书涵盖的主题,但如果你感兴趣,这里有一些资源可以帮助你入门:

在下一章中,我们将学习如何将 pytest 与现有的基于unittest的测试套件一起使用,包括有关如何迁移它们并逐步使用更多 pytest 功能的提示和建议。

第五章:将 unittest 套件转换为 pytest

在上一章中,我们已经看到了灵活的 pytest 架构如何创建了丰富的插件生态系统,拥有数百个可用的插件。我们学习了如何轻松找到和安装插件,并概述了一些有趣的插件。

现在您已经熟练掌握 pytest,您可能会遇到这样的情况,即您有一个或多个基于unittest的测试套件,并且希望开始使用 pytest 进行测试。在本章中,我们将讨论从简单的测试套件开始做到这一点的最佳方法,这可能需要很少或根本不需要修改,到包含多年来有机地增长的各种自定义的大型内部测试套件。本章中的大多数提示和建议都来自于我在 ESSS(wwww.esss.co)工作时迁移我们庞大的unittest风格测试套件的经验。

以下是本章将涵盖的内容:

  • 使用 pytest 作为测试运行器

  • 使用unittest2pytest转换断言

  • 处理设置和拆卸

  • 管理测试层次结构

  • 重构测试工具

  • 迁移策略

使用 pytest 作为测试运行器

令人惊讶的是,许多人不知道的一件事是,pytest 可以直接运行unittest套件,无需任何修改。

例如:

class Test(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = cls.temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(cls.temp_dir)

    def setUp(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

    def test_read_properties(self):
        self.assertEqual(self.grids[0], GridData("Main Grid", 48, 44))
        self.assertEqual(self.grids[1], GridData("2nd Grid", 24, 21))
        self.assertEqual(self.grids[2], GridData("3rd Grid", 24, 48))

    def test_invalid_path(self):
        with self.assertRaises(IOError):
            list(iter_grids_from_csv(Path("invalid file")))

    @unittest.expectedFailure
    def test_write_properties(self):
        self.fail("not implemented yet")

我们可以使用unittest运行器来运行这个:

..x
----------------------------------------------------------------------
Ran 3 tests in 0.005s

OK (expected failures=1)

但很酷的是,pytest 也可以在不进行任何修改的情况下运行此测试:

λ pytest test_simple.py
======================== test session starts ========================
...
collected 3 items

test_simple.py ..x                                             [100%]

================ 2 passed, 1 xfailed in 0.11 seconds ================

这使得使用 pytest 作为测试运行器变得非常容易,带来了几个好处:

  • 您可以使用插件,例如pytest-xdist,来加速测试套件。

  • 您可以使用几个命令行选项:-k选择测试,--pdb在错误时跳转到调试器,--lf仅运行上次失败的测试,等等。

  • 您可以停止编写self.assert*方法,改用普通的assert。 pytest 将愉快地提供丰富的失败信息,即使对于基于unittest的子类也是如此。

为了完整起见,以下是直接支持的unittest习语和功能:

  • setUptearDown用于函数级setup/teardown

  • setUpClasstearDownClass用于类级setup/teardown

  • setUpModuletearDownModule用于模块级setup/teardown

  • skipskipIfskipUnlessexpectedFailure装饰器,用于函数和类

  • TestCase.skipTest用于在测试内部进行命令式跳过

目前不支持以下习语:

pytest-xdist的惊喜

如果您决定在测试套件中使用pytest-xdist,请注意它会以任意顺序运行测试:每个工作进程将在完成其他测试后运行测试,因此测试执行的顺序是不可预测的。因为默认的unittest运行程序会按顺序顺序运行测试,并且通常以相同的顺序运行,这将经常暴露出测试套件中的并发问题,例如,试图使用相同名称创建临时目录的测试。您应该将这视为修复潜在并发问题的机会,因为它们本来就不应该是测试套件的一部分。

unittest 子类中的 pytest 特性

尽管不是设计为在运行基于unittest的测试时支持所有其特性,但是支持一些 pytest 习语:

  • 普通断言:当子类化unittest.TestCase时,pytest 断言内省的工作方式与之前一样

  • 标记:标记可以正常应用于unittest测试方法和类。处理标记的插件在大多数情况下应该正常工作(例如pytest-timeout标记)

  • 自动使用固定装置:在模块或conftest.py文件中定义的自动使用固定装置将在正常执行unittest测试方法时创建/销毁,包括在类范围的自动使用固定装置的情况下

  • 测试选择:命令行中的-k-m应该像正常一样工作

其他 pytest 特性与unittest不兼容,特别是:

  • 固定装置unittest测试方法无法请求固定装置。Pytest 使用unittest自己的结果收集器来执行测试,该收集器不支持向测试函数传递参数

  • 参数化:由于与固定装置的原因相似,这也不受支持:我们需要传递参数化值,目前这是不可能的。

不依赖于固定装置的插件可能会正常工作,例如pytest-timeoutpytest-randomly

使用 unitest2pytest 转换断言

一旦您将测试运行程序更改为 pytest,您就可以利用编写普通的断言语句来代替self.assert*方法。

转换所有的方法调用是无聊且容易出错的,这就是unittest2pytest工具存在的原因。它将所有的self.assert*方法调用转换为普通的断言,并将self.assertRaises调用转换为适当的 pytest 习语。

使用pip安装它:

λ pip install unittest2pytest

安装完成后,您现在可以在想要的文件上执行它:

λ unittest2pytest test_simple2.py
RefactoringTool: Refactored test_simple2.py
--- test_simple2.py (original)
+++ test_simple2.py (refactored)
@@ -5,6 +5,7 @@
 import unittest
 from collections import namedtuple
 from pathlib import Path
+import pytest

 DATA = """
 Main Grid,48,44
@@ -49,12 +50,12 @@
 self.grids = list(iter_grids_from_csv(self.filepath))

 def test_read_properties(self):
-        self.assertEqual(self.grids[0], GridData("Main Grid", 48, 44))
-        self.assertEqual(self.grids[1], GridData("2nd Grid", 24, 21))
-        self.assertEqual(self.grids[2], GridData("3rd Grid", 24, 48))
+        assert self.grids[0] == GridData("Main Grid", 48, 44)
+        assert self.grids[1] == GridData("2nd Grid", 24, 21)
+        assert self.grids[2] == GridData("3rd Grid", 24, 48)

 def test_invalid_path(self):
-        with self.assertRaises(IOError):
+        with pytest.raises(IOError):
 list(iter_grids_from_csv(Path("invalid file")))

 @unittest.expectedFailure
RefactoringTool: Files that need to be modified:
RefactoringTool: test_simple2.py

默认情况下,它不会触及文件,只会显示它可以应用的更改的差异。要实际应用更改,请传递-wn--write--nobackups)。

请注意,在上一个示例中,它正确地替换了self.assert*调用,self.assertRaises,并添加了pytest导入。它没有更改我们测试类的子类,因为这可能会有其他后果,具体取决于您正在使用的实际子类,因此unittest2pytest会保持不变。

更新后的文件运行方式与以前一样:

λ pytest test_simple2.py
======================== test session starts ========================
...
collected 3 items

test_simple2.py ..x                                            [100%]

================ 2 passed, 1 xfailed in 0.10 seconds ================

采用 pytest 作为运行程序,并能够使用普通的断言语句是一个经常被低估的巨大收获:不再需要一直输入self.assert...是一种解放。

在撰写本文时,unittest2pytest尚未处理最后一个测试中的self.fail("not implemented yet")语句。因此,我们需要手动用assert 0, "not implemented yet"替换它。也许您想提交一个 PR 来改进这个项目?(github.com/pytest-dev/unittest2pytest)。

处理设置/拆卸

要完全将TestCase子类转换为 pytest 风格,我们需要用 pytest 的习语替换unittest。我们已经在上一节中看到了如何使用unittest2pytest来做到这一点。但是我们能对setUptearDown方法做些什么呢?

正如我们之前学到的,TestCase子类中的autouse fixtures 工作得很好,所以它们是替换setUptearDown方法的一种自然方式。让我们使用上一节的例子。

在转换assert语句之后,首先要做的是删除unittest.TestCase的子类化:

class Test(unittest.TestCase):
    ...

这变成了以下内容:

class Test:
    ...

接下来,我们需要将setup/teardown方法转换为 fixture 等效方法:

    @classmethod
    def setUpClass(cls):
        cls.temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = cls.temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(cls.temp_dir)

因此,类作用域的setUpClasstearDownClass方法将成为一个单一的类作用域 fixture:

    @classmethod
    @pytest.fixture(scope='class', autouse=True)
    def _setup_class(cls):
        temp_dir = Path(tempfile.mkdtemp())
        cls.filepath = temp_dir / "data.csv"
        cls.filepath.write_text(DATA.strip())
        yield
        shutil.rmtree(temp_dir)

由于yield语句,我们可以很容易地在 fixture 本身中编写拆卸代码,就像我们已经学到的那样。

以下是一些观察:

  • Pytest 不在乎我们如何称呼我们的 fixture,所以我们可以继续使用旧的setUpClass名称。我们选择将其更改为setup_class,有两个目标:避免混淆这段代码的读者,因为它可能看起来仍然是一个TestCase子类,并且使用_前缀表示这个 fixture 不应该像普通的 pytest fixture 一样使用。

  • 我们将temp_dir更改为局部变量,因为我们不再需要在cls中保留它。以前,我们不得不这样做,因为我们需要在tearDownClass期间访问cls.temp_dir,但现在我们可以将其保留为一个局部变量,并在yield语句之后访问它。这是使用yield将设置和拆卸代码分开的美妙之一:你不需要保留上下文变量;它们自然地作为函数的局部变量保留。

我们使用相同的方法来处理setUp方法:

    def setUp(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

这变成了以下内容:

    @pytest.fixture(autouse=True)
    def _setup(self):
        self.grids = list(iter_grids_from_csv(self.filepath))

这种技术非常有用,因为你可以通过一组最小的更改得到一个纯粹的 pytest 类。此外,像我们之前做的那样为 fixtures 使用命名约定,有助于向读者传达 fixtures 正在转换旧的setup/teardown习惯。

现在这个类是一个合适的 pytest 类,你可以自由地使用 fixtures 和参数化。

管理测试层次结构

正如我们所看到的,在大型测试套件中需要共享功能是很常见的。由于unittest是基于子类化TestCase,所以在TestCase子类本身中放置额外的功能是很常见的。例如,如果我们需要测试需要数据库的应用逻辑,我们可能最初会直接在我们的TestCase子类中添加启动和连接到数据库的功能:

class Test(unittest.TestCase):

    def setUp(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def tearDown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    def create_table(self, table_name, **fields):
        ...

    def check_row(self, table_name, **query):
        ...

    def test1(self):
        self.create_table("weapons", name=str, type=str, dmg=int)
        ...

这对于单个测试模块效果很好,但通常情况下,我们需要在以后的某个时候在另一个测试模块中使用这个功能。unittest模块没有内置的功能来共享常见的setup/teardown代码,所以大多数人自然而然地会将所需的功能提取到一个超类中,然后在需要的地方从中创建一个子类:

# content of testing.py
class DataBaseTesting(unittest.TestCase):

    def setUp(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def tearDown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    def create_table(self, table_name, **fields):
        ...

    def check_row(self, table_name, **query):
        ...

# content of test_database2.py
from . import testing

class Test(testing.DataBaseTesting):

    def test1(self):
        self.create_table("weapons", name=str, type=str, dmg=int)
        ...

超类通常不仅包含setup/teardown代码,而且通常还包括调用self.assert*执行常见检查的实用函数(例如在上一个例子中的check_row)。

继续我们的例子:一段时间后,我们需要在另一个测试模块中完全不同的功能,例如,测试一个 GUI 应用程序。我们现在更加明智,怀疑我们将需要在几个其他测试模块中使用 GUI 相关的功能,所以我们首先创建一个具有我们直接需要的功能的超类:

class GUITesting(unittest.TestCase):

    def setUp(self):
        self.app = self.create_app()

    def tearDown(self):
        self.app.close_all_windows()

    def mouse_click(self, window, button):
        ...

    def enter_text(self, window, text):
        ...

setup/teardown和测试功能移动到超类的方法是可以的,并且易于理解。

当我们需要在同一个测试模块中使用两个不相关的功能时,问题就出现了。在这种情况下,我们别无选择,只能求助于多重继承。假设我们需要测试连接到数据库的对话框;我们将需要编写这样的代码:

from . import testing

class Test(testing.DataBaseTesting, testing.GUITesting):

    def setUp(self):
 testing.DataBaseTesting.setUp(self)
 testing.GUITesting.setUp(self)

    def tearDown(self):
 testing.GUITesting.setUp(self)
 testing.DataBaseTesting.setUp(self)

一般来说,多重继承会使代码变得不太可读,更难以理解。在这里,它还有一个额外的恼人之处,就是我们需要显式地按正确的顺序调用setUptearDown

还要注意的一点是,在 unittest 框架中,setUptearDown 是可选的,因此如果某个类不需要任何拆卸代码,通常不会声明 tearDown 方法。如果此类包含的功能后来移动到超类中,许多子类可能也不会声明 tearDown 方法。问题出现在后来的多重继承场景中,当您改进超类并需要添加 tearDown 方法时,因为现在您必须检查所有子类,并确保它们调用超类的 tearDown 方法。

因此,假设我们发现自己处于前述情况,并且希望开始使用与 TestCase 测试不兼容的 pytest 功能。我们如何重构我们的实用类,以便我们可以自然地从 pytest 中使用它们,并且保持现有的基于 unittest 的测试正常工作?

使用 fixtures 重用测试代码

我们应该做的第一件事是将所需的功能提取到定义良好的 fixtures 中,并将它们放入 conftest.py 文件中。继续我们的例子,我们可以创建 db_testinggui_testing fixtures:

class DataBaseFixture:

    def __init__(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    def teardown(self):
        self.session.close()
        os.remove(self.db_file)

    def create_temporary_db(self):
        ...

    def connect_db(self, db_file):
        ...

    ...

@pytest.fixture
def db_testing():
    fixture = DataBaseFixture()
    yield fixture
    fixture.teardown()

class GUIFixture:

    def __init__(self):
        self.app = self.create_app()

    def teardown(self):
        self.app.close_all_windows()

    def mouse_click(self, window, button):
        ...

    def enter_text(self, window, text):
        ...

@pytest.fixture
def gui_testing():
    fixture = GUIFixture()
    yield fixture
    fixture.teardown()

现在,您可以开始使用纯 pytest 风格编写新的测试,并使用 db_testinggui_testing fixtures,这很棒,因为它为在新测试中使用 pytest 功能打开了大门。但这里很酷的一点是,我们现在可以更改 DataBaseTestingGUITesting 来重用 fixtures 提供的功能,而不会破坏现有代码:

class DataBaseTesting(unittest.TestCase):

    @pytest.fixture(autouse=True)
    def _setup(self, db_testing):
 self._db_testing = db_testing

    def create_temporary_db(self):
        return self._db_testing.create_temporary_db()

    def connect_db(self, db_file):
        return self._db_testing.connect_db(db_file)

    ...

class GUITesting(unittest.TestCase):

    @pytest.fixture(autouse=True)
 def _setup(self, gui_testing):
 self._gui_testing = gui_testing

    def mouse_click(self, window, button):
        return self._gui_testing.mouse_click(window, button)

    ...

我们的 DatabaseTestingGUITesting 类通过声明一个自动使用的 _setup fixture 来获取 fixture 值,这是我们在本章早期学到的一个技巧。我们可以摆脱 tearDown 方法,因为 fixture 将在每次测试后自行清理,而实用方法变成了在 fixture 中实现的方法的简单代理。

作为奖励分,GUIFixtureDataBaseFixture 也可以使用其他 pytest fixtures。例如,我们可能可以移除 DataBaseTesting.create_temporary_db(),并使用内置的 tmpdir fixture 为我们创建临时数据库文件:

class DataBaseFixture:

    def __init__(self, tmpdir):
        self.db_file = str(tmpdir / "file.db")
        self.session = self.connect_db(self.db_file)

    def teardown(self):
        self.session.close()

    ...

@pytest.fixture
def db_testing(tmpdir):
    fixture = DataBaseFixture(tmpdir)
    yield fixture
    fixture.teardown()

然后使用其他 fixtures 可以极大地简化现有的测试实用程序代码。

值得强调的是,这种重构不需要对现有测试进行任何更改。这里,fixtures 的一个好处再次显而易见:fixture 的要求变化不会影响使用 fixture 的测试。

重构测试实用程序

在前一节中,我们看到测试套件可能使用子类来共享测试功能,并且如何将它们重构为 fixtures,同时保持现有的测试正常工作。

unittest 套件中通过超类共享测试功能的另一种选择是编写单独的实用类,并在测试中使用它们。回到我们的例子,我们需要具有与数据库相关的设施,这是一种在 unittest 友好的方式实现的方法,而不使用超类:

# content of testing.py
class DataBaseTesting:

    def __init__(self, test_case):        
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)
        self.test_case = test_case
        test_case.addCleanup(self.teardown)

    def teardown(self):
        self.session.close()
        os.remove(self.db_file)

    ...

    def check_row(self, table_name, **query):
        row = self.session.find(table_name, **query)
        self.test_case.assertIsNotNone(row)
        ...

# content of test_1.py
from testing import DataBaseTesting

class Test(unittest.TestCase):

    def test_1(self):
        db_testing = DataBaseTesting(self)
        db_testing.create_table("weapons", name=str, type=str, dmg=int)
        db_testing.check_row("weapons", name="zweihander")
        ...

在这种方法中,我们将测试功能分离到一个类中,该类将当前的 TestCase 实例作为第一个参数,然后是任何其他所需的参数。

TestCase实例有两个目的:为类提供对各种self.assert*函数的访问,并作为一种方式向TestCase.addCleanup注册清理函数(docs.python.org/3/library/unittest.html#unittest.TestCase.addCleanup)。TestCase.addCleanup注册的函数将在每个测试完成后调用,无论它们是否成功。我认为它们是setUp/tearDown函数的一个更好的替代方案,因为它们允许资源被创建并立即注册进行清理。在setUp期间创建所有资源并在tearDown期间释放它们的缺点是,如果在setUp方法中引发任何异常,那么tearDown将根本不会被调用,从而泄漏资源和状态,这可能会影响后续的测试。

如果您的unittest套件使用这种方法进行测试设施,那么好消息是,您可以轻松地转换/重用这些功能以供 pytest 使用。

因为这种方法与 fixtures 的工作方式非常相似,所以很容易稍微改变类以使其作为 fixtures 工作:

# content of testing.py
class DataBaseFixture:

    def __init__(self):
        self.db_file = self.create_temporary_db()
        self.session = self.connect_db(self.db_file)

    ...

    def check_row(self, table_name, **query):
        row = self.session.find(table_name, **query)
        assert row is not None

# content of conftest.py
@pytest.fixture
def db_testing():
    from .testing import DataBaseFixture
    result = DataBaseFixture()
    yield result
    result.teardown()

我们摆脱了对TestCase实例的依赖,因为我们的 fixture 现在负责调用teardown(),并且我们可以自由地使用普通的 asserts 而不是Test.assert*方法。

为了保持现有的套件正常工作,我们只需要创建一个薄的子类来处理在与TestCase子类一起使用时的清理:

# content of testing.py
class DataBaseTesting(DataBaseFixture):

    def __init__(self, test_case):
        super().__init__()
        test_case.addCleanup(self.teardown) 

通过这种小的重构,我们现在可以在新测试中使用原生的 pytest fixtures,同时保持现有的测试与以前完全相同的工作方式。

虽然这种方法效果很好,但一个问题是,不幸的是,我们无法在DataBaseFixture类中使用其他 pytest fixtures(例如tmpdir),而不破坏在TestCase子类中使用DataBaseTesting的兼容性。

迁移策略

能够立即使用 pytest 作为运行器开始使用unittest-based 测试绝对是一个非常强大的功能。

最终,您需要决定如何处理现有的基于unittest的测试。您可以选择几种方法:

  • 转换所有内容:如果您的测试套件相对较小,您可能决定一次性转换所有测试。这样做的好处是,您不必妥协以保持现有的unittest套件正常工作,并且更容易被他人审查,因为您的拉取请求将具有单一主题。

  • 边转换边进行:您可能决定根据需要转换测试和功能。当您需要添加新测试或更改现有测试时,您可以利用这个机会转换测试和/或重构功能,使用前几节中的技术来创建 fixtures。如果您不想花时间一次性转换所有内容,而是慢慢地铺平道路,使 pytest 成为唯一的测试套件,那么这是一个很好的方法。

  • 仅新测试:您可能决定永远不触及现有的unittest套件,只在 pytest 风格中编写新测试。如果您有成千上万的测试,可能永远不需要进行维护,那么这种方法是合理的,但您将不得不保持前几节中展示的混合方法永远正常工作。

根据您的时间预算和测试套件的大小选择要使用的迁移策略。

总结

我们已经讨论了一些关于如何在各种规模的基于unittest的测试套件中使用 pytest 的策略和技巧。我们从讨论如何使用 pytest 作为测试运行器开始,以及哪些功能适用于TestCase测试。我们看了看如何使用unittest2pytest工具将self.assert*方法转换为普通的 assert 语句,并充分利用 pytest 的内省功能。然后,我们学习了一些关于如何将基于unittestsetUp/tearDown代码迁移到 pytest 风格的测试类中的技巧,管理在测试层次结构中分散的功能,以及一般的实用工具。最后,我们总结了可能的迁移策略概述,适用于各种规模的测试套件。

在下一章中,我们将简要总结本书学到的内容,并讨论接下来可能会有什么。

第六章:总结

在上一章中,我们学习了一些技术,可以用来将基于unittest的测试套件转换为 pytest,从简单地将其用作运行器,一直到将复杂的现有功能转换为更符合 pytest 风格的方式。

这是本快速入门指南的最后一章,我们将讨论以下主题:

  • 我们学到了什么

  • pytest 社区

  • 下一步

  • 最终总结

我们学到了什么

接下来的章节将总结我们在本书中学到的内容。

介绍

  • 您应该考虑编写测试作为您的安全网。这将使您对自己的工作更有信心,允许您放心地进行重构,并确保您没有破坏系统的其他部分。

  • 如果您正在将 Python 2 代码库转换为 Python 3,测试套件是必不可少的,因为任何指南都会告诉您,(docs.python.org/3/howto/pyporting.html#have-good-test-coverage)。

  • 如果您依赖的外部 API没有自动化测试,为其编写测试是一个好主意。

  • pytest 之所以是初学者的绝佳选择之一,是因为它很容易上手;使用简单的函数和assert语句编写您的测试。

编写和运行测试

  • 始终使用虚拟环境来管理您的软件包和依赖关系。这个建议适用于任何 Python 项目。

  • pytest 的内省功能使得表达您的检查变得简洁;可以直接比较字典、文本和列表。

  • 使用pytest.raises检查异常和pytest.warns检查警告。

  • 使用pytest.approx比较浮点数和数组。

  • 测试组织;您可以将您的测试内联到应用程序代码中,也可以将它们保存在一个单独的目录中。

  • 使用-k标志选择测试:-k test_something

  • 使用-x第一个失败时停止。

  • 记住了重构二人组--lf -x

  • 使用-s禁用输出捕获

  • 使用-ra显示测试失败、xfails 和跳过的完整摘要

  • 使用pytest.ini进行每个存储库的配置

标记和参数化

  • 在测试函数和类中使用@pytest.mark装饰器创建标记。要应用到模块,请使用pytestmark特殊变量。

  • 使用@pytest.mark.skipif@pytest.mark.skippytest.importorskip("module")来跳过当前环境不适用的测试。

  • 使用@pytest.mark.xfail(strict=True)pytest.xfail("reason")来标记预期失败的测试。

  • 使用@pytest.mark.xfail(strict=False)来标记不稳定的测试

  • 使用@pytest.mark.parametrize快速测试多个输入的代码和测试相同接口的不同实现

Fixture

  • Fixture是 pytest 的主要特性之一,用于共享资源并提供易于使用的测试辅助工具

  • 使用conftest.py文件在测试模块之间共享 fixtures。记得优先使用本地导入以加快测试收集速度。

  • 使用autouse fixture 确保层次结构中的每个测试都使用某个 fixture 来执行所需的设置或拆卸操作。

  • Fixture 可以假定多个范围functionclassmodulesession。明智地使用它们来减少测试套件的总时间,记住高级 fixture 实例在测试之间是共享的。

  • 可以使用@pytest.fixture装饰器的params参数对fixture 进行参数化。使用参数化 fixture 的所有测试将自动进行参数化,使其成为一个非常强大的工具。

  • 使用tmpdirtmpdir_factory创建空目录。

  • 使用monkeypatch临时更改对象、字典和环境变量的属性。

  • 使用capsyscapfd来捕获和验证发送到标准输出和标准错误的输出。

  • fixture 的一个重要特性是它们抽象了依赖关系,在使用简单函数与 fixture之间存在平衡。

插件

  • 使用plugincompat (plugincompat.herokuapp.com/) 和 PyPI (pypi.org/) 搜索新插件。

  • 插件安装简单:使用pip安装,它们会自动激活。

  • 有大量的插件可供使用,满足各种需求。

将 unittest 套件转换为 pytest

  • 你可以从切换到pytest 作为运行器开始。通常情况下,这可以在现有代码中不做任何更改的情况下完成。

  • 使用unittest2pytestself.assert*方法转换为普通的assert

  • 现有的设置拆卸代码可以通过autouse fixtures 进行小的重构后重复使用。

  • 可以将复杂的测试工具层次结构重构为更模块化的 fixture,同时保持现有的测试工作。

  • 有许多方法可以进行迁移:一次性转换所有内容,转换现有测试时逐步转换测试,或者仅在测试中使用 pytest。这取决于你的测试套件大小和时间预算。

pytest 社区

我们的社区位于 GitHub 的pytest-dev组织(github.com/pytest-dev)和 BitBucket(bitbucket.org/pytest-dev)。pytest 仓库(github.com/pytest-dev/pytest)本身托管在 GitHub 上,而 GitHub 和 Bitbucket 都托管了许多插件。成员们努力使社区对来自各个背景的新贡献者尽可能友好和欢迎。我们还在pytest-dev@python.org上有一个邮件列表,欢迎所有人加入(mail.python.org/mailman/listinfo/pytest-dev)。

大多数 pytest-dev 成员居住在西欧,但我们有来自全球各地的成员,包括阿联酋、俄罗斯、印度和巴西(我就住在那里)。

参与其中

因为所有 pytest 的维护完全是自愿的,我们一直在寻找愿意加入社区并帮助改进 pytest 及其插件的人,与他人诚信合作。有许多参与的方式:

成为贡献者很容易;你只需要贡献一个关于相关代码更改、文档或错误修复的拉取请求,如果愿意,你就可以成为pytest-dev组织的成员。作为成员,你可以帮助回答、标记和关闭问题,并审查和合并拉取请求。

另一种贡献方式是向pytest-dev提交新的插件,可以在 GitHub 或 BitBucket 上进行。我们喜欢当新的插件被添加到组织中,因为这会提供更多的可见性,并帮助与其他成员分享维护工作。

你可以在 pytest 网站上阅读我们的完整贡献指南(docs.pytest.org/en/latest/contributing.html)。

2016 年冲刺活动

2016 年 6 月,核心团队在德国弗莱堡举办了一次大规模的冲刺活动。超过 20 名参与者参加了为期六天的活动;活动主题围绕着实施新功能和解决问题。我们进行了大量的小组讨论和闪电演讲,并休息一天去美丽的黑森林徒步旅行。

团队成功发起了一次成功的 Indiegogo 活动(www.indiegogo.com/projects/python-testing-sprint-mid-2016#/),旨在筹集 11000 美元以偿还参与者的旅行费用、冲刺场地和餐饮费用。最终,我们筹集了超过 12000 美元,这显示了使用 pytest 的用户和公司的赞赏。

这真是太有趣了!我们一定会在未来重复这样的活动,希望能有更多的参与者。

下一步

在学到所有这些知识之后,你可能迫不及待地想要开始使用 pytest,或者更频繁地使用它。

以下是你可以采取的一些下一步的想法:

  • 在工作中使用它;如果你已经在日常工作中使用 Python 并有大量的测试,那是开始的最佳方式。你可以慢慢地使用 pytest 作为测试运行器,并以你感到舒适的速度使用更多的 pytest 功能。

  • 在你自己的开源项目中使用它:如果你是一个开源项目的成员或所有者,这是获得一些 pytest 经验的好方法。如果你已经有了一个测试套件,那就更好了,但如果没有,当然从 pytest 开始将是一个很好的选择。

  • 为开源项目做贡献;你可以选择一个具有unittest风格测试的开源项目,并决定提供更改以使用 pytest。2015 年 4 月,pytest 社区组织了所谓的 Adopt pytest 月活动(docs.pytest.org/en/latest/adopt.html),开源项目与社区成员配对,将他们的测试套件转换为 pytest。这个活动取得了成功,大多数参与者都玩得很开心。这是参与另一个开源项目并同时学习 pytest 的好方法。

  • 为 pytest 本身做出贡献;如前所述,pytest 社区对新贡献者非常欢迎。我们很乐意欢迎你!

本书故意省略了一些主题,因为它们被认为对于快速入门来说有点高级,或者因为由于空间限制,我们无法将它们纳入书中。

最终总结

所以,我们已经完成了快速入门指南。在本书中,我们从在命令行上使用 pytest 到将现有测试套件转换为利用强大的 pytest 功能的技巧和窍门,进行了全面的概述。您现在应该能够每天轻松使用 pytest,并在需要时帮助他人。

您已经走到了这一步,所以祝贺您!希望您在学习的过程中学到了一些东西,并且玩得开心!

第七章:引入 pytest

自动化测试被认为是生产高质量软件的不可或缺的工具和方法。测试应该是每个专业软件开发人员工具箱的一部分,但与此同时,许多人认为这是工作中无聊和重复的部分。但当您使用 pytest 作为测试框架时,情况就不一样了。

本书将向您介绍各种关键功能,并教您如何从第一章开始有效地使用 pytest 进行日常编码任务,重点是让您尽快提高生产力。编写测试应该成为一种乐趣,而不是工作中无聊的部分。

我们将首先看一下自动化测试的重要性。我还会试图说服您,这不是因为这是正确的事情,所以您应该拥有它。自动化测试是您希望拥有的东西,因为它会让您的工作变得更加轻松和愉快。我们将简要介绍 Python 的标准unittest模块,并介绍 pytest 以及为什么它具有更多的功能,同时使用起来非常简单。然后,我们将介绍如何编写测试,如何将它们组织成类和目录,以及如何有效地使用 pytest 的命令行。然后,我们将看一下如何使用标记来控制跳过测试或期望测试失败,如何利用自定义标记,以及如何使用相同的测试代码参数化来测试多个输入,以避免复制/粘贴代码。这将帮助我们学习如何使用 pytest 最受欢迎的功能之一:fixture 来管理和重用测试资源和环境。之后,我们将介绍 pytest 提供的一些更受欢迎和有用的插件。最后,我们将探讨如何逐步将基于unittest的测试套件转换为 pytest 风格,以便在现有代码库中充分利用其许多优势。

在本章中,我们将快速了解为什么我们应该进行测试,内置的unittest模块以及 pytest 的概述。以下内容将被涵盖:

  • 为什么要花时间编写测试?

  • 快速了解unittest模块

  • 为什么选择 pytest?

让我们先退一步,思考为什么编写测试被认为是如此重要。

为什么要花时间编写测试?

手动测试程序是自然的;编写自动化测试则不是。

程序员在学习编码或尝试新技术和库时使用各种技术。编写短小的代码片段,跟随教程,使用 REPL 玩耍,甚至使用 Jupyter(jupyter.org/)。通常,这涉及手动验证所学内容的结果,使用打印语句或绘制图形。这是一种简单、自然且完全有效的学习新知识的方式。

然而,这种模式不应该延续到专业软件开发中。专业软件并不简单;相反,它通常非常复杂。根据系统设计的好坏,各个部分可能以奇怪的方式交织在一起,新功能的添加可能会破坏系统的另一个看似无关的部分。修复一个错误可能会导致另一个错误在其他地方出现。

如何确保新功能正常工作或错误已经被彻底解决?同样重要的是,如何确保通过修复或引入新功能,系统的另一部分不会被破坏?

答案是通过拥有一套健康和全面的自动化测试,也称为测试套件。

测试套件简单来说就是测试您的代码的代码。通常,它们会创建一个或多个必要的资源,并调用要测试的应用程序代码。然后,他们断言结果是否符合预期。除了在开发人员的机器上执行外,在大多数现代设置中,它们会被连续运行,例如每小时或每次提交,由像 Jenkins 这样的自动化系统运行。因此,为一段代码添加测试意味着从现在开始,它将在添加功能和修复错误时一遍又一遍地进行测试。

拥有自动化测试意味着您可以对程序进行更改,并立即查看这些更改是否破坏了系统的某个部分,作为开发人员的安全网。拥有一个良好的测试套件非常令人振奋:您不再害怕改进 8 年前编写的代码,如果犯了任何错误,测试套件会告诉您。您可以添加一个新功能,并确信它不会破坏您没有预料到的系统的其他部分。能够有信心地将一个大型库从 Python 2 转换为 3,或进行大规模的重构,是绝对必要的。通过添加一个或多个自动化测试来重现一个 bug,并证明您已经修复了它,您可以确保这个 bug 不会在以后的重构或其他编码错误中再次出现。

一旦你习惯了享受测试套件作为安全网的好处,你甚至可能决定为你依赖的 API 编写测试,但知道开发人员没有测试:能够向原始开发人员提供失败的测试来证明他们的新版本是导致错误的原因,而不是你的代码,这是一个罕见的职业骄傲时刻。

拥有一个写得很好、深入的测试套件将使您能够放心地进行任何大小的更改,并帮助您晚上睡得更好。

快速查看 unittest 模块

Python 自带内置的unittest模块,这是一个基于 Java 的单元测试框架 JUnit 编写自动化测试的框架。您可以通过从unittest.TestCase继承并定义以test开头的方法来创建测试。以下是使用unittest的典型最小测试用例的示例:

    import unittest
    from fibo import fibonacci

    class Test(unittest.TestCase):

        def test_fibo(self):
            result = fibonacci(4)
            self.assertEqual(result, 3)

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

这个例子的重点是展示测试本身,而不是被测试的代码,所以我们将使用一个简单的fibonacci函数。斐波那契数列是一个无限的正整数序列,其中序列中的下一个数字是通过将前两个数字相加得到的。以下是前 11 个数字:

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...

我们的fibonacci函数接收斐波那契数列的index,实时计算值并返回它。

为了确保函数按预期工作,我们使用我们知道正确答案的值来调用它(斐波那契数列的第四个元素是 3),然后调用self.assertEqual(a, b)方法来检查ab是否相等。如果函数有 bug 并且没有返回预期的结果,当我们执行它时,框架会告诉我们:

 λ python3 -m venv .env
  source .env/bin/activate
 F
 ======================================================================
 FAIL: test_fibo (__main__.Test)
 ----------------------------------------------------------------------
 Traceback (most recent call last):
 File "test_fibo.py", line 8, in test_fibo
 self.assertEqual(result, 3)
 AssertionError: 5 != 3

 ----------------------------------------------------------------------
 Ran 1 test in 0.000s

 FAILED (failures=1)

我们的fibonacci函数似乎有一个 bug,写它的人忘记了对于n=0应该返回0。修复函数并再次运行测试显示函数现在是正确的:


    λ python test_fibo.py
 .
 ----------------------------------------------------------------------
 Ran 1 test in 0.000s

 OK

这很好,当然是朝着正确的方向迈出的一步。但请注意,为了编写这个非常简单的检查,我们必须做一些与检查本身无关的事情:

  1. 导入unittest

  2. 创建一个从unittest.TestCase继承的类

  3. 使用self.assertEqual()进行检查;有很多self.assert*方法应该用于所有情况,比如self.assertGreaterEqual(用于≥比较),self.assertLess(用于<比较),self.assertAlmostEqual(用于浮点数比较),self.assertMultiLineEqual()(用于多行字符串比较),等等

上述内容感觉像是不必要的样板文件,虽然这当然不是世界末日,但有些人觉得这段代码不符合 Pythonic 的风格;代码只是为了迎合框架而编写的。

此外,unittest框架在帮助您编写真实世界的测试方面并没有提供太多内置功能。需要临时目录吗?您需要自己创建并在之后清理。需要连接到 PostgreSQL 数据库来测试 Flask 应用程序?您需要编写支持代码来连接到数据库,创建所需的表,并在测试结束时进行清理。需要在测试之间共享实用程序测试函数和资源吗?您需要创建基类并通过子类化重用它们,在大型代码库中可能会演变成多重继承。一些框架提供自己的unittest支持代码(例如 Django,www.djangoproject.com/),但这些框架很少。

为什么选择 pytest?

Pytest 是一个成熟且功能齐全的测试框架,从小型测试到应用程序和库的大规模功能测试。

Pytest 很容易上手。要编写测试,您不需要类;您可以编写以test开头并使用 Python 内置的assert语句的简单函数:

    from fibo import fibonacci

    def test_fibo():
        assert fibonacci(4) == 3

就是这样。您导入您的代码,编写一个函数,并使用普通的 assert 调用来确保它们按您的期望工作:无需创建子类并使用各种self.assert*方法来进行测试。而美妙的是,当断言失败时,它还提供了有用的输出:

 λ pytest test_fibo2.py -q
 F                                                              [100%]
 ============================= FAILURES ==============================
 _____________________________ test_fibo _____________________________

 def test_fibo():
 >       assert fibonacci(4) == 3
 E       assert 5 == 3
 E        + where 5 = fibonacci(4)

 test_fibo2.py:4: AssertionError
 1 failed in 0.03 seconds

请注意,表达式中涉及的值和周围的代码都会显示出来,以便更容易理解错误。

Pytest 不仅使编写测试变得简单,它还有许多命令行选项来提高生产力,比如仅运行最后失败的测试,或者按名称或特殊标记运行特定组的测试。

创建和管理测试资源是经常被忽视的重要方面,通常在教程或测试框架的概述中被忽略。真实应用程序的测试通常需要复杂的设置,比如启动后台工作程序,填充数据库或初始化 GUI。使用 pytest,这些复杂的测试资源可以通过一个称为fixtures的强大机制来管理。fixtures 使用简单,但同时非常强大,许多人称之为pytest 的杀手功能。它们将在第四章中详细介绍,Fixtures

定制很重要,pytest 通过定义一个非常强大的插件系统进一步发展。插件可以改变测试运行的多个方面,从测试的执行方式到提供新的 fixtures 和功能,以便轻松测试许多类型的应用程序和框架。有一些插件每次以随机顺序执行测试,以确保测试不会改变可能影响其他测试的全局状态,有一些插件多次重复执行失败的测试以排除不稳定的行为,有一些插件在测试运行结束时显示失败,而不仅仅是在最后显示,还有一些插件在多个 CPU 上执行测试以加快测试套件的速度。还有一些插件在测试 Django、Flask、Twisted 和 Qt 应用程序时非常有用,还有一些插件用于使用 Selenium 进行 Web 应用程序的验收测试。外部插件的数量真的令人震惊:在撰写本文时,有超过 500 个 pytest 插件可供安装和立即使用(plugincompat.herokuapp.com/)。

总结 pytest:

  • 您可以使用普通的assert语句来编写您的检查,并进行详细的报告

  • pytest 具有自动测试发现功能

  • 它有 fixtures 来管理测试资源

  • 它有许多插件,可以扩展其内置功能,并帮助测试大量的框架和应用程序

  • 它可以直接运行基于unittest的测试套件,无需任何修改,因此您可以逐渐迁移现有的测试套件

因此,许多人认为 pytest 是在 Python 中编写测试的一种 Pythonic 方法。它使编写简单的测试变得容易,并且足够强大,可以编写非常复杂的功能测试。然而,更重要的是,pytest 让测试变得有趣。

使用 pytest 编写自动化测试,并享受它们的诸多好处,将会变得自然而然。

摘要

在本章中,我们介绍了为什么编写测试对于生产高质量软件以及让您有信心引入变化是重要的。之后,我们看了一下内置的unittest模块以及如何使用它来编写测试。最后,我们简要介绍了 pytest,发现了使用它编写测试是多么简单,看了它的主要特点,还看了大量覆盖各种用例和框架的第三方插件。

在下一章中,我们将学习如何安装 pytest,如何编写简单的测试,如何更好地将它们组织到项目的文件和目录中,以及如何有效地使用命令行。

posted @ 2025-09-18 14:36  绝不原创的飞龙  阅读(36)  评论(0)    收藏  举报