本文主要介绍使用pytest进行Python单元测试的指南。

下面是这本书的架构及内容概览:

Acknowledgments 和 Preface

  • Acknowledgments:感谢那些对本书创作做出贡献的人。
  • Preface:介绍为什么选择pytest作为测试工具,以及本书的组织方式、读者需要的先决知识、第二版更新的理由和如何获取示例代码与在线资源。

Part I. Primary Power

这部分介绍了pytest的基础知识和核心概念,适合pytest初学者。

  1. Getting Started with pytest:讲解如何安装pytest,以及如何运行第一个测试。
  2. Writing Test Functions:教授如何编写测试函数,包括使用assert语句、测试异常、以及如何组织测试函数。
  3. pytest Fixtures:详解pytest的fixture机制,这是pytest的核心特性之一,用于设置和清理测试环境。
  4. Builtin Fixtures:介绍pytest自带的fixtures,如tmp_path和monkeypatch。
  5. Parametrization:学习如何使用参数化测试,可以让你的测试函数接受不同的参数值。
  6. Markers:标记测试函数,用于控制测试的执行,如跳过、预期失败和选择性执行。

Part II. Working with Projects 这部分深入探讨了在实际项目中使用pytest的方法。

  1. Strategy:制定测试策略,包括测试范围、软件架构考量和创建测试案例。
  2. Configuration Files:理解和使用pytest的配置文件,如pytest.ini和conftest.py。
  3. Coverage:使用coverage.py和pytest-cov插件来衡量代码覆盖率。
  4. Mocking:隔离外部依赖,使用mock对象来模拟对象的行为。
  5. tox and Continuous Integration:介绍持续集成的概念,以及如何使用tox进行多版本Python测试。
  6. Testing Scripts and Applications:测试脚本和应用程序的策略,包括如何组织项目目录结构。
  7. Debugging Test Failures:当测试失败时,如何使用pytest的工具进行调试。

Part III. Booster Rockets 这部分涵盖了高级主题和扩展功能。

  1. Third-Party Plugins:探索第三方pytest插件,包括并行测试和随机测试顺序。
  2. Building Plugins:指导读者如何自己创建pytest插件,包括本地开发、发布和测试。
  3. Advanced Parametrization:高级参数化技巧,如动态参数和间接参数化。

Appendices

  • Virtual Environments:解释虚拟环境的重要性,以及如何使用它们。
  • pip:介绍pip,Python的包管理器,用于安装和管理软件包。

整本书从基础到进阶,不仅提供了理论知识,还包含了大量的练习题和实战案例,旨在让读者能够熟练运用pytest进行高效、高质量的测试工作。

第1章. pytest入门指南

安装pytest

要在虚拟环境中安装pytest,请按照以下步骤操作:

$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install pytest

运行pytest

pytest支持多种运行方式:

  • pytest:无参数时,pytest会在当前目录及其子目录中搜索测试。
  • pytest <filename>:运行指定文件中的测试。
  • pytest <filename> <filename> ...:运行多个指定文件中的测试。
  • pytest <dirname>:从特定目录(或多个目录)开始,递归搜索并运行测试。

示例测试文件如下:

# test_one.py
def test_passing():
    assert (1, 2, 3) == (1, 2, 3)

# test_two.py
def test_failing():
    assert (1, 2, 3) == (3, 2, 1)

使用-v--verbose命令行标志可以显示更详细的输出。

使用--tb=no命令行标志可以关闭追踪回溯信息。

示例命令如下:

pytest test_one.py
pytest test_two.py
pytest -v test_two.py
pytest --tb=no
pytest --tb=no test_one.py test_two.py
pytest -v ch1/test_one.py::test_passing

测试发现机制

测试发现机制指的是pytest如何找到你的测试代码,这取决于命名规则:

  • 测试文件应当命名为test_<something>.py<something>_test.py
  • 测试方法和函数应当命名为test_<something>
  • 测试类应当命名为Test<Something>

测试函数的结果

测试函数的结果可能有以下几种情况:

  • 成功:当所有断言都通过时,测试被视为成功。
  • 失败:当断言失败或测试中抛出异常时,测试被视为失败。
  • 跳过:当测试被显式跳过(使用pytest.mark.skip)时,测试被视为跳过。
  • 错误:当测试在执行前就遇到错误(如导入错误)时,测试被视为错误。
  • 预期失败:当测试预计会失败但实际通过(或反之)时,测试被视为预期失败。
  • 挂起:当测试因外部条件未满足而无法执行时,测试被视为挂起。例如,网络连接不可用。

第2章. 编写测试函数

Cards 应用程序

要在虚拟环境中安装 Cards 应用程序及 pytest,按照以下步骤操作:

  • 进入代码目录:cd /path/to/code
  • 创建虚拟环境:python -m venv venv --prompt cards
  • 激活虚拟环境:source venv/bin/activate(Windows上使用venv\Scripts\activate.bat
  • 安装 Cards 应用:pip install ./cards_proj
  • 安装 pytest:pip install pytest

使用 assert 语句

pytest 支持 assert 重写,允许我们使用标准的 Python assert 表达式。

pytest unittest
assert something assertTrue(something)
assert not something assertFalse(something)
assert a == b assertEqual(a, b)
assert a != b assertNotEqual(a, b)
assert a is None assertIsNone(a)
assert a is not None assertIsNotNone(a)
assert a <= b assertLessEqual(a, b)

通过 pytest.fail() 和异常失败

测试可以从断言失败、调用 fail() 或任何未捕获的异常中失败。

如果出现任何未捕获的异常,测试将失败。这可能发生在:

  • 断言语句失败,将引发 AssertionError 异常,
  • 测试代码调用 pytest.fail(),将引发异常,
  • 其他任何异常被抛出。

编写断言辅助函数

使用 pytest.raises() 来检测预期的异常。

with pytest.raises(TypeError): 语句表示接下来的代码块应该抛出 TypeError 异常。如果没有抛出异常,测试失败;如果抛出了不同的异常,测试同样失败。

结构化测试函数

一种优秀的测试结构方法被称为 Given-When-Then 或 Arrange-Act-Assert。

Given-Act-Assert 模式最初由 Bill Wake 在 2001 年命名。Kent Beck 后来将其作为测试驱动开发 (TDD) 的一部分加以推广。

行为驱动开发 (BDD) 使用 Given-When-Then 术语,这是一种来自 Ivan Moore 的模式,由 Dan North 推广。

不论步骤的命名如何,目的始终相同:将测试分阶段。

一种常见的反模式是采用“Arrange-Assert-Act-Assert-Act-Assert…”的模式,其中大量动作后跟着状态或行为检查,以验证工作流。

  • Given/Arrange —— 初始状态。在这里,你设置数据或环境以准备进行动作。
  • When/Act —— 执行某个动作。这是测试的重点——我们试图确保正确运行的行为。
  • Then/Assert —— 应该发生一些预期的结果或最终状态。测试末尾,我们确保动作产生了预期的行为。

通过类分组测试

类可以用于分组测试。 我建议在生产测试代码中,也要谨慎使用测试类,主要用作分组手段。

运行测试子集

在调试时运行测试的小子集非常方便,pytest 允许你以多种方式运行少量测试。

子集 语法
单个测试方法 pytest path/test_module.py::TestClass::test_method
类中的所有测试 pytest path/test_module.py::TestClass
单个测试函数 pytest path/test_module.py::test_function
模块中的所有测试 pytest path/test_module.py
目录中的所有测试 pytest path
匹配名称模式的测试 pytest -k pattern
按标记筛选测试 pytest -m marker

命令行标志

-vv 命令行标志在测试失败时显示更多信息。 --tb=short 命令行标志使用较短的追踪回溯格式。 -k 参数,结合 and、not 和 or,接收一个表达式,告诉 pytest 运行包含与表达式匹配子串的测试。

第3章. pytest 中的fixture

fixtures是测试辅助函数,对于几乎任何非平凡软件系统的测试代码结构至关重要。fixture是在实际测试函数之前(有时在之后)由 pytest 运行的函数。你可以使用fixture获取测试所需的数据集,让系统在运行测试前处于已知状态,或为多个测试准备好数据。

开始使用fixture

fixture是通过 @pytest.fixture() 装饰器定义的函数。

在 pytest 和本书中,测试fixture指的是 pytest 提供的一种机制,用于将“准备”和“清理”代码与测试函数本身分开。

在测试代码主体中发生的异常(或断言失败或调用 pytest.fail())会导致测试结果为“失败”。然而,在fixture中,测试函数会被报告为“错误”。

使用fixture进行设置和清理

测试函数或其他fixture依赖于一个fixture,只需在它们的参数列表中包含fixture的名称即可。

fixture可以使用 returnyield 返回数据。

yield 前面的代码是设置代码。yield 后面的代码是清理代码。

# ch3/test_count_initial.py
from pathlib import Path
from tempfile import TemporaryDirectory
import cards

def test_empty():
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db = cards.CardsDB(db_path)
        count = db.count()
        db.close()
        assert count == 0

pytest 会查看测试的具体参数名称,然后寻找具有相同名称的fixture。我们从不直接调用fixture函数,pytest 会为我们完成调用。

使用 –setup-show 追踪fixture执行

pytest 提供了命令行标志 --setup-show,它可以展示测试和fixture的操作顺序,包括fixture的设置和清理阶段。

$ pytest --setup-show test_count.py

指定fixture的作用域

fixture可以设置为 functionclassmodulepackagesession 作用域。

  • scope='function' 每个测试函数运行一次。使用fixture的每个测试之前的设置部分都会运行,使用fixture的每个测试之后的清理部分也会运行。这是没有指定作用域参数时的默认作用域。
  • scope='class' 每个测试类运行一次,无论类中有多少测试方法。
  • scope='module' 每个模块运行一次,无论模块中有多少测试函数、方法或其他fixture使用它。
  • scope='package' 每个包(或测试目录)运行一次,无论包中有多少测试函数、方法或其他fixture使用它。
  • scope='session' 每个会话运行一次。所有使用会话作用域fixture的测试方法和函数共享一个设置和清理调用。

默认作用域为函数作用域,甚至可以动态定义作用域。

通过 conftest.py 文件共享fixture

多个测试函数可以使用同一个fixture。

多个测试模块可以通过 conftest.py 文件使用同一个fixture。

不同作用域的多个fixture可以在加速测试套件的同时保持测试隔离。

测试和fixture可以使用多个fixture。

虽然 conftest.py 是一个 Python 模块,但它不应被测试文件导入。conftest.py 文件会被 pytest 自动读取,所以无需在任何地方导入 conftest。

查找fixture定义的位置

使用 --fixtures,pytest 可以向我们展示测试可使用的所有可用fixture的列表。这个列表包括内置fixture。

`$ pytest --fixtures -v`

使用 --fixtures-per-test 可以查看每个测试使用的fixture以及fixture的定义位置。

`$ pytest --fixtures-per-test test_count.py::test_empty`

使用多级fixture

使用多层次的fixture可以提供显著的速度优势,同时保持测试顺序的独立性。

数据库的设置最先发生,具有会话作用域(来自 S)。接着是 cards_db 的设置,在每个测试函数调用之前发生,具有函数作用域(来自 F)。此外,所有三个测试都通过了。

@pytest.fixture(scope="session")
def db():
    """连接到临时数据库的 CardsDB 对象"""
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db_ = cards.CardsDB(db_path)
        yield db_
        db_.close()

@pytest.fixture(scope="function")
def cards_db(db):
    """空的 CardsDB 对象"""
    db.delete_all()
    return db

动态决定fixture作用域

代替具体的作用域,我们放入了一个函数名,db_scope。为了允许 pytest 使用这个新标志,我们需要编写一个钩子函数(pytest_addoption)。

# conftest.py
@pytest.fixture(scope=db_scope)
def db():
    """连接到临时数据库的 CardsDB 对象"""
    with TemporaryDirectory() as db_dir:
        db_path = Path(db_dir)
        db_ = cards.CardsDB(db_path)
        yield db_
        db_.close()


def db_scope(fixture_name, config):
    if config.getoption("--func-db", None):
        return "function"
    return "session"


def pytest_addoption(parser):
    parser.addoption(
        "--func-db",
        action="store_true",
        default=False,
        help="为每个测试创建新的数据库",
    )

经过所有这些之后,默认行为与之前相同,即具有会话作用域的 db:

pytest --setup-show test_count.py

但是当我们使用新标志时,我们会得到一个函数作用域的 db fixture:

pytest --func-db --setup-show test_count.py

使用 autouse 对于总是被使用的fixture

你可以使用 autouse=True 让fixture始终运行。自动使用的fixture不必在测试函数中被命名。

重命名fixture

fixture的名称可以与fixture函数名称不同。 pytest 允许你使用 name 参数对 @pytest.fixture() 进行fixture重命名。

# test_rename_fixture.py
import pytest


@pytest.fixture(name="ultimate_answer")
def ultimate_answer_fixture():
    return 42


def test_everything(ultimate_answer):
    assert ultimate_answer == 42

命令行标志

pytest --setup-show 用于查看执行顺序。 pytest --fixtures 用于列出可用的fixture及其位置。 pytest -s--capture=no 允许在通过的测试中也能看到打印语句。

第4章. 内置fixture

使用 tmp_path 和 tmp_path_factory

tmp_pathtmp_path_factory 用于创建临时目录。

tmp_pathtmp_path_factory fixture用于处理临时目录,其中 tmp_path 具有函数作用域,而 tmp_path_factory 具有会话作用域。

tmp_path 函数作用域fixture返回一个指向在测试期间和之后一段时间内存在的临时目录的 pathlib.Path 实例。tmp_path_factory 会话作用域fixture则返回一个 TempPathFactory 对象。

def test_tmp_path(tmp_path):
    file = tmp_path / "file.txt"
    file.write_text("Hello")
    assert file.read_text() == "Hello"

def test_tmp_path_factory(tmp_path_factory):
    path = tmp_path_factory.mktemp("sub")
    file = path / "file.txt"
    file.write_text("Hello")
    assert file.read_text() == "Hello"

# conftest.py
@pytest.fixture(scope="session")
def db(tmp_path_factory):
    """连接到临时数据库的 CardsDB 对象"""
    db_path = tmp_path_factory.mktemp("cards_db")
    db_ = cards.CardsDB(db_path)
    yield db_
    db_.close()

以下是两个相关的内置fixture:

  • *tmpdir — 与 tmp_path 类似,但返回一个 py.path.local 对象。此fixture在 tmp_path 出现之前就在 pytest 中存在。py.path.local 在 Python 3.4 添加 pathlib 之前存在,现在正在逐步淘汰,转而支持标准库中的 pathlib 版本。因此,推荐使用 tmp_path
  • tmpdir_factory — 与 tmp_path_factory 类似,但其 mktemp 函数返回的是 py.path.local 对象而不是 pathlib.Path 对象。

你也可以通过 pytest --basetemp=mydir 指定自己的基目录。

使用 capsys

capsys — 用于捕获输出。

capsys 可以用来捕获 stdoutstderr。它还可以暂时禁用输出捕获。

测试版本号的一个方法是使用 subprocess.run() 实际运行命令,抓取输出,并将其与 API 版本进行比较:

# test_version.py
import subprocess
def test_version_v1():
    process = subprocess.run(
        ["cards", "version"], capture_output=True, text=True
    )
    output = process.stdout.rstrip()
    assert output == cards.__version__

capsys fixture使你能够捕获对 stdoutstderr 的写入。我们可以直接调用实现 CLI 版本功能的方法,并使用 capsys 来读取输出。capsys 的另一个特性是能够暂时禁用 pytest 正常的输出捕获。

# test_version.py
import cards

def test_version_v2(capsys):
    cards.cli.version()
    output = capsys.readouterr().out.rstrip()
    assert output == cards.__version__

使用 -s--capture=no 标志可以看到所有输出,即使在通过的测试中也是如此。

# test_print.py
def test_normal():
    print("\nnormal print")

$ pytest -s test_print.py::test_normal

另一种始终包含输出的方法是使用 capsys.disabled()。在 with 块中的输出始终会被显示,即使没有 -s 标志。

# test_print.py
def test_disabled(capsys):
    with capsys.disabled():
        print("\ncapsys disabled print")

相关的fixture有 capsysbinarycapfdcapfdbinarycaplog

以下是相关的内置fixture:

  • capfd — 类似于 capsys,但捕获文件描述符 1 和 2,这通常等同于 stdoutstderr
  • capsysbinarycapsys 捕获文本,而 capsysbinary 捕获字节。
  • capfdbinary — 在文件描述符 1 和 2 上捕获字节
  • caplog — 捕获使用 Python 日志记录包写入的输出

使用 monkeypatch

monkeypatch — 用于改变环境或应用程序代码,类似一种轻量级的模拟。

monkeypatch 可以用来改变应用程序代码或环境。在 Cards 应用程序中,我们用它将数据库位置重定向到由 tmp_path 创建的临时目录。

内置的 monkeypatch fixture允许你在单个测试的上下文中执行这些操作。它用于修改对象、字典、环境变量、Python 搜索路径或当前目录。这就像一个小型的模拟版本。

monkeypatch fixture提供了以下功能:

  • setattr(target, name, value, raising=True) — 设置属性
  • delattr(target, name, raising=True) — 删除属性
  • setitem(dic, name, value) — 设置字典条目
  • delitem(dic, name, raising=True) — 删除字典条目
  • setenv(name, value, prepend=None) — 设置环境变量
  • delenv(name, raising=True) — 删除环境变量
  • syspath_prepend(path) — 将 path 插入到 sys.path 开头,sys.path 是 Python 的导入位置列表
  • chdir(path) — 更改当前工作目录
from typer.testing import CliRunner
import cards

def run_cards(*params):
    runner = CliRunner()
    result = runner.invoke(cards.app, params)
    return result.output.rstrip()

def test_run_cards():
    assert run_cards("version") == cards.__version__

def test_patch_get_path(monkeypatch, tmp_path):
    def fake_get_path():
        return tmp_path
    monkeypatch.setattr(cards.cli, "get_path", fake_get_path)
    assert run_cards("config") == str(tmp_path)

def test_patch_home(monkeypatch, tmp_path):
    full_cards_dir = tmp_path / "cards_db"

    def fake_home():
        return tmp_path

    monkeypatch.setattr(cards.cli.pathlib.Path, "home", fake_home)
    assert run_cards("config") == str(full_cards_dir)

剩余的内置fixture

以下是本版写作时 pytest 带有的剩余内置fixture列表:

  • capfdcapfdbinarycapsysbinarycapsys 的变体,用于处理文件描述符和/或二进制输出
  • caplog — 与 capsys 类似,用于处理 Python 日志系统生成的消息
  • cache — 用于在 pytest 运行之间存储和检索值。这个fixture最有用的部分在于它使得 --last-failed--failed-first 等标志成为可能。
  • doctest_namespace — 如果你喜欢使用 pytest 运行 doctest 风格的测试,这将很有用。
  • pytestconfig — 用于访问配置值、插件管理器和插件挂钩。
  • record_propertyrecord_testsuite_property — 用于向测试或测试套件添加额外属性。特别适用于向 XML 报告中添加数据,以便持续集成工具使用。
  • recwarn — 用于测试警告消息。
  • request — 用于提供有关正在执行的测试函数的信息。最常用于fixture参数化中。
  • pytestertestdir — 用于提供一个临时测试目录,以帮助运行和测试 pytest 插件。pytester 是基于 pathlibtestdir 替代品。
  • tmpdirtmpdir_factory — 类似于 tmp_pathtmp_path_factory;用于返回 py.path.local 对象而不是 pathlib.Path 对象。

命令行标志

使用 pytest -spytest --capture=no 标志可以看到所有输出,即使在通过的测试中也是如此。 你可以使用 pytest --fixtures 来阅读这些和其他fixture的详细信息。

以下是中文重构后的代码和说明:

下面是这段文本的中文重构版本,其中代码部分保持不变:

第5章. 参数化

使用 @pytest.mark.parametrize() 参数化函数

为了参数化一个测试函数,需要在测试定义中添加参数,并使用 @pytest.mark.parametrize() 装饰器来定义要传递给测试的不同参数组合,例如:

# test_func_param.py
import pytest
from cards import Card

@pytest.mark.parametrize(
    "start_summary, start_state",
    [
    ("write a book", "done"),
    ("second edition", "in prog"),
    ("create a course", "todo"),
    ],
)
def test_finish(cards_db, start_summary, start_state):
    initial_card = Card(summary=start_summary, state=start_state)
    index = cards_db.add_card(initial_card)
    cards_db.finish(index)
    card = cards_db.get_card(index)
    assert card.state == "done"

使用 @pytest.fixture(params=()) 参数化 fixture

pytest 会根据我们提供的每一组值调用 fixture 一次。之后,依赖于该 fixture 的每个测试函数也会被调用一次,每次对应一个 fixture 值。每个 params 的值会被保存到 request.param 中以供 fixture 使用。

# test_fix_param.py
@pytest.fixture(params=["done", "in prog", "todo"])
def start_state(request):
    return request.param

def test_finish(cards_db, start_state):
    c = Card("write a book", state=start_state)
    index = cards_db.add_card(c)
    cards_db.finish(index)
    card = cards_db.get_card(index)
    assert card.state == "done"

使用名为 pytest_generate_tests 的钩子函数

钩子函数通常被插件用来改变 pytest 的正常执行流程。但是我们也可以在测试文件和 conftest.py 文件中使用许多钩子函数。metafunc 对象包含了大量信息,但这里我们只使用它来获取参数名称并生成参数化。

使用 pytest_generate_tests 实现相同的流程如下:

# test_gen.py
from cards import Card

def pytest_generate_tests(metafunc):
    if "start_state" in metafunc.fixturenames:
        metafunc.parametrize("start_state", ["done", "in prog", "todo"])

def test_finish(cards_db, start_state):
    c = Card("write a book", state=start_state)
    index = cards_db.add_card(c)
    cards_db.finish(index)
    card = cards_db.get_card(index)
    assert card.state == "done"

pytest_generate_tests 特别有用,如果我们在测试收集时想要以有趣的方式修改参数化列表。

  • 我们可以根据命令行标志来构建我们的参数化列表,因为 metafunc 给我们提供了访问 metafunc.config.getoption("--someflag") 的能力。也许我们可以添加一个 --excessive 标志来测试更多的值,或者一个 --quick 标志来测试少量的值。
  • 一个参数的参数化列表可以基于另一个参数的存在。例如,对于请求两个相关参数的测试函数,我们可以为这两个参数使用不同的值集,而不是仅请求一个参数的情况。
  • 我们可以同时参数化两个相关的参数,例如使用 metafunc.parametrize("planet, moon", [('Earth', 'Moon'), ('Mars', 'Deimos'), ('Mars', 'Phobos'), ...])

使用关键字选择测试用例

参数化技术非常适合快速创建大量测试用例。因此,能够运行测试用例的一个子集往往是非常有益的。我们探讨了如何使用 pytest -k 来运行参数化测试用例的子集。

我们可以运行所有 “todo” 的测试用例:

pytest -v -k todo

如果我们想要排除带有 “play” 或 “create” 的测试用例,我们可以进一步筛选:

pytest -v -k "todo and not (play or create)"

我们也可以选择运行一个特定的测试用例:

pytest -v "test_func_param.py::test_finish[write a book-done]"

命令行标志

我们探讨了如何使用 pytest -k 来运行参数化测试用例的子集。

下面是这段文本的中文重构版本,其中代码部分保持不变:

第6章. 标记

使用内置标记

pytest 的内置标记用于修改测试运行的行为。以下是 pytest 6 版本中包含的所有内置标记:

  • @pytest.mark.filterwarnings(warning): 此标记向给定测试添加警告过滤器。
  • @pytest.mark.skip(reason=None): 此标记跳过测试,并可选地给出原因。
  • @pytest.mark.skipif(condition, ..., *, reason): 如果任何条件为真,则此标记跳过测试。
  • @pytest.mark.xfail(condition, ..., *, reason, run=True, raises=None, strict=xfail_strict): 此标记告诉 pytest 我们期望测试失败。
  • @pytest.mark.parametrize(argnames, argvalues, indirect, ids, scope): 此标记多次调用测试函数,依次传递不同的参数。
  • @pytest.mark.usefixtures(fixturename1, fixturename2, ...): 此标记标记测试需要指定的所有 fixture。

使用 pytest.mark.skip 跳过测试

@pytest.mark.skip() 标记告诉 pytest 跳过测试。原因虽然是可选的,但在后期维护时列出原因很重要。

import pytest

@pytest.mark.skip(reason="Card 尚不支持 < 比较")
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2

当我们运行被跳过的测试时,它们会显示为 s 或者 SKIPPED(在详细模式下):

pytest test_skip.py

或者

pytest -v -ra test_skip.py

条件性跳过测试 pytest.mark.skipif

skipif 标记允许你传入任意数量的条件,只要其中任何一个条件为真,就会跳过测试。在此示例中,我们使用 packaging.version.parse 来允许我们隔离主要版本。

import cards
from packaging.version import parse

@pytest.mark.skipif(
    parse(cards.__version__).major < 2,
    reason="Card < 比较在 1.x 版本不支持",
)
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2

预期测试失败 pytest.mark.xfail

如果我们想运行所有的测试,即使是那些我们知道会失败的测试,我们可以使用 xfail 标记。

这是 xfail 的完整签名:

@pytest.mark.xfail(condition, ..., *, reason, run=True,
raises=None, strict=xfail_strict)

此标记的第一组参数与 skipif 相同。默认情况下,测试会被运行,但 run 参数可用于告诉 pytest 不运行测试,通过设置 run=Falseraises 参数允许你提供一个异常类型或异常类型的元组,这些异常类型会导致 xfail。strict 告诉 pytest 如果通过的测试应该标记为 XPASS(strict=False)还是 FAIL(strict=True)。

@pytest.mark.xfail(
    parse(cards.__version__).major < 2,
    reason="Card < 比较在 1.x 版本不支持",
)
def test_less_than():
    c1 = Card("a task")
    c2 = Card("b task")
    assert c1 < c2

@pytest.mark.xfail(reason="XPASS 示例")
def test_xpass():
    c1 = Card("a task")
    c2 = Card("a task")
    assert c1 == c2

@pytest.mark.xfail(reason="严格示例", strict=True)
def test_xfail_strict():
    c1 = Card("a task")
    c2 = Card("a task")
    assert c1 == c2

使用自定义标记选择测试

自定义标记是我们自己创建并应用于测试的标记。可以把它们想象成标签或标签。自定义标记可用于选择要运行或跳过的测试。

我们从“smoke”开始,并将 @pytest.mark.smoke 添加到 test_start()

def test_start(cards_db):
    """
    start 将状态从 "todo" 更改为 "in prog"
    """
    i = cards_db.add_card(Card("foo", state="todo"))
    cards_db.start(i)
    c = cards_db.get_card(i)
    assert c.state == "in prog"

现在我们应该能够使用 -m smoke 标志仅选择这个测试:

pytest -v -m smoke test_start.py

我们在 pytest.ini 文件中通过添加 markers 部分来注册自定义标记。每个列出的标记的形式为 <marker_name>: <description>,如下所示:

# pytest.ini
[pytest]
markers =
    smoke: 测试的一部分
    exception: 检查预期的异常

标记文件、类和参数

  • 使用文件级别的标记
import pytest
from cards import Card, InvalidCardId

pytestmark = pytest.mark.finish
  • 使用类级别的标记
@pytest.mark.smoke
class TestFinish:
    def test_finish_from_todo(self, cards_db):
        i = cards_db.add_card(Card("foo", state="todo"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"

    def test_finish_from_in_prog(self, cards_db):
        i = cards_db.add_card(Card("foo", state="in prog"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"

    def test_finish_from_done(self, cards_db):
        i = cards_db.add_card(Card("foo", state="done"))
        cards_db.finish(i)
        c = cards_db.get_card(i)
        assert c.state == "done"
  • 使用参数级别的标记
@pytest.mark.parametrize(
    "start_state",
    [
        "todo",
        pytest.param("in prog", marks=pytest.mark.smoke),
        "done",
    ],
)
def test_finish_func(cards_db, start_state):
    i = cards_db.add_card(Card("foo", state=start_state))
    cards_db.finish(i)
    c = cards_db.get_card(i)
    assert c.state == "done"
  • 使用参数级别的标记与 fixture 结合
@pytest.fixture(
    params=[
        "todo",
        pytest.param("in prog", marks=pytest.mark.smoke),
        "done",
    ]
)
def start_state_fixture(request):
    return request.param

def test_finish_fix(cards_db, start_state_fixture):
    i = cards_db.add_card(Card("foo", state=start_state_fixture))
    cards_db.finish(i)
    c = cards_db.get_card(i)
    assert c.state == "done"
  • 使用多个标记
@pytest.mark.smoke
@pytest.mark.exception
def test_finish_non_existent(cards_db):
    i = 123  # 任何数字都行,数据库为空
    with pytest.raises(InvalidCardId):
        cards_db.finish(i)

使用 “and”,“or”,“not” 和括号与标记结合

pytest -v -m "finish and exception"
pytest -v -m "finish and not smoke"
pytest -v -m "(exception or smoke) and (not finish)"
pytest -v -m smoke -k "not TestFinish"

对标记严格要求

@pytest.mark.smok
@pytest.mark.exception
def test_start_non_existent(cards_db):
    """
    不应能启动一个不存在的任务卡。
    """
    any_number = 123  # 任何数字都是无效的,数据库为空
    with pytest.raises(InvalidCardId):
        cards_db.start(any_number)

但是,如果我们希望这个警告成为错误,我们可以使用 --strictmarkers 标志:

pytest --strict-markers -m smoke

为了避免每次都要键入,你可以在 pytest.ini 文件的 addopts 部分添加 --strict-markers

# pytest.ini
[pytest]
markers =
    smoke: 测试的一部分
    exception: 检查预期的异常
    finish: 所有的 "cards finish" 相关测试
    num_cards:  cards_db fixture 预填充卡片的数量
addopts =
    --strict-markers
    -ra
xfail_strict = true

将标记与 fixture 结合

标记可以与 fixture 结合使用。它们也可以与其他插件和钩子函数结合使用。

现在我们需要修改 cards_db fixture:

@pytest.fixture(scope="function")
def cards_db(session_cards_db, request, faker):
    db = session_cards_db
    db.delete_all()
    # 支持 `@pytest.mark.num_cards(<some number>)`
    # 随机种子
    faker.seed_instance(101)
    m = request.node.get_closest_marker("num_cards")
    if m and len(m.args) > 0:
        num_cards = m.args[0]
        for _ in range(num_cards):
            db.add_card(
                Card(summary=faker.sentence(), owner=faker.first_name())
            )
    return db

然后我们需要声明我们的标记:

# pytest.ini
[pytest]
markers =
    smoke: 测试的一部分
    exception: 检查预期的异常
    finish: 所有的 "cards finish" 相关测试
    num_cards:  cards_db fixture 预填充卡片的数量
addopts =
    --strict-markers
    -ra
xfail_strict = true

现在所有旧的测试即使没有使用标记也会以相同的方式工作,而新的测试如果希望数据库中有初始卡片也能使用相同的 fixture:

import pytest

def test_no_marker(cards_db):
    assert cards_db.count() == 0

@pytest.mark.num_cards
def test_marker_with_no_param(cards_db):
    assert cards_db.count() == 0

@pytest.mark.num_cards(3)
def test_three_cards(cards_db):
    assert cards_db.count() == 3
    # 仅仅为了好玩,让我们看看 Faker 为我们生成的卡片
    print()
    for c in cards_db.list_cards():
        print(c)

@pytest.mark.num_cards(10)
def test_ten_cards(cards_db):
    assert cards_db.count() == 10

让我们运行这些测试确保一切正常,并查看这个假数据的一个例子:

pytest -v -s test_num_cards.py

列出标记

要列出所有可用的标记,包括描述和参数,运行 pytest --markers

pytest --markers

这是一个非常方便的功能,让我们可以快速查找标记,并且是一个很好的理由,在我们自己的标记中包含有用的描述。

命令行标志

  • --strict-markers 标志告诉 pytest 如果看到我们使用未声明的标记则抛出错误。默认情况下,这是一个警告。
  • -ra 标志告诉 pytest 列出任何未通过测试的原因。这包括失败、错误、跳过、xfail 和 xpass。
  • 设置 xfail_strict = true 会使任何通过的 xfail 测试变为失败的测试,因为我们对系统行为的理解有误。如果你希望 xfail 通过的测试标记为 XPASS,则可以省略这一设置。
  • -m 标志可以使用逻辑运算符 and、or、not 以及括号。
  • pytest --markers 列出所有可用的标记。

第7章. 测试策略

确定测试范围

范围问题

在确定我们需要做多少测试时,还有许多其他问题需要考虑:

  • 安全性是否重要?如果你保存了任何机密信息,这一点尤为重要。
  • 性能如何?交互操作需要快速吗?具体多快?
  • 负载能力怎样?你能处理大量用户发出的大量请求吗?你是否预计将来会有这方面的需求?如果有,你应该对此进行测试。
  • 输入验证是否到位?对于任何接受用户输入的系统来说,我们应在执行操作之前验证数据的有效性。

测试案例范围

这里有一个合理的起点:

  • 测试用户可见功能的行为。
  • 推迟安全性、性能和负载测试,直到现有设计发生变化。目前的设计是将数据库存储在用户的主目录中。如果将来移动到共享位置供多位用户使用,这些问题就变得非常重要。
  • 输入验证在 Cards 作为单用户应用时相对不那么重要。然而,我也不希望在使用应用程序时出现堆栈跟踪错误,所以我们至少应该在命令行界面级别测试一些奇怪的输入。

考虑软件架构

测试策略

所有这些都会以多种方式影响我们的测试策略:

  • 我们应该在哪一层级上进行测试?顶层用户界面?更低的层级?子系统?所有层级?
  • 在不同层级进行测试有多容易?UI 测试通常是难度最大的,但也更容易与客户特性联系起来。针对单个函数的测试可能更容易实施,但更难与客户需求关联。
  • 谁负责不同层级及其测试?如果你提供的是一个子系统,你只对该子系统负责吗?是否有人专门负责系统测试?如果是这样,选择就很简单:测试你自己的子系统。不过,最好至少参与了解在系统层面正在测试的内容。

测试案例策略

cli.pydb.py 都尽可能地轻薄,原因有几个:

  • 通过 API 进行测试可以测试大部分系统和逻辑。
  • 第三方依赖被隔离在一个单独的文件中。

这与测试策略有何关系?几个方面:

  • 因为 CLI 在逻辑上很薄,我们可以通过 API 测试几乎所有内容。
  • 只需测试 CLI 是否正确调用了 API 入口点即可。
  • 由于数据库交互被隔离在 db.py 中,如果觉得有必要,我们可以在这一层添加子系统测试。

以下是 Cards 的可行测试策略:

  • 测试用户可以访问的功能——即 CLI 中可见的功能。
  • 通过 API 而不是 CLI 来测试这些功能。
  • 测试 CLI 是否足够确保它正确连接到 API。

评估待测试的功能

优先级划分

我通常根据以下因素来优先排序待测试的功能:

  • 最近变化 — 新功能、新代码区域、最近修复、重构或以其他方式修改的新功能。
  • 核心 — 你的产品的独特卖点(USPs)。必须持续运作才能使产品有用的基本功能。
  • 风险 — 应用程序中风险较大的区域,例如对客户很重要的区域,但开发团队不经常使用,或使用你不完全信任的第三方代码的部分。
  • 问题频发 — 经常出错或经常收到缺陷报告的功能。
  • 专业知识 — 仅被一小部分人理解的特性或算法。

测试案例功能

因为我们把 Cards 项目视为需要测试的遗留系统,这些标准中有些比其他的更有帮助:

  • 核心
    • add, count, delete, finish, list, start, 和 update 看起来都是核心功能。
    • configversion 看起来没那么重要。
  • 风险
    • 第三方包是用于 CLI 的 Typer 和用于数据库的 TinyDB。围绕我们使用这些组件的一些重点测试将是明智的。我们将通过测试 CLI 来测试 Typer 的使用。我们将通过所有其他测试来测试 TinyDB 的使用,并且由于 db.py 隔离了我们与 TinyDB 的交互,我们可以在必要时为此层创建测试。

即使这种快速的功能分析也帮助我们形成了策略:

  • 彻底测试核心功能。
  • 至少为非核心功能编写一个测试案例。
  • 隔离测试 CLI。

创建测试案例

测试标准

对于生成初始的一组测试案例,以下标准将会很有帮助:

  • 从一个非平凡的、“理想路径”测试案例开始。
  • 然后寻找代表
    • 有趣的数据集,
    • 有趣的起始状态,
    • 有趣的结束状态,或
    • 所有可能的错误状态的测试案例。

什么是有趣的起始状态?我认为:

  • 空数据库
  • 一项任务
  • 多项任务

测试案例

因此,对于 count 我们有了这些测试案例:

  • 从空数据库计数
  • 有一项任务时计数
  • 有多项任务时计数

以下是 add 的测试案例:

  • 向空数据库添加任务,具有摘要
  • 向非空数据库添加任务,具有摘要
  • 添加带有摘要和所有者的任务
  • 添加缺少摘要的任务
  • 添加重复的任务

以下是 delete 的测试案例:

  • 从有多项任务的数据库中删除一项
  • 删除最后一项任务
  • 删除不存在的任务

这给我们带来了这些新的测试案例:

  • 从 “todo”, “in prog”, 和 “done” 状态开始任务
  • 开始一个无效的 ID
  • 从 “todo”, “in prog”, 和 “done” 状态完成任务
  • 完成一个无效的 ID

以下是剩余功能的测试案例:

  • 更新任务的所有者
  • 更新任务的摘要
  • 同时更新任务的所有者和摘要
  • 更新不存在的任务
  • 从空数据库列出任务
  • 从非空数据库列出任务
  • config 返回正确的数据库路径
  • version 返回正确的版本

编写测试策略

即使有了这个对我们测试策略的快速总结,一旦深入测试时也很容易忘记细节。因此,我喜欢把测试策略写下来以便稍后参考。如果是在团队中工作,即使只有两个人,写下策略尤其重要。

这里是当前 Cards 的测试策略:

  • 测试可以通过最终用户界面,即 CLI 访问的行为和功能。
  • 尽可能通过 API 来测试这些功能。
  • 测试 CLI 足够以确认所有功能都正确调用了 API。
  • 彻底测试以下核心功能:add, count, delete, finish, list, start, 和 update
  • 包含对 configversion 的简略测试。
  • 通过针对 db.py 的子系统测试来测试我们使用 TinyDB 的情况。

常见错误

在编写自动化测试时,这些是常见的错误:

  • 只编写理想路径测试案例
  • 花费太多时间思考可能出现的问题
  • 忽略基于系统或组件状态的行为变化

第8章. 配置文件

了解 pytest 配置文件

配置文件

让我们来看看对 pytest 重要的非测试文件:

  • pytest.ini:这是主要的 pytest 配置文件,允许您更改 pytest 的默认行为。其位置也定义了 pytest 的根目录 (rootdir)。
  • conftest.py:这个文件包含 fixture 和钩子函数。它可以在 rootdir 或任何子目录中存在。
  • __init__.py:当放置在测试子目录中时,此文件允许您在多个测试目录中有相同的名字的测试文件。
  • tox.inipyproject.tomlsetup.cfg:这些文件可以替代 pytest.ini。如果您已经在项目中有一个这样的文件,您可以使用它来保存 pytest 设置。
    • tox.ini 是由 tox,一个命令行自动化测试工具所使用的。
    • pyproject.toml 用于打包 Python 项目,并且可以用来保存各种工具的设置,包括 pytest。
    • setup.cfg 同样用于打包,并且可以用来保存 pytest 设置。

项目目录结构

cards_proj
├── ... 顶级项目文件、src 目录、文档等 ...
├── pytest.ini
└── tests
    ├── conftest.py
    ├── api
    │ ├── __init__.py
    │ ├── conftest.py
    │ └── ... api 的测试文件 ...
    └── cli
    ├── __init__.py
    ├── conftest.py
    └── ... cli 的测试文件 ...

pytest.ini 中保存设置和标志

  • [pytest]
    • 文件以 [pytest] 开始,表示 pytest 设置的开始。包括 [pytest] 可以让 pytest 解析器同样地处理 pytest.initox.ini 文件。之后是各个设置,每个设置占据一行(或多行),形式为 <setting> = <value>
  • addopts = --strict-markers --strict-config -ra
    • addopts 设置使我们能够列出在这个项目中始终想要运行的 pytest 标志。
    • --strict-markers 告诉 pytest 对测试代码中遇到的任何未注册的标记抛出错误,而不是警告。打开此选项以避免标记名称拼写错误。
    • --strict-config 告诉 pytest 对解析配置文件的任何困难抛出错误。默认情况下是警告。打开此选项以避免配置文件中的拼写错误被忽略。
    • -ra 告诉 pytest 在测试运行结束时显示额外的测试总结信息。默认情况下仅在测试失败和错误时显示额外信息。-raa 部分告诉 pytest 显示除了通过测试之外的所有信息。这增加了跳过、期望失败和期望通过的测试。
  • testpaths = tests
    • testpaths 设置告诉 pytest 如果您没有在命令行给出文件或目录名称,那么在哪里查找测试。设置 testpathstests 告诉 pytest 查找 tests 目录。
    • 初看之下,将 testpaths 设置为 tests 可能显得多余,因为 pytest 无论如何都会在那里查找,而且我们的 srcdocs 目录中并没有 test_ 文件。但是指定 testpaths 目录可以节省一点启动时间,特别是如果我们的 docssrc 或其他目录相当大的时候。
  • markers = ...
    • markers 设置用于声明标记。每个列出的标记都是形式 <marker_name>: <description>
# pytest.ini
[pytest]
addopts =
    --strict-markers
    --strict-config
    -ra
testpaths = tests
markers =
    smoke: 测试子集
    exception: 检查预期的异常

使用 tox.inipyproject.tomlsetup.cfg 替代 pytest.ini

tox.ini

它也可以包含一个 [pytest] 部分。由于它也是一个 .ini 文件,下面的 tox.ini 示例几乎与前面所示的 pytest.ini 示例相同。唯一的区别是也会有一个 [tox] 部分。

# tox.ini
[tox]
; tox 特定设置

[pytest]
addopts =
    --strict-markers
    --strict-config
    -ra
testpaths = tests
markers =
    smoke: 测试子集
    exception: 检查预期的异常

pyproject.toml

不是 [pytest],而是以 [tool.pytest.ini_options] 开始部分。设置值需要用引号括起来,列表形式的设置值需要放在方括号内。

# pyproject.toml
[tool.pytest.ini_options]
addopts = [
    "--strict-markers",
    "--strict-config",
    "-ra"
]
testpaths = "tests"
markers = [
    "smoke: 测试子集",
    "exception: 检查预期的异常"
]

setup.cfg

pytest.ini 之间的唯一明显区别是部分指定符为 [tool:pytest]。然而,pytest 文档警告称 .cfg 解析器不同于 .ini 文件解析器,这种差异可能会导致难以追踪的问题。

# setup.cfg
[tool:pytest]
addopts =
    --strict-markers
    --strict-config
    -ra
testpaths = tests
markers =
    smoke: 测试子集
    exception: 检查预期的异常

确定根目录和配置文件

根目录搜索过程

如果您已经传递了一个测试目录,pytest 从那里开始查找。 如果您传递了多个文件或目录,pytest 从它们的共同祖先目录开始。 如果您没有传递文件或目录,它从当前目录开始。

如果 pytest 在起始目录找到配置文件,那就是根目录。 如果没有找到,pytest 会沿着目录树向上查找直到找到含有 pytest 部分的配置文件。

一旦 pytest 找到了配置文件,它会标记找到该文件的目录作为根目录,也就是 rootdir。这个根目录也是测试节点 ID 的相对根目录。它还会告诉您在哪里找到了配置文件。

配置文件

甚至在开始寻找要运行的测试文件之前,pytest 就会读取配置文件 —— pytest.ini 文件或包含 pytest 部分的 tox.inisetup.cfgpyproject.toml 文件。

即使您不需要任何配置设置,还是建议在项目的顶部放置一个空的 pytest.ini 文件。如果您没有任何配置文件,pytest 将一直搜索到文件系统的根目录。最好的情况,这只会造成轻微的延迟,因为 pytest 正在查找;最坏的情况,它会在途中找到一个与您的项目无关的配置文件。

一旦定位了配置文件,pytest 将在测试运行开始时打印出它正在使用的 rootdir 和配置文件。

通过 conftest.py 共享本地 fixture 和钩子函数

conftest.py 文件用于存储 fixture 和钩子函数。您可以在项目中拥有任意数量的 conftest.py 文件,甚至每个测试子目录一个。在 conftest.py 文件中定义的任何内容都适用于该目录及其所有子目录中的测试。

然而,最好尽量坚持使用一个 conftest.py 文件,这样您可以轻松地找到 fixture 定义。尽管您总是可以通过使用 pytest --fixtures -v 找到 fixture 被定义的位置,但如果知道它要么在您正在查看的测试文件中,要么在一个文件中,即 conftest.py 文件,那会更容易。

避免测试文件名冲突

__init__.py 文件影响 pytest 的方式只有一个:它允许您有重复的测试文件名。 如果您在每个测试子目录中都有 __init__.py 文件,您可以在多个目录中拥有相同的测试文件名。这就是使用 __init__.py 文件的唯一原因。

$ tree tests_with_init
tests_with_init
├── api
│ ├── __init__.py
│ └── test_add.py
├── cli
│ ├── __init__.py
│ └── test_add.py
└── pytest.ini

为了避免遇到重复文件名带来的混淆错误,养成在子目录中放置 __init__.py 文件的习惯是个不错的选择。

第9章. 代码覆盖率

测量代码覆盖率的工具会在运行测试套件时监视您的代码,并记录哪些行被执行了,哪些行未被执行。这种度量称为行覆盖率,它是通过将已执行的行数除以总行数计算得出的。代码覆盖率工具还可以告诉您控制语句中的所有路径是否都被遍历,这种度量称为分支覆盖率。

使用 coverage.pypytest-cov

coverage.pypytest-cov 都是第三方包,在使用前需要安装:

pip install coverage
pip install pytest-cov

使用 pytest-cov 运行覆盖率:

  • 使用 pytest --cov=<源代码目录> <测试路径> 来运行并生成简单的报告
  • 使用 pytest --cov=<源代码目录> --cov-report=term-missing <测试路径> 来显示哪些行未被执行
pytest --cov=cards ch7
pytest --cov=cards --cov-report=term-missing ch7

单独使用 coverage.py 运行覆盖率:

  • 使用 coverage run --source=<源代码目录> -m pytest <测试路径> 来使用覆盖率运行测试套件
  • 使用 coverage report 来显示简单的终端报告
  • 使用 coverage report --show-missing 来显示哪些行未被执行
coverage run --source=cards -m pytest ch7
coverage report
coverage report --show-missing

使用配置文件

coverage.py 的配置文件设置源代码目录。

# .coveragerc
[paths]
source =
    cards_proj/src/cards
    */site-packages/cards

生成 HTML 报告

使用 pytest-cov 生成 HTML 报告:

  • 使用 pytest --cov=<源代码目录> --cov-report=html <测试路径> 来生成 HTML 报告
pytest --cov=cards --cov-report=html ch7

使用 coverage.py 生成 HTML 报告:

  • 使用 coverage html 来生成 HTML 报告
pytest --cov=cards ch7
coverage html

排除代码不计入覆盖率

这些类型的代码块经常通过简单的 pragma 语句从测试中排除:

if __name__ == '__main__': # pragma: no cover
    main()

在测试上运行覆盖率

除了使用覆盖率来确定测试套件是否覆盖了应用程序代码的每一行,我们还可以将测试目录添加到覆盖率报告中:

pytest --cov=cards --cov=ch7 ch7

--cov=cards 命令告诉覆盖率工具监视 cards 包。--cov=ch7 命令告诉覆盖率工具监视 ch7 目录,其中存放着我们的测试文件。

在目录上运行覆盖率

ch9/some_code 目录中,有若干源代码模块和一个测试模块:

$ tree ch9/some_code
ch9/some_code
├── bar_module.py
├── foo_module.py
└── test_some_code.py

为了演示如何指向一个路径而不是包,我们可以留在顶层代码目录中,并从那里运行测试:

pytest --cov=ch9/some_code ch9/some_code/test_some_code.py

在单个文件上运行覆盖率

在这种情况下,即使没有导入任何东西,我们也把文件当作一个包处理,并使用 --cov=single_file(不含 .py 扩展名):

pytest --cov=single_file single_file.py

第十章 模拟

隔离命令行界面

通过这个 cards 命名空间,cli.py 访问:

  • cards.__version__(一个字符串)
  • cards.CardDB(一个代表主要API方法的类)
  • cards.InvalidCardID(一个异常)
  • cards.Card(CLI和API之间主要的数据类型)

大多数API访问是通过一个上下文管理器创建 cards.CardsDB 对象:

# cards_proj/src/cards/cli.py
@contextmanager
def cards_db():
    db_path = get_path()
    db = cards.CardsDB(db_path)
    yield db
    db.close()

大多数函数都通过这个对象工作。例如,start 命令通过 db(一个 CardsDB 实例)访问 db.start()

# cards_proj/src/cards/cli.py
@app.command()
def start(card_id: int):
    """将卡片状态设置为'in prog'。"""
    with cards_db() as db:
        try:
            db.start(card_id)
        except cards.InvalidCardId:
            print(f"错误: 无效的卡片ID {card_id}")

addupdate 也使用了我们之前玩过的 cards.Card 数据结构:

# cards_proj/src/cards/cli.py
db.add_card(cards.Card(summary, owner, state="todo"))

version 命令则查找 cards.__version__

# cards_proj/src/cards/cli.py
@app.command()
def version():
    """返回卡片应用的版本号。"""
    print(cards.__version__)

使用Typer进行测试

这里有一个调用 version 函数的例子:

# test_typer_testing.py
from typer.testing import CliRunner
from cards.cli import app

runner = CliRunner()

def test_typer_runner():
    result = runner.invoke(app, ["version"])
    print()
    print(f"版本: {result.stdout}")
    result = runner.invoke(app, ["list", "-o", "brian"])
    print(f"列表:\n{result.stdout}")

在这个例子测试中:

  • 要运行 cards version,我们执行 runner.invoke(app, ["version"])
  • 要运行 cards list -o brian,我们执行 runner.invoke(app, ["list", "-o", "brian"])

让我们简化一下:

# test_typer_testing.py
import shlex

def cards_cli(command_string):
    command_list = shlex.split(command_string)
    result = runner.invoke(app, command_list)
    output = result.stdout.rstrip()
    return output
    
def test_cards_cli():
    result = cards_cli("version")
    print()
    print(f"版本: {result}")
    result = cards_cli("list -o brian")
    print(f"列表:\n{result}")

模拟属性

我们将主要使用 patch.object 的上下文管理器形式。

# test_mock.py
from unittest import mock
import cards
import pytest
from cards.cli import app
from typer.testing import CliRunner


runner = CliRunner()
def test_mock_version():
    with mock.patch.object(cards, "__version__", "1.2.3"):
        result = runner.invoke(app, ["version"])
        assert result.stdout.rstrip() == "1.2.3"

在这种情况下,cards__version__ 属性在 with 块的持续时间内被替换为 “1.2.3”。然后我们使用 invoke 来调用我们的应用并传入 "version" 命令。

模拟替换了系统的一部分,即模拟对象。使用模拟对象,我们可以做很多事情,比如设置属性值、可调用对象的返回值,甚至可以查看可调用对象是如何被调用的。

模拟类和方法

这是一个探索性的测试函数,用于确认模拟是否设置正确。这次,我们希望让 CardsDB 成为一个模拟对象。

# test_mock.py
def test_mock_CardsDB():
    with mock.patch.object(cards, "CardsDB") as MockCardsDB:
        print()
        print(f" 类:{MockCardsDB}")
        print(f"返回值:{MockCardsDB.return_value}")
        with cards.cli.cards_db() as db:
            print(f" 对象:{db}")

对于 CardsDB() 返回的第二个模拟对象,我们可以在那里改变 path 属性。

# test_mock.py
def test_mock_path():
    with mock.patch.object(cards, "CardsDB") as MockCardsDB:
        MockCardsDB.return_value.path.return_value = "/foo/"
        with cards.cli.cards_db() as db:
            print()
            print(f"{db.path=}")
            print(f"{db.path()=}")

在真正开始测试CLI之前,我们需要做的最后一件事就是将数据库的模拟推入一个fixture中——因为我们将需要它在很多测试方法中:

# test_mock.py
@pytest.fixture()
def mock_cardsdb():
    with mock.patch.object(cards, "CardsDB", autospec=True) as CardsDB:
        yield CardsDB.return_value


def test_config(mock_cardsdb):
    mock_cardsdb.path.return_value = "/foo/"
    result = runner.invoke(app, ["config"])
    assert result.stdout.rstrip() == "/foo/"

通过使用 autospec 保持模拟与实现同步

当被模拟的接口发生变化而你的测试代码中的模拟没有变化时,就会出现模拟漂移。

这种形式的模拟漂移可以通过在创建模拟时添加 autospec=True 来解决,就像我们对 CardsDB 所做的那样。如果没有 autospec=True,模拟将会允许你调用任何函数并传入任何参数,即使这不符合被模拟的真实对象的行为。

# test_mock.py
def test_bad_mock():
    with mock.patch.object(cards, "CardsDB", autospec=True) as CardsDB:
        db = CardsDB("/some/path")
        db.path() # 正确
        db.path(35) # 无效参数
        db.not_valid() # 无效函数

确保函数被正确调用

有一些命令,我们无法通过检查输出来测试其行为,因为这些命令不会产生输出。我们将使用模拟来确保CLI正确地调用了API方法。

# test_cli.py
def test_add_with_owner(mock_cardsdb):
    cards_cli("add some task -o brian")
    expected = cards.Card("some task", owner="brian", state="todo")
    mock_cardsdb.add_card.assert_called_with(expected)

创建错误条件

现在让我们检查一下卡片CLI是否能正确处理错误条件。

为了测试CLI处理错误条件的能力,我们可以假设 delete_card 会引发一个异常,通过将异常赋给模拟对象的 side_effect 属性:

# cards_proj/src/cards/cli.py
@app.command()
def delete(card_id: int):
    """删除具有给定ID的数据库中的卡片。"""
    with cards_db() as db:
        try:
            db.delete_card(card_id)
        except cards.InvalidCardId:
            print(f"错误: 无效的卡片ID {card_id}")

# test_cli.py
def test_delete_invalid(mock_cardsdb):
    mock_cardsdb.delete_card.side_effect = cards.api.InvalidCardId
    out = cards_cli("delete 25")
    assert "错误: 无效的卡片ID 25" in out

通过多层测试避免模拟

# test_cli_alt.py
def test_add_with_owner(cards_db):
    """
    添加的卡片出现在列表中,内容如预期所示。
    """
    cards_cli("add some task -o brian")
    expected = cards.Card("some task", owner="brian", state="todo")
    all_cards = cards_db.list_cards()
    assert len(all_cards) == 1
    assert all_cards[0] == expected

# test_cli.py
def test_add_with_owner(mock_cardsdb):
    cards_cli("add some task -o brian")
    expected = cards.Card("some task", owner="brian", state="todo")
    mock_cardsdb.add_card.assert_called_with(expected)

使用插件辅助模拟

使用 pytest-mock 的一个好处是,该fixture会在结束后自动清理,因此你不必像我们的示例中那样使用with块。

还有一些特定用途的模拟库,在其关注点与你要测试的内容相符时应该考虑使用:

  • 对于模拟数据库访问,尝试使用 pytest-postgresql, pytest-mongo, pytest-mysql, 和 pytest-dynamodb。
  • 对于模拟HTTP服务器,尝试使用 pytest-httpserver。
  • 对于模拟请求,尝试使用 responses 和 betamax。
  • 还有更多工具,如 pytest-rabbitmq, pytest-solr, pytest-elasticsearch, 和 pytest-redis。

第十一章 tox 和持续集成

在多人协作开发同一个代码库的情况下,持续集成(CI)可以极大地提升生产力。CI 是指定期(通常是每天多次)将所有开发者的代码变更合并到共享仓库中的实践。

大多数CI工具都在服务器上运行(GitHub Actions 是一个例子)。tox 是一个自动化工具,它的作用类似于CI工具,但可以在本地运行,也可以与其他CI工具配合在服务器上使用。

什么是持续集成?

CI 工具可以自行构建和运行测试,通常是由合并请求触发的。由于构建和测试阶段都是自动化的,开发者可以更频繁地进行集成,甚至每天多次。这种频率使得分支间的代码变更更小,减少了合并冲突的可能性。结合Git等工具提供的自动化合并功能,我们实现了“持续”这一持续集成过程的一部分。

CI 工具传统上会自动化构建和测试的过程。最终主代码分支的实际合并有时也可以由CI系统处理。然而,更常见的是,工具在测试完成后停止,然后软件团队可以继续进行代码审查,并手动在版本控制系统中点击“合并”按钮。

介绍 tox

tox 是一个命令行工具,允许你在多个环境中运行完整的测试套件。学习CI时,tox 是一个很好的起点。虽然它严格意义上不是一个CI系统,但它的工作方式很类似,并且可以在本地运行。

tox 不仅限于Python版本的测试,还可以用于不同的依赖配置和不同操作系统的配置。

简而言之,tox 的工作模型如下:

tox 使用 setup.pypyproject.toml 中的项目信息来创建你的包的可安装发行版。它会在 tox.ini 文件中查找环境列表,然后为每个环境:

  1. .tox 目录中创建虚拟环境,
  2. 使用 pip 安装一些依赖,
  3. 构建你的包,
  4. 使用 pip 安装你的包,
  5. 运行你的测试。

尽管许多项目都在使用 tox,但也存在类似的替代工具,如 noxinvoke

设置 tox

下面是简化后的代码布局:

cards_proj
├── LICENSE
├── README.md
├── pyproject.toml
├── pytest.ini
├── src
│ └── cards
│ └── ...
├── tests
│ ├── api
│ │ └── ...
│ └── cli
│ └── ...
└── tox.ini

让我们看一下 Cards 项目的 tox.ini 文件的基本配置:

# tox.ini
[tox]
envlist = py310
isolated_build = True
[testenv]
deps =
    pytest
    faker
commands = pytest

[tox] 下,envlist = py310 告诉 tox 使用 Python 版本 3.10 运行测试。

对于所有 pyproject.toml 配置的包,需要设置 isolated_build = True。对于使用 setuptools 库配置的 setup.py 项目,可以省略此行。

[testenv] 下,deps 部分列出了 pytestfaker。这告诉 tox 需要安装这两个工具以进行测试。

最后,commands 设置告诉 tox 在每个环境中运行 pytest。

运行 tox

在运行 tox 之前,需要先安装它:

$ pip install tox

然后只需运行 tox 即可:

$ tox

测试多个 Python 版本

让我们扩展 tox.ini 中的 envlist 来添加更多的 Python 版本:

# tox_multiple_pythons.ini
[tox]
envlist = py37, py38, py39, py310
isolated_build = True
skip_missing_interpreters = True

设置 skip_missing_interpreters = True 后,tox 将在任何可用的 Python 版本上运行测试,但会跳过找不到的版本而不导致失败。

我们使用 tox -c tox_multiple_pythons.ini 来查看同一项目的不同 tox.ini 设置。

$ tox -c tox_multiple_pythons.ini

并行运行 tox 环境

也可以使用 -p 标志并行运行它们:

$ tox -c tox_multiple_pythons.ini -p

向 tox 添加覆盖率报告

通过在 tox.ini 文件中进行一些修改,tox 可以为测试运行添加覆盖率报告。

# tox_coverage.ini
[testenv]
deps =
    pytest
    faker
    pytest-cov
commands = pytest --cov=cards

当使用覆盖率与 tox 一起时,设置 .coveragerc 文件也很有用,这样覆盖率工具就可以知道哪些源路径应该被视为相同:

# .coveragerc
[paths]
source =
    src
    .tox/*/site-packages

将覆盖率源设置为包括 src.tox/*/site-packages 是一种简写方式,使之前的代码能够工作,并生成以下输出:

$ tox -c tox_coverage.ini -e py310

指定最低覆盖率级别

当从 tox 运行覆盖率时,设置一个基线覆盖率百分比以标记覆盖率下降也很有用。这是通过 --cov-fail-under 标志完成的:

# tox_coverage_min.ini
[testenv]
deps =
    pytest
    faker
    pytest-cov
commands = pytest --cov=cards --cov=tests --cov-fail-under=100

这将在输出中添加一行:

$ tox -c tox_coverage_min.ini -e py310

我们还使用了一些其他标志。在 tox.ini 中,我们在 pytest 调用中添加了 --cov=tests 以确保所有的测试都被运行。在 tox 命令行中,我们使用了 -e py310-e 标志允许我们运行特定的 tox 环境。

通过 tox 传递 pytest 参数

我们还可以通过进一步修改允许参数传递给 pytest 来聚焦于单个测试。

修改只需在 pytest 命令中添加 {posargs}

# tox_posargs.ini
[testenv]
deps =
    pytest
    faker
    pytest-cov
commands =
    pytest --cov=cards --cov=tests --cov-fail-under=100 {posargs}

然后,要向 pytest 传递参数,请在 tox 参数和 pytest 参数之间添加 --

$ tox -c tox_posargs.ini -e py310 -- -k test_version --no-cov

使用 GitHub Actions 运行 tox

要将 Actions 添加到仓库,只需要在项目顶层的 .github/workflows/ 目录中添加一个工作流 .yml 文件即可。

# cards_proj/.github/workflows/main.yml
name: CI
on: [push, pull_request]
jobs:
    build:
        runs-on: ubuntu-latest
        strategy:
            matrix:
                python: ["3.7", "3.8", "3.9", "3.10"]
        steps:
            - uses: actions/checkout@v2
            - name: Setup Python
            uses: actions/setup-python@v2
            with:
                python-version: $
            - name: Install Tox and any other packages
            run: pip install tox
            - name: Run Tox
            run: tox -e py

现在让我们逐一解释这个文件所指定的内容:

  • name 可以是任意名称。它会出现在稍后我们将看到的 GitHub Actions 用户界面中。
  • on: [push, pull_request] 告诉 Actions 每当我们推送代码到仓库或创建拉取请求时运行测试。如果我们推送代码更改,我们的测试将会运行;如果任何人创建拉取请求,测试也会运行。在拉取请求界面上可以看到测试运行的结果。所有 Actions 运行结果都可以在 GitHub 界面的 Actions 标签页中看到。
  • runs-on: ubuntu-latest 指定了运行测试的操作系统。这里我们只在 Linux 上运行,但也可以选择其他操作系统。
  • matrix: python: ["3.7", "3.8", "3.9", "3.10"] 指定了要运行的 Python 版本。
  • steps 是一系列步骤的列表。每个步骤的名称可以是任意的,而且是可选的。
  • uses: actions/checkout@v2 是 GitHub Actions 的一个工具,用于检出我们的仓库,以便后续的工作流程可以访问它。
  • uses: actions/setup-python@v2 是 GitHub Actions 的一个工具,用于在构建环境中配置和安装 Python。
  • with: python-version: $ 表示为 matrix: python 列表中的每个 Python 版本创建环境。
  • run: pip install tox 安装 tox。
  • run: tox -e py 运行 tox。-e py 看起来有点奇怪,因为我们没有指定 py 环境。然而,这可以正常工作,以选择 tox.ini 中指定的正确 Python 版本。

第十二章 测试脚本和应用程序

为了明确术语,在本章中适用以下定义:

  • 脚本:是指包含 Python 代码的单个文件,旨在直接通过 Python 运行,例如 python my_script.py
  • 可导入脚本:是指导入时不执行任何代码的脚本。代码只有在直接运行时才会被执行。
  • 应用程序:是指包含外部依赖项的包或脚本,这些依赖项定义在一个 requirements.txt 文件中。

在测试脚本和应用程序时,经常会出现以下问题:

  • 如何从测试中运行脚本?
  • 如何捕获脚本的输出?
  • 我想在我的测试中导入我的源模块或包。如果测试和代码位于不同的目录中,我该如何实现这一点?
  • 如果没有包需要构建,如何使用 tox?
  • 如何让 tox 从 requirements.txt 文件中加载外部依赖项?

使用虚拟环境

首先,我们可以创建一个虚拟环境,并安装必要的测试工具:

$ cd /path/to/code/ch12
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install -U pip
(venv) $ pip install pytest
(venv) $ pip install tox

测试简单的 Python 脚本

对于 hello.py 脚本,我们的挑战在于(1)如何从测试中运行它,以及(2)如何捕获输出。Python 标准库中的 subprocess 模块有一个 run() 方法可以很好地解决这两个问题:

# script/hello.py
print("Hello, World!")


# script/test_hello.py
from subprocess import run

def test_hello():
    result = run(["python", "hello.py"], capture_output=True, text=True)
    output = result.stdout
    assert output == "Hello, World!\n"

该测试启动了一个子进程,捕获输出,并将其与 "Hello, World!\n" 进行比较,包括 print() 自动添加的换行符。

pytest -v test_hello.py

如果我们设置一个标准的 tox.ini 文件,它可能不会正常工作。不过我们还是尝试一下:

# script/tox.ini
[tox]
envlist = py39, py310
skipsdist = true
[testenv]
deps = pytest
commands = pytest
[pytest]

运行这个配置会暴露出问题所在:

tox

我们使用 pytesttox 测试了脚本,并使用 subprocess.run() 启动脚本以捕获输出。

测试可导入的 Python 脚本

我们可以稍微修改脚本代码使其可导入,并允许测试和代码位于不同的目录中。

# script_importable/hello.py
def main():
    print("Hello, World!")

if __name__ == "__main__":
    main()

现在我们可以像测试任何其他函数一样测试 main()。在修改后的测试中,我们使用了 capsys

# script_importable/test_hello.py
import hello
def test_main(capsys):
    hello.main()
    output = capsys.readouterr().out
    assert output == "Hello, World!\n"

我们现在可以分别测试这些函数。

# script_funcs/hello.py
def full_output():
    return "Hello, World!"

def main():
    print(full_output())

if __name__ == "__main__":
    main()

我们将输出内容放入 full_output() 函数中,而实际打印则放在 main() 函数中。现在我们可以分别测试它们:

# script_funcs/test_hello.py
import hello

def test_full_output():
    assert hello.full_output() == "Hello, World!"

def test_main(capsys):
    hello.main()
    output = capsys.readouterr().out
    assert output == "Hello, World!\n"

将代码分离到 srctests 目录

我们决定将脚本移动到 src 目录中,而测试则放在 tests 目录中,结构如下:

script_src
├── src
│   └── hello.py
├── tests
│   └── test_hello.py
└── pytest.ini

如果不做其他改变,pytest 会失败。

cd /path/to/code/script_src
pytest --tb=short -c pytest_bad.ini

我们需要做的就是将想要导入的源代码目录添加到 sys.path 中。pytest 提供了一个选项帮助我们做到这一点,即 pythonpath。这个选项是在 pytest 7 中引入的。如果你需要使用 pytest 6.2,你可以使用 pytest-srcpaths 插件来添加这个选项。

首先,我们需要修改 pytest.ini 文件以设置 pythonpathsrc

# script_src/pytest.ini
[pytest]
addopts = -ra
testpaths = tests
pythonpath = src

现在 pytest 可以正常运行了:

pytest tests/test_hello.py

定义 Python 的搜索路径

Python 的搜索路径仅仅是 Python 存储在 sys.path 变量中的一系列目录。

# script_src/tests/test_sys_path.py
import sys

def test_sys_path():
    print("sys.path: ")
    for p in sys.path:
        print(p)

当我们运行它时,注意搜索路径:

pytest -s tests/test_sys_path.py

测试基于 requirements.txt 的应用程序

requirements.txt 文件中的依赖列表可能只是一个松散的依赖列表,例如:

# sample_requirements.txt
typer
requests

然而,对于应用程序来说,更常见的做法是通过定义已知可以工作的具体版本来“锁定”依赖项:

# sample_pinned_requirements.txt
typer==0.3.2
requests==2.26.0

requirements.txt 文件被用来通过 pip install -r 重新创建运行环境。其中的 -r 告诉 pip 读取并安装 requirements.txt 文件中的所有内容。

一个合理的流程可能是:

  • 以某种方式获取代码。例如,git clone <项目仓库>
  • 创建一个虚拟环境:python3 -m venv venv
  • 激活虚拟环境。
  • 安装依赖项:pip install -r requirements.txt
  • 运行应用程序。

我们将使用 Typer 来帮助我们向问候命令添加一个命令行参数。

# app/requirements.txt
typer==0.3.2

请注意,我还锁定了 Typer 的版本为 0.3.2。

pip install typer==0.3.2

或者

pip install -r requirements.txt

我们也需要对代码做一些改动:

# app/src/hello.py
import typer
from typing import Optional

def full_output(name: str):
    return f"Hello, {name}!"

app = typer.Typer()

@app.command()
def main(name: Optional[str] = typer.Argument("World")):
    print(full_output(name))

if __name__ == "__main__":
    app()

Typer 使用类型提示来指定传递给命令行应用的选项和参数的类型,包括可选参数。

$ cd /path/to/code/app/src
$ python hello.py
Hello, World!
$ python hello.py Brian
Hello, Brian!

现在我们需要修改测试以确保 hello.py 无论有无名字参数都能正常工作:

# app/tests/test_hello.py
import hello
from typer.testing import CliRunner

def test_full_output():
    assert hello.full_output("Foo") == "Hello, Foo!"
    
def test_hello_app_no_name():
    runner = CliRunner()
    result = runner.invoke(hello.app)
    assert result.stdout == "Hello, World!\n"

def test_hello_app_with_name():
    result = runner.invoke(hello.app, ["Brian"])
    assert result.stdout == "Hello, Brian!\n"

我们不再直接调用 main(),而是使用 Typer 内置的 CliRunner() 来测试应用程序。

让我们先用 pytest 运行它,然后再用 tox 运行:

cd /path/to/code/app
pytest -v

我们通过在 deps 设置中添加 -rrequirements.txt 来实现这一点:

# ch12/app/tox.ini
[tox]
envlist = py39, py310
skipsdist = true
[testenv]
deps = pytest
    pytest-srcpaths
    -rrequirements.txt
commands = pytest
[pytest]
addopts = -ra
testpaths = tests
pythonpath = src

我们有一个包含外部依赖项的应用程序,这些依赖项列在 requirements.txt 文件中。我们使用 pythonpath 指定源代码位置,并在 tox.ini 中添加了 -rrequirements.txt 以安装这些依赖项。

这很简单。我们来试一试:

tox

第 13 章. 调试测试失败

Python 包含一个内置的源代码调试器叫做 pdb,以及一些标志来方便地使用 pdb 进行调试。

向 Cards 项目添加新功能

为此,我们需要一个命令行接口(CLI)命令:

# cards_proj/src/cards/cli.py
@app.command("done")
def list_done_cards():
    """
    列出数据库中状态为 'done' 的卡片。
    """
    with cards_db() as db:
        the_cards = db.list_done_cards()
        print_cards_list(the_cards)

list_done_cards() 方法实际上只需要调用 list_cards() 并预填充 state="done"

# cards_proj/src/cards/api.py
def list_done_cards(self):
    """返回状态为 'done' 的卡片。"""
    done_cards = self.list_cards(state="done")

首先,@pytest.mark.num_cards(10)Faker 生成卡片的内容。API 测试如下:

# cards_proj/tests/api/test_list_done.py
import pytest

@pytest.mark.num_cards(10)
def test_list_done(cards_db):
    cards_db.finish(3)
    cards_db.finish(5)
    the_list = cards_db.list_done_cards()
    assert len(the_list) == 2
    for card in the_list:
        assert card.id in (3, 5)
        assert card.state == "done"

现在我们添加 CLI 测试:

# cards_proj/tests/cli/test_done.py
import cards

expected = """\
    ID state owner summary
    ────────────────────────────────
    1 done some task
    3 done a third"""

def test_done(cards_db, cards_cli):
    cards_db.add_card(cards.Card("some task", state="done"))
    cards_db.add_card(cards.Card("another"))
    cards_db.add_card(cards.Card("a third", state="done"))
    output = cards_cli("done")
    assert output == expected

以可编辑模式安装 Cards

让我们创建一个新的虚拟环境:

$ cd /path/to/code/ch13
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install -U pip

我们可以以可编辑模式安装 Cards,并且一次性安装所有测试工具及其可选依赖项。

$ pip install -e "./cards_proj/[test]"

这能起作用是因为所有这些依赖项都在 pyproject.toml 文件的可选依赖项部分定义了:

# cards_proj/pyproject.toml
[project.optional-dependencies]
test = [
    "pytest",
    "faker",
    "tox",
    "coverage",
    "pytest-cov",
]

现在我们运行测试。我们将使用 --tb=no 来关闭堆栈跟踪信息:

$ cd /path/to/code/cards_proj $ pytest --tb=no

使用 pytest 标志进行调试

pytest 包含许多命令行标志,对于调试非常有用。我们将使用其中的一些来调试测试失败。

用于选择要运行哪些测试、运行顺序以及何时停止的标志:

  • -lf / --last-failed: 只运行上次失败的测试
  • -ff / --failed-first: 按上次失败的顺序运行所有测试
  • -x / --exitfirst: 在第一个失败后停止测试会话
  • --maxfail=num: 在达到指定数量的失败后停止测试
  • -nf / --new-first: 按文件修改时间顺序运行所有测试
  • --sw / --stepwise: 在第一个失败后停止测试;下次从上一次失败的地方开始运行
  • --sw-skip / --stepwise-skip: 与 --sw 相同,但跳过第一个失败

用于控制 pytest 输出的标志:

  • -v / --verbose: 显示所有测试名称,无论是通过还是失败
  • --tb=[auto/long/short/line/native/no]: 控制堆栈跟踪的样式
  • -l / --showlocals: 在堆栈跟踪旁边显示局部变量

用于启动命令行调试器的标志:

  • --pdb: 在失败点启动交互式调试会话
  • --trace: 在运行每个测试时立即启动 pdb 源码调试器
  • --pdbcls: 使用 pdb 的替代品,如 IPython 的调试器 --pdbcls=IPython.terminal.debugger:TerminalPdb

重新运行失败的测试

我们将使用 --lf 仅重新运行失败的测试,并使用 --tb=no 隐藏堆栈跟踪,因为我们还不准备查看它:

$ pytest --lf --tb=no

让我们只运行第一次失败的测试,在失败后停止并查看堆栈跟踪:

$ pytest --lf -x

为了确保理解问题所在,我们可以使用 -l/--showlocals 再次运行相同的测试。我们不需要再次看到完整的堆栈跟踪,所以可以使用 --tb=short 缩短它:

$ pytest --lf -x -l --tb=short

使用 pdb 进行调试

pdb,即“Python 调试器”,是 Python 标准库的一部分,因此我们无需额外安装即可使用它。

你可以通过以下几种方式从 pytest 中启动 pdb:

  • 在测试代码或应用程序代码中添加一个 breakpoint() 调用。当 pytest 运行遇到 breakpoint() 函数调用时,它将在该处停止并启动 pdb。
  • 使用 --pdb 标志。使用 --pdb,pytest 将在失败点停止。在这种情况下,将是 assert len(the_list) == 2 这一行。
  • 使用 --trace 标志。使用 --trace,pytest 将在每个测试开始时停止。

对于我们的目的,结合 --lf--trace 是完美的。这个组合将告诉 pytest 重新运行失败的测试并在 test_list_done() 开始之前停止,即在调用 list_done_cards() 之前:

$ pytest --lf --trace

以下是 pdb 支持的常用命令。

元命令: • h(elp): 打印命令列表 • h(elp) command: 打印特定命令的帮助 • q(uit): 退出 pdb

查看当前位置: • l(ist): 列出当前行周围的 11 行。再次使用会列出下 11 行,依此类推 • l(ist) .: 同上,但使用一个点。列出当前行周围的 11 行。如果你已经使用 l(list) 多次并丢失了当前位置,这很有用 • l(ist) first, last: 列出特定行范围 • ll: 列出当前函数的所有源代码 • w(here): 打印堆栈跟踪

查看值: • p(rint) expr: 评估表达式并打印其值 • pp expr: 与 p(rint) expr 相同,但使用 pprint 模块的漂亮打印。非常适合结构化数据 • a(rgs): 打印当前函数的参数列表

执行命令: • s(tep): 执行当前行并步入下一行源代码,即使它位于函数内部 • n(ext): 执行当前行并步进到当前函数中的下一行 • r(eturn): 继续执行直到当前函数返回 • c(ontinue): 继续执行直到下一个断点。当与 --trace 一起使用时,继续执行直到下一个测试的开始 • unt(il) lineno: 继续执行直到给定的行号

继续调试我们的测试,我们将使用 ll 列出当前函数:

(Pdb) ll

我们可以使用 until 8 在调用 list_done_cards() 之前打断点:

(Pdb) until 8

然后使用 step 进入函数:

(Pdb) step

让我们再次使用 ll 查看整个函数:

(Pdb) ll

现在让我们继续执行直到此函数即将返回:

(Pdb) return

我们可以使用 ppp 查看 done_cards 的值:

(Pdb) pp done_cards

如果我们继续退出调试器,进入调用测试并检查返回值,我们可以进一步确认:

(Pdb) step (Pdb) ll (Pdb) pp the_list

如果我们停止调试器,向 list_done_cards() 添加 return done_cards,并重新运行失败的测试,我们可以看看是否修复了问题:

(Pdb) exit $ pytest --lf -x -v --tb=no

结合使用 pdb 和 tox

我们已经在 Cards 的 tox.ini 文件中设置好了。

# cards_proj/tox.ini
[tox]
envlist = py39, py310
isolated_build = True
skip_missing_interpreters = True
[testenv]
deps =
    pytest
    faker
    pytest-cov
commands = pytest --cov=cards --cov=tests --cov-fail-under=100 {posargs}

让我们运行一次并使用 -e py310 -- --pdb --no-cov 在失败点停止。(--no-cov 用于禁用覆盖率报告。)

$ tox -e py310 -- --pdb --no-cov

我们可以在测试中添加空格,并重新运行带有单个测试失败的 tox 环境:

$ tox -e py310 -- --lf --tb=no --no-cov -v

第 14 章. 第三方插件

寻找插件

你可以从几个地方找到第三方 pytest 插件。

插件列表 pytest 的主要文档网站包含了一个按字母顺序排列的插件列表,这些插件是从 PyPI 获取的。列表非常长。

PyPI Python 包索引 (PyPI) 是获取大量 Python 包的好地方,同时也是寻找 pytest 插件的好去处。当你寻找 pytest 插件时,搜索 pytest、pytest- 或 -pytest 应该能得到不错的结果,因为大多数 pytest 插件要么以 pytest- 开头,要么以 -pytest 结尾。你也可以通过分类器 “Framework :: Pytest” 进行过滤,这样会包括那些虽然不以 pytest- 或 -pytest 命名但包含 pytest 插件的包,例如 Hypothesis 和 Faker。

pytest-dev GitHub 上的 pytest-dev 组织存放着 pytest 的源代码。这也是许多流行的 pytest 插件所在的地方。对于插件而言,pytest-dev 组织旨在作为一个集中位置,以维护一些流行的 pytest 插件,并分担一些维护责任。更多信息请参考 docs.pytest.org 网站上的 “提交插件到 pytest-dev”。

pytest 主文档网站 pytest 的主文档网站有一个页面讨论了如何安装和使用 pytest 插件,并列出了几个常用的插件。

安装插件

pytest 插件是通过 pip 安装的,就像你在本书前面章节中安装的其他 Python 包一样。

$ pip install pytest-cov

探索 pytest 插件的多样性

改变常规测试运行流程的插件

有时改变测试的运行顺序是有好处的。以下是一些改变常规测试运行流程的插件:

  • pytest-order — 允许我们使用标记来指定顺序
  • pytest-randomly — 随机化测试的顺序,首先是文件级别,然后是类级别,最后是测试级别
  • pytest-repeat — 方便重复运行单一测试或多个测试特定次数
  • pytest-rerunfailures — 重新运行失败的测试。对于不可靠的测试很有帮助
  • pytest-xdist — 并行运行测试,可以在一台机器上的多个 CPU 上或多个远程机器上运行

改变或增强输出的插件

常规的 pytest 输出主要显示通过测试的点以及其他输出的字符。如果你传递 -v 参数,你会看到测试名称和结果的列表。但是,有一些插件可以改变输出形式。

  • pytest-instafail — 添加了一个 --instafail 标志,可以在测试失败后立即报告失败测试的堆栈跟踪和输出。通常情况下,pytest 在所有测试完成后才报告这些信息。
  • pytest-sugar — 用绿色勾号代替通过测试的点,并且有一个漂亮的进度条。它也会像 pytest-instafail 一样立即显示失败情况。
  • pytest-html — 允许生成 HTML 报告。报告可以扩展额外的数据和图像,例如失败案例的屏幕截图。

用于 Web 开发的插件

pytest 广泛用于测试 Web 项目,因此有许多插件可以帮助进行 Web 测试也就不足为奇了。

  • pytest-selenium — 提供了便于配置基于浏览器的测试的固定件。Selenium 是一个流行的浏览器测试工具。
  • pytest-splinter — 基于 Selenium 构建了一个更高级别的接口,使得更容易地从 pytest 使用 Splinter。
  • pytest-django 和 pytest-flask — 使使用 pytest 测试 Django 和 Flask 应用程序变得更加容易。Django 和 Flask 是 Python 最流行的 Web 框架之一。

生成假数据的插件

有几个插件满足这一需求。

  • Faker — 为你生成假数据。提供了 faker 固定件用于 pytest
  • model-bakery — 生成带有假数据的 Django 模型对象。
  • pytest-factoryboy — 包含了 Factory Boy 的固定件,这是一个数据库模型数据生成器
  • pytest-mimesis — 生成类似 Faker 的假数据,但 Mimesis 的速度要快得多

扩展 pytest 功能的插件

所有的插件都扩展了 pytest 的功能,但我正在耗尽好的分类名称。这里是一些各种很酷的插件。

  • pytest-cov — 在测试的同时运行覆盖率分析
  • pytest-benchmark — 对测试中的代码进行基准计时
  • pytest-timeout — 不让测试运行太久
  • pytest-asyncio — 测试异步函数
  • pytest-bdd — 使用 pytest 编写行为驱动开发 (BDD) 风格的测试
  • pytest-freezegun — 冻结时间,使得任何读取时间的代码在测试期间都能得到相同的时间值。你还可以设置特定的日期或时间。
  • pytest-mock — 对 unittest.mock 的补丁 API 的轻量级封装

并行运行测试

如果你的测试不需要访问共享资源,可以通过并行运行多个测试来加速测试会话。pytest-xdist 插件允许你这么做。你可以指定多个处理器并并行运行许多测试。你甚至可以把测试推送到其他机器上,并使用多台计算机。

我们可以使用 -n=auto 来尽可能多地利用 CPU 核心:

$ pytest --count=10 -n=auto test_parallel.py

下面是使用 -n=6 对于 60 个测试的情况:

$ pytest --count=60 -n=6 test_parallel.py

pytest-xdist 插件还附带了一个很好的特性:--looponfail 标志。--looponfail 标志允许你在子进程中反复运行测试。

随机化测试顺序

pytest-randomly 插件非常适合随机化测试顺序。它还随机化了其他随机工具(如 Faker 和 Factory Boy)的种子值。

要随机化测试顺序,请安装 pytest-randomly:

$ pip install pytest-randomly $ pytest -v

定期随机化你的测试可以帮助你避免某些问题。

第 15 章. 构建插件

从一个酷点子开始

这里是我们的想法(文档实际上使用 --runslow,但我们使用 --slow,因为它更短,而且在我看来似乎是一个更好的标志):

  • 使用 @pytest.mark.slow 标记那些运行非常慢以至于你不希望总是运行它们的测试。
  • 当 pytest 收集要运行的测试时,通过向所有标记为 @pytest.mark.slow 的测试添加额外的标记 @pytest.mark.skip(reason="需要 --slow 选项来运行") 来拦截这个过程。这样,这些测试默认会被跳过。
  • 添加 --slow 标志,以便用户能够覆盖这种行为并实际运行慢速测试。在正常情况下,每当运行 pytest 时,标记为慢速的测试将会被跳过。然而,--slow 标志将会运行所有测试,包括慢速测试。
  • 要仅运行慢速测试,仍然可以使用 -m slow 来选择标记,但必须与 --slow 结合使用,所以 -m slow --slow 将只运行慢速测试。

我们已经可以使用标记来选择或排除特定测试。有了 --slow,我们只是试图改变默认行为,即排除标记为“慢”的测试:

行为 没有插件 有插件
排除慢速 pytest -m “not slow” pytest
包含慢速 pytest pytest –slow
仅慢速 pytest -m slow pytest -m slow –slow

测试文件看起来像这样:

# just_markers/test_slow.py
import pytest

def test_normal():
    pass

@pytest.mark.slow
def test_slow():
    pass

这里是声明“慢”标记的配置文件:

# just_markers/pytest.ini
[pytest]
markers = slow: 标记测试为慢速运行

我们想要更容易实现的行为,即避免慢速测试,看起来像这样:

cd path/to/code/just_markers
pytest -v -m "not slow"

构建本地的 conftest 插件

我们将从修改 conftest.py 文件开始,并在将代码移到插件之前在当地测试我们的更改。

为了修改 pytest 的工作方式,我们需要利用 pytest 的钩子函数。钩子函数是 pytest 提供的功能入口点,允许插件开发者在某些点拦截 pytest 的行为并做出改变。pytest 文档列出了很多钩子函数。

  • pytest_configure() — 允许插件和 conftest 文件执行初始配置。我们将使用这个钩子函数预声明慢速标记,这样用户就不必在他们的配置文件中添加 slow
  • pytest_addoption() — 用来注册选项和设置。我们将使用这个钩子添加 --slow 标志。
  • pytest_collection_modifyitems() — 在收集测试之后调用,可用于过滤或重新排序测试项。我们需要这个来查找慢速测试,以便我们可以标记它们以跳过。

让我们从 pytest_configure() 开始,声明慢速标记,并使用 pytest_addoption() 添加 --slow 标志:

# conftest.py
import pytest

def pytest_configure(config):
    config.addinivalue_line("markers", "slow: 标记测试为慢速运行")

def pytest_addoption(parser):
    parser.addoption(
        "--slow", action="store_true", help="包含标记为慢速的测试"
    )

def pytest_collection_modifyitems(config, items):
    if not config.getoption("--slow"):
        skip_slow = pytest.mark.skip(reason="需要 --slow 选项来运行")
        for item in items:
            if item.get_closest_marker("slow"):
                item.add_marker(skip_slow)

创建可安装的插件

顶级目录的名称并不重要。我们将称其为 pytest_skip_slow

pytest_skip_slow
├── examples
│   └── test_slow.py
└── pytest_skip_slow.py

我们首先安装 Flit 并在一个虚拟环境中以及新目录中运行 flit init

cd path/to/code/pytest_skip_slow
pip install flit
flit init

现在让我们看看 flit initpyproject.toml 的样子:

[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"

[project]
name = "pytest-skip-slow"
authors = [{name = "您的名字", email = "your.name@example.com"}]
readme = "README.md"
classifiers = [
    "License :: OSI Approved :: MIT License",
    "Framework :: Pytest"
]
dynamic = ["version", "description"]
dependencies = ["pytest>=6.2.0"]
requires-python = ">=3.7"

[project.urls]
Home = "https://github.com/okken/pytest-skip-slow"

[project.entry-points.pytest11]
skip_slow = "pytest_skip_slow"

[project.optional-dependencies]
test = ["tox"]

[tool.flit.module]
name = "pytest_skip_slow"

我们几乎准备好构建我们的包了。不过还有一些缺失的东西,我们需要:

  1. pytest_skip_slow.py 的顶部添加一个描述插件的文档字符串。
  2. pytest_skip_slow.py 中添加一个 __version__ 字符串。
  3. 创建一个 README.md 文件。(它不必很花哨;我们可以稍后再添加内容。)

这是 pytest_skip_slow.py 中的文档字符串和版本:

# pytest_skip_slow_final/pytest_skip_slow.py
"""
一个 pytest 插件,用于默认跳过 `@pytest.mark.slow` 的测试。
使用 `--slow` 包含慢速测试。
"""
import pytest

__version__ = "0.0.1"
# ... 我们的插件剩余部分的代码 ...

这是一个简单的 README.md 文件:

# pytest_skip_slow_final/README.md
# pytest-skip-slow
一个 pytest 插件,用于默认跳过 `@pytest.mark.slow` 的测试。
使用 `--slow` 包含慢速测试。

现在我们可以使用 flit build 来构建一个可安装的包:

flit build

我们可以直接安装轮子文件来自行尝试:

pip install dist/pytest_skip_slow-0.0.1-py3-none-any.whl
pytest examples/test_slow.py
pytest --slow examples/test_slow.py

如果我们打算在这里停下来,还需要记住做以下几步:

  • 确保 .gitignore 文件中忽略了 __pycache__dist 目录。
  • 提交 LICENSEREADME.mdpyproject.tomlexamples/test_slow.pypytest_skip_slow.py 到版本控制系统。

使用 pytester 测试插件

插件是需要像其他代码一样进行测试的代码。然而,测试对测试工具的更改有些棘手。当我们手动使用 test_slow.py 测试插件时,

  • 使用 -v 运行以确保标记为慢速的测试被跳过,
  • 使用 -v --slow 运行以确保所有测试都运行了,
  • 使用 -v -m slow --slow 运行以确保仅慢速测试运行。

我们首先需要在 conftest.py 中启用它:

# pytest_skip_slow_final/tests/conftest.py
pytest_plugins = ["pytester"]

pytester 文档列出了一系列函数来帮助填充这个目录:

  • makefile() 创建任何类型的文件。
  • makepyfile() 创建 Python 文件。这通常用来创建测试文件。
  • makeconftest() 创建 conftest.py
  • makeini() 创建 tox.ini
  • makepyprojecttoml() 创建 pyproject.toml
  • maketxtfile() … 你明白了吧。
  • mkdir()mkpydir() 创建包含或不包含 __init__.py 的测试子目录。
  • copy_example() 从项目的目录复制文件到临时目录。这是我最喜欢的,也是我们将用来测试我们的插件的方式。

让我们来看一个例子:

# pytest_skip_slow_final/tests/test_plugin.py
import pytest

@pytest.fixture()
def examples(pytester):
    pytester.copy_example("examples/test_slow.py")

def test_skip_slow(pytester, examples):
    result = pytester.runpytest("-v")
    result.stdout.fnmatch_lines(
        [
            "*test_normal PASSED*",
            "*test_slow SKIPPED (需要 --slow 选项来运行)*",
        ]
    )
    result.assert_outcomes(passed=1, skipped=1)

def test_run_slow(pytester, examples):
    result = pytester.runpytest("--slow")
    result.assert_outcomes(passed=2)

def test_run_only_slow(pytester, examples):
    result = pytester.runpytest("-v", "-m", "slow", "--slow")
    result.stdout.fnmatch_lines(["*test_slow PASSED*"])
    outcomes = result.parseoutcomes()
    assert outcomes["passed"] == 1
    assert outcomes["deselected"] == 1

def test_help(pytester):
    result = pytester.runpytest("--help")
    result.stdout.fnmatch_lines(
        ["*--slow * 包含标记为慢速的测试*"]
    )

在运行此测试之前,让我们先针对可编辑代码进行测试:

cd /path/to/code/ch15/pytest_skip_slow_final
pip uninstall pytest-skip-slow
pip install -e .

使用 tox 测试多个 Python 和 pytest 版本

这是我们插件的 tox.ini 文件:

# pytest_skip_slow_final/tox.ini
[pytest]
testpaths = tests

[tox]
envlist = py{37, 38, 39, 310}-pytest{62,70}
isolated_build = True

[testenv]
deps =
    pytest62: pytest==6.2.5
    pytest70: pytest==7.0.0
commands = pytest {posargs:tests}
description = 运行 pytest

现在我们只需要运行它:

tox -q --parallel

发布插件

要发布你的插件,你可以:

  • 将插件代码推送到 Git 存储库并从那里安装。
  • 将轮子文件 pytest_skip_slow-0.0.1-py3-none-any.whl 复制到某个共享目录并从那里安装。
    • cp dist/*.whl path/to/my_packages/
    • pip install pytest-skip-slow –no-index –find-links=path/to/my_packages/
  • 发布到 PyPI。
    • 查看 Python 关于打包的文档中的“上传发行存档”部分。
    • 参阅 Flit 文档中的“控制包上传”部分。
原创文章转载请注明出处: 【Python Testing with pytest Simple, Rapid, Effective, and Scalable】笔记