Home > Techniques > Pytest Deep Dive Tutorial: Beginner-Friendly Guide to Python Testing

Pytest Deep Dive Tutorial: Beginner-Friendly Guide to Python Testing
pytest python testing unit-testing tutorial test-driven-development programming

Pytest 深度入门教程 (初学者友好版)

pytest 是一个功能丰富、易于使用且非常流行的 Python 测试框架。与 Python 内置的 unittest 模块相比,pytest 的语法更简洁、更灵活,并且拥有庞大的插件生态系统,能够极大地提升你的测试效率和体验。

想象一下,你是一位大厨,需要确保每一道菜品都符合标准。测试代码就像是品尝和检验菜品的过程,而 pytest 就是一套能帮你高效完成这个过程的顶级厨具和标准化流程。

为什么选择 Pytest?

  1. 简单易学,上手快:
    • 你不需要学习复杂的类结构,直接使用标准的 Python 函数来编写测试。
    • 断言(检查条件是否为真)直接使用 Python 内置的 assert 语句,非常直观。
  2. 强大的断言功能:
    • pytestassert 语句进行了智能处理。当断言失败时,它会提供非常详细的上下文信息,告诉你哪里出了错,以及相关变量的当前值,极大地帮助调试。
  3. 自动发现测试:
    • 你只需要遵循简单的命名约定,pytest 就能自动找到你的测试文件和测试函数,无需手动注册。
  4. 丰富的插件生态系统:
    • 拥有大量开箱即用的插件,例如:
      • pytest-cov: 用于生成测试覆盖率报告。
      • pytest-xdist: 用于并行执行测试,加快测试速度。
      • pytest-django, pytest-flask: 用于集成主流Web框架。
      • 还有更多用于报告、Mocking 等功能的插件。
  5. 优雅的 Fixtures (测试固件/夹具):
    • 这是 pytest 的核心特性之一。Fixtures 提供了一种模块化、可重用的方式来管理测试的准备工作(setup)和清理工作(teardown)。你可以把它们看作是测试函数运行前需要准备好的“原材料”或“环境”。
  6. 灵活的参数化测试 (Parametrization):
    • 可以非常方便地为同一个测试函数提供多组不同的输入数据和预期输出,避免编写大量重复的测试逻辑。
  7. 清晰的测试报告:
    • 默认提供简洁明了的测试报告,通过插件还可以生成更详细的HTML报告。

安装 Pytest

安装 pytest 非常简单,只需要使用 pip:

pip install pytest

安装完成后,你就可以在你的项目中使用 pytest 了。

你的第一个 Pytest 测试

pytest 通过遵循特定的命名约定来自动发现测试:

  • 测试文件: 通常命名为 test_*.py (例如 test_calculator.py) 或 *_test.py (例如 calculator_test.py)。
  • 测试函数: 在测试文件中,以 test_ 开头的函数会被识别为测试函数 (例如 def test_addition():)。
  • 测试类 (可选): 如果你喜欢将相关的测试组织在类中,类名应以 Test 开头 (例如 class TestCalculator:),类中的测试方法同样以 test_ 开头。pytest 不需要测试类继承任何特定的基类。

让我们创建一个名为 test_example.py 的文件,并编写一个简单的测试:

# test_example.py

# 这是我们要测试的函数
def inc(x):
    return x + 1

# 这是我们的第一个测试函数
def test_increment_positive_number():
    # "Arrange" (准备) - 定义输入和预期输出
    input_value = 3
    expected_value = 4
    # "Act" (执行) - 调用被测试的函数
    result = inc(input_value)
    # "Assert" (断言) - 检查结果是否符合预期
    assert result == expected_value

def test_increment_zero():
    assert inc(0) == 1

def test_increment_negative_number():
    assert inc(-5) == -4

代码解释:

  • 我们定义了一个简单的函数 inc(x),它将输入值加1。
  • test_increment_positive_number 是一个测试函数。它遵循了“Arrange-Act-Assert”(AAA)模式:
    • Arrange: 设置测试所需的初始条件和输入。
    • Act: 执行被测试的代码。
    • Assert: 验证结果是否与预期相符。
  • 我们直接使用 assert 关键字来声明我们的期望。如果 inc(3) 的结果不等于 4assert 语句会抛出 AssertionErrorpytest 会捕获这个错误并将测试标记为失败。

运行你的测试

打开你的终端或命令行工具,导航到包含 test_example.py 文件的目录,然后简单地运行以下命令:

pytest

发生了什么?

  1. pytest 会从当前目录开始,递归地查找所有符合命名约定的测试文件 (test_*.py*_test.py)。
  2. 在找到的测试文件中,它会查找所有符合命名约定的测试函数 (test_*) 或测试类 (Test*) 中的测试方法。
  3. 然后,它会逐个执行这些测试。
  4. 最后,它会汇总结果并显示出来。

预期输出 (默认模式):

============================= test session starts ==============================
platform ... -- Python ...
plugins: ...
collected 3 items

test_example.py ...                                                      [100%]

============================== 3 passed in X.XXs ===============================
  • collected 3 items: pytest 找到了3个测试函数。
  • test_example.py ...: 每个点 (.) 代表一个通过的测试。如果所有测试都通过,你会看到一串点。
  • 3 passed in X.XXs: 总结信息,告诉你有多少测试通过以及花费的时间。

如果某个测试失败了,比如我们故意修改 test_increment_zero

# test_example.py
# ... (其他代码不变) ...
def test_increment_zero():
    assert inc(0) == 2 # 故意写错,应该是 1

再次运行 pytest,输出会变成:

============================= test session starts ==============================
platform ... -- Python ...
plugins: ...
collected 3 items

test_example.py .F.                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_increment_zero ____________________________

    def test_increment_zero():
>       assert inc(0) == 2 # 故意写错,应该是 1
E       assert 1 == 2
E        +  where 1 = inc(0)

test_example.py:14: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_increment_zero - assert 1 == 2
========================= 1 failed, 2 passed in X.XXs ==========================

注意看 FAILURES 部分,pytest 非常清晰地指出了:

  • 哪个测试函数失败了 (test_increment_zero)。
  • 失败的 assert 语句是什么 (assert inc(0) == 2)。
  • 断言失败时的具体值比较 (assert 1 == 2),并且它还告诉我们 1inc(0) 的结果。这种详细的错误报告是 pytest 的一大优势。

理解 -v (详细) 和 -q (静默) 参数

pytest 提供了不同的命令行选项来控制输出的详细程度。

  • pytest (无参数 - 默认模式):

    • 如上所示,对每个通过的测试显示一个点 (.)。
    • 失败的测试显示 F
    • 如果测试代码本身有错误(不是断言失败,而是比如语法错误或未捕获的异常),会显示 E
    • 最后会有一个总结,如果存在失败或错误,会有详细的失败信息。
  • pytest -v (verbose - 详细模式):

    • 这个选项会为每个测试函数显示其完整的名称以及测试结果 (PASSED, FAILED, ERROR)。
    • 当你有很多测试,并且想清楚地看到每个测试的执行状态时,这个模式非常有用。
    pytest -v
    

    如果所有测试都通过,输出示例:

    ============================= test session starts ==============================
    platform ... -- Python ...
    plugins: ...
    collected 3 items
      
    test_example.py::test_increment_positive_number PASSED                   [ 33%]
    test_example.py::test_increment_zero PASSED                              [ 66%]
    test_example.py::test_increment_negative_number PASSED                   [100%]
      
    ============================== 3 passed in X.XXs ===============================
    
  • pytest -q (quiet - 静默模式):

    • 这个选项会大幅减少输出信息。
    • 如果所有测试都通过,它通常只输出最后的总结行,甚至可能什么都不输出(除了最终的退出码)。
    • 只有在测试失败出错时,它才会输出相关的错误信息和总结。
    • 这个模式非常适合在持续集成 (CI) 系统中使用,因为你通常只关心是否有问题发生。
    pytest -q
    

    如果所有测试都通过,输出示例可能仅仅是:

    ============================== 3 passed in X.XXs ===============================
    

    或者,如果CI环境配置为在成功时不输出,你可能什么都看不到。

    如果你之前运行 pytest -q 没有看到任何关于测试通过的点的输出,那恰恰说明你的所有测试都成功通过了! -q 的设计目标就是在一切顺利时保持安静。

何时使用哪个参数?

  • 日常开发,快速检查:pytest
  • 想看每个测试的名称和状态,或者调试时:pytest -v
  • 在自动化脚本或CI环境中,只关心失败:pytest -q

使用 assert 进行强大的断言

pytest 最棒的一点就是它允许你直接使用 Python 内置的 assert 语句。当 assert 后面的条件为 False 时,会引发 AssertionErrorpytest 会捕获这个错误,将测试标记为失败,并提供非常丰富的调试信息,包括表达式中各个部分的值。

让我们看更多断言的例子。创建一个新文件 test_assertions.py

# test_assertions.py
import pytest # 需要导入 pytest 来使用 pytest.raises

# 要测试的函数
def get_user_info(user_id):
    if user_id == 1:
        return {"name": "Alice", "age": 30, "active": True}
    elif user_id == 2:
        return {"name": "Bob", "age": 24, "active": False}
    else:
        return None

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero") # 注意:这里我们抛出 ValueError
    return a / b

# 测试函数
def test_user_alice():
    alice = get_user_info(1)
    assert alice is not None
    assert alice["name"] == "Alice"
    assert alice["age"] > 25
    assert alice["active"] is True # 明确检查布尔值

def test_user_bob_inactive():
    bob = get_user_info(2)
    assert bob["name"].startswith("B")
    assert not bob["active"] # 另一种检查 False 的方式
    assert "email" not in bob # 检查字典中是否不包含某个键

def test_unknown_user():
    unknown = get_user_info(99)
    assert unknown is None

def test_division_normal():
    assert divide(10, 2) == 5.0
    assert divide(7, 2) == 3.5

def test_division_by_zero_custom_error():
    # 测试函数是否按预期抛出了特定的异常
    # pytest.raises 作为一个上下文管理器使用
    with pytest.raises(ValueError) as excinfo: # 捕获 ValueError
        divide(10, 0)
    # 可选:检查异常信息是否符合预期
    assert "Cannot divide by zero" in str(excinfo.value)

def test_list_operations():
    my_list = [10, 20, 30, 40]
    assert 20 in my_list
    assert 50 not in my_list
    assert len(my_list) == 4
    # Pytest 的断言内省对于比较序列非常有用
    # 如果下面这个断言失败了: assert my_list == [10, 20, 35, 40]
    # Pytest 会告诉你具体哪个元素不同
    assert my_list == [10, 20, 30, 40]

def test_string_properties():
    text = "Pytest is awesome!"
    assert "awesome" in text
    assert text.lower() == "pytest is awesome!"
    assert text.endswith("!")
    assert len(text.split()) == 3

运行这些测试:

pytest test_assertions.py -v

关键点:

  • 丰富的比较信息: 如果 assert alice["name"] == "Bob" 失败了 (因为实际上是 “Alice”),pytest 会告诉你 assert "Alice" == "Bob",让你清楚地看到实际值和期望值的差异。
  • 测试异常 (pytest.raises): 当你期望某段代码抛出特定类型的异常时,使用 pytest.raises。它会捕获预期的异常,如果代码没有抛出该异常,或者抛出了不同类型的异常,测试就会失败。excinfo 对象包含了关于捕获到的异常的详细信息。
  • 涵盖多种数据类型: 你可以用 assert 来检查数字、字符串、列表、字典、布尔值等几乎所有 Python 对象。

参数化测试 (@pytest.mark.parametrize)

当你需要用不同的输入和期望输出来测试同一个函数逻辑时,参数化测试非常有用。它可以避免你编写大量结构相似的测试函数。

你已经在你的 test_single_and_batch 测试中使用了它,这是一个很好的实践!

让我们创建一个 test_parametrize_examples.py 文件:

# test_parametrize_examples.py
import pytest

# 要测试的函数
defis_palindrome(text):
    if not isinstance(text, str):
        raise TypeError("Input must be a string")
    return text.lower() == text.lower()[::-1]

# 使用 parametrize
@pytest.mark.parametrize("test_input, expected_output", [
    ("madam", True),
    ("racecar", True),
    ("hello", False),
    ("Aibohphobia", True), # 测试大小写不敏感
    ("", True),             # 测试空字符串
    (" ", True),            # 测试单个空格
    ("No lemon, no melon.", False) # 包含标点和空格,按当前函数逻辑会失败
])
def test_is_palindrome_various_inputs(test_input, expected_output):
    assertis_palindrome(test_input) == expected_output

# 另一个例子:测试数据类型检查
@pytest.mark.parametrize("invalid_input", [
    123,
    ["list"],
    None,
    {"a": 1}
])
def test_is_palindrome_invalid_type(invalid_input):
    with pytest.raises(TypeError) as excinfo:
       is_palindrome(invalid_input)
    assert "Input must be a string" in str(excinfo.value)

# 你也可以给每个参数组合起一个ID,方便在报告中识别
@pytest.mark.parametrize(
    "a, b, expected_sum",
    [
        pytest.param(1, 2, 3, id="positive_nums"),
        pytest.param(-1, -2, -3, id="negative_nums"),
        pytest.param(-1, 1, 0, id="mixed_nums"),
        pytest.param(0, 0, 0, id="zeros")
    ]
)
def test_addition(a, b, expected_sum):
    assert a + b == expected_sum

运行:

pytest test_parametrize_examples.py -v

你会看到 test_is_palindrome_various_inputs 为每一组参数都运行了一次。如果其中一组失败,报告会明确指出是哪一组参数导致了失败。test_addition 的输出会使用你提供的 id 来标识每个测试用例。

参数化的好处:

  • 代码简洁: 避免了为每个场景编写单独的测试函数。
  • 可读性高: 测试数据和预期结果清晰地组织在一起。
  • 易于扩展: 添加新的测试场景只需要在参数列表中增加一行。
  • 覆盖更全: 方便测试各种边界条件和特殊情况。

Fixtures (测试固件/夹具) - 优雅的测试准备与清理

Fixtures 是 pytest 中一个非常强大和核心的概念。它们用于:

  1. 提供测试所需的上下文或数据: 比如一个数据库连接、一个临时文件、一个已登录的用户对象等。
  2. 管理测试的准备 (setup) 和清理 (teardown) 过程: 确保测试在一致的环境中运行,并在测试结束后释放资源。

你可以把 fixture 想象成戏剧表演中的“道具”或“场景布置”。每个需要特定道具的“场景”(测试函数)都可以声明它需要哪些道具,pytest 会在场景开始前准备好这些道具,并在场景结束后清理它们。

定义 Fixture:

Fixture 本身也是一个 Python 函数,使用 @pytest.fixture 装饰器来标记。

使用 Fixture:

测试函数如果需要某个 fixture,只需将其名称作为参数声明即可。pytest 会自动查找并执行对应的 fixture 函数,并将其返回值(如果有的话)传递给测试函数。

1. 基础 Fixture 示例

让我们创建一个 test_fixtures_basic.py 文件:

# test_fixtures_basic.py
import pytest
import tempfile # 用于创建临时文件/目录
import os
import shutil # 用于删除目录

# 定义一个 fixture,它会创建一个简单的字典数据
@pytest.fixture
def sample_user_data():
    print("\n(Fixture: Creating sample_user_data...)") # 方便观察fixture何时执行
    data = {"username": "testuser", "email": "test@example.com", "is_active": True}
    return data

# 测试函数使用这个 fixture
def test_user_username(sample_user_data):
    print("\n(Test: Running test_user_username...)")
    assert sample_user_data["username"] == "testuser"

def test_user_is_active(sample_user_data):
    print("\n(Test: Running test_user_is_active...)")
    assert sample_user_data["is_active"] is True

# 另一个 fixture,演示 setup 和 teardown (使用 yield)
@pytest.fixture
def managed_tmp_dir():
    dir_name = tempfile.mkdtemp(prefix="pytest_managed_") # Setup: 创建临时目录
    print(f"\n(Fixture: Created temp directory: {dir_name})")
    yield dir_name # fixture 的值在这里提供给测试函数
    # Teardown: 测试函数执行完毕后,这里的代码会执行
    print(f"\n(Fixture: Cleaning up temp directory: {dir_name})")
    shutil.rmtree(dir_name) # 清理临时目录

def test_create_file_in_managed_dir(managed_tmp_dir):
    print(f"\n(Test: Running test_create_file_in_managed_dir with {managed_tmp_dir})")
    file_path = os.path.join(managed_tmp_dir, "test_file.txt")
    with open(file_path, "w") as f:
        f.write("Hello from fixture test!")
    assert os.path.exists(file_path)

运行 pytest -v -s test_fixtures_basic.py ( -s 选项可以让你看到 print 语句的输出,方便观察 fixture 的执行流程)。

你会注意到:

  • sample_user_data fixture 在每个需要它的测试函数(test_user_usernametest_user_is_active)运行之前都会被调用一次。
  • managed_tmp_dir fixture 在 test_create_file_in_managed_dir 运行前创建了目录,测试结束后该目录被清理。yield 语句是实现这种 setup/teardown 模式的关键。在 yield 之前是 setup 代码,之后是 teardown 代码。

2. Fixture 作用域 (Scope)

Fixture 可以有不同的作用域,决定了 fixture 函数执行的频率以及其返回值的生命周期:

  • function (默认): 每个测试函数执行一次。这是最常见的,确保每个测试都有一个干净、独立的 fixture 实例。
  • class: 每个测试类执行一次。该类中所有测试方法共享同一个 fixture 实例。
  • module: 每个模块(测试文件)执行一次。该模块中所有测试函数/方法共享同一个 fixture 实例。
  • session: 整个测试会话(即一次 pytest 运行)执行一次。所有测试共享同一个 fixture 实例。这对于昂贵的 setup 操作(如启动一个外部服务)非常有用。

通过在 @pytest.fixture 装饰器中指定 scope 参数来设置作用域:

# test_fixture_scopes.py
import pytest

# Session-scoped fixture: 在整个测试会话中只执行一次
@pytest.fixture(scope="session")
def db_connection():
    print("\n(SESSION Fixture: Connecting to database...)")
    connection = "fake_db_connection_string" # 模拟数据库连接
    yield connection
    print("\n(SESSION Fixture: Closing database connection...)")

# Module-scoped fixture: 在这个模块中只执行一次
@pytest.fixture(scope="module")
def module_resource(db_connection): # Fixtures 可以依赖其他 fixtures
    print(f"\n(MODULE Fixture: Setting up module resource using {db_connection}...)")
    resource = {"id": "module_res_123", "db": db_connection}
    yield resource
    print("\n(MODULE Fixture: Tearing down module resource...)")

class TestUserOperations:
    # Class-scoped fixture: 对这个类只执行一次
    @pytest.fixture(scope="class")
    def user_service(self, module_resource): # 注意类方法中的 fixture 需要 self
        print(f"\n(CLASS Fixture: Initializing UserSerice with {module_resource['id']}...)")
        service = f"UserService_instance_for_{module_resource['id']}"
        yield service
        print("\n(CLASS Fixture: Shutting down UserService...)")

    # Function-scoped fixture (默认)
    @pytest.fixture
    def new_user_payload(self):
        print("\n(FUNCTION Fixture: Creating new_user_payload...)")
        return {"username": "temp_user", "role": "guest"}

    def test_get_user(self, user_service, db_connection): # 使用 class 和 session fixture
        print(f"\n(Test: test_get_user using {user_service} and {db_connection})")
        assert user_service is not None
        assert "fake_db" in db_connection

    def test_create_user(self, user_service, new_user_payload, module_resource): # 使用 class, function, module fixture
        print(f"\n(Test: test_create_user using {user_service}, payload: {new_user_payload}, module_res: {module_resource['id']})")
        assert new_user_payload["username"] == "temp_user"
        assert module_resource is not None

def test_another_module_level_test(module_resource, db_connection):
    print(f"\n(Test: test_another_module_level_test using {module_resource['id']} and {db_connection})")
    assert "module_res" in module_resource["id"]

运行 pytest -v -s test_fixture_scopes.py。仔细观察 print 语句的输出顺序和次数,你就能理解不同作用域的 fixture 是如何工作的。

选择合适的作用域很重要:

  • 如果 fixture 的创建和销毁成本很高,或者你希望在多个测试之间共享状态(要小心!),可以使用更广的作用域(class, module, session)。
  • 为了测试的独立性和避免副作用,function 作用域通常是首选。

3. 内置 Fixtures

pytest 提供了一些非常有用的内置 fixtures,例如:

  • tmp_path (function scope): 提供一个临时的目录路径 (pathlib.Path 对象),测试结束后会自动清理。
  • tmp_path_factory (session scope): 一个工厂 fixture,可以用来创建多个临时目录。
  • capsys, capfd: 用于捕获测试期间打印到 stdout/stderr 的内容。
  • monkeypatch: 用于安全地修改或替换模块、类或对象的属性,测试结束后自动恢复。
  • request: 一个特殊的 fixture,提供了关于当前正在执行的测试请求的信息。

你在之前的教程中已经用到了 tmp_path

# test_fixture.py (部分回顾)
@pytest.fixture
def tmp_file(tmp_path): # tmp_path 是内置 fixture
    file_path = tmp_path / "my_temp_file.txt"
    file_path.write_text("test content")
    return file_path

4. conftest.py: 共享 Fixtures

如果你的多个测试文件都需要使用相同的 fixtures,你可以将它们定义在一个名为 conftest.py 的文件中。pytest 会自动发现并加载 conftest.py 文件中的 fixtures,使其在同一目录及其子目录下的所有测试文件中可用,无需显式导入。

项目结构示例:

my_project/
├── conftest.py       # 共享的 fixtures 在这里定义
├── package_a/
│   └── test_module_a.py
└── package_b/
    └── test_module_b.py
```conftest.py` 中的内容:
```python
# my_project/conftest.py
import pytest

@pytest.fixture(scope="session")
def global_config():
    print("\n(CONFTEST: Loading global config...)")
    return {"api_url": "http://example.com/api", "timeout": 30}

test_module_a.py 中可以直接使用 global_config

# my_project/package_a/test_module_a.py
def test_api_url(global_config): # 无需导入,可以直接使用
    assert "example.com" in global_config["api_url"]
```conftest.py` 是组织和共享 fixtures 的标准方式,能让你的测试代码更整洁。

## 使用标记 (Markers) 管理测试

`pytest` 允许你使用“标记 (markers)”来给测试函数或类添加元数据。这些标记可以用于:
* 跳过某些测试。
* 在特定条件下跳过测试。
* 将测试标记为预期失败 (xfail)。
* 对测试进行分类,方便选择性地运行。

### 1. 内置标记

* **`@pytest.mark.skip(reason="...")`**: 无条件跳过该测试。
* **`@pytest.mark.skipif(condition, reason="...")`**: 当 `condition` 为真时跳过该测试。
* **`@pytest.mark.xfail(condition, reason="...", strict=False)`**: 标记测试为“预期失败”。如果测试实际通过了(而你标记为 xfail),默认情况下会报告为 `XPASS`。如果测试如预期般失败了,会报告为 `XFAIL`。如果设置 `strict=True`,那么 `XPASS` 会被视为测试失败。这对于标记那些已知有 bug 但暂时不修复的测试很有用。
* **`@pytest.mark.parametrize(...)`**: 我们已经学习过了,用于参数化测试。

```python
# test_markers.py
import pytest
import sys

def get_python_version():
    return sys.version_info

@pytest.mark.skip(reason="这个功能尚未实现")
def test_new_feature():
    assert False

IS_WINDOWS = sys.platform == "win32"

@pytest.mark.skipif(IS_WINDOWS, reason="此测试仅在非 Windows 系统上运行")
def test_linux_specific_path():
    path = "/usr/local/bin"
    assert path.startswith("/")

@pytest.mark.skipif(get_python_version() < (3, 8), reason="需要 Python 3.8 或更高版本")
def test_feature_for_python38_plus():
    # 一些只在 Python 3.8+ 中可用的特性
    assert True

@pytest.mark.xfail(reason="已知bug #123,除数为零")
def test_division_bug():
    assert 1 / 0 == 1 # 这会抛出 ZeroDivisionError

@pytest.mark.xfail(get_python_version() < (3, 10), reason="此功能在旧版Python中可能表现不同")
def test_potentially_flaky_on_old_python():
    # 假设这个测试在 Python < 3.10 时可能通过也可能失败
    if get_python_version() < (3, 10):
        assert 1 == 1 # 在旧版 Python 中,我们预期它可能失败 (xfail)
    else:
        assert 1 == 1 # 在新版 Python 中,我们预期它通过

2. 自定义标记与运行特定标记的测试

你可以定义自己的标记,以便对测试进行逻辑分组。在 pytest.inipyproject.toml 文件中注册自定义标记是个好习惯,以避免拼写错误和警告。

pytest.ini 示例:

[pytest]
markers =
    slow: 标记运行缓慢的测试
    smoke: 标记为冒烟测试,用于快速检查核心功能
    integration: 标记为集成测试

在测试中使用自定义标记:

# test_custom_markers.py
import pytest
import time

@pytest.mark.slow
def test_very_slow_operation():
    time.sleep(2) # 模拟一个耗时操作
    assert True

@pytest.mark.smoke
def test_quick_check():
    assert 1 + 1 == 2

@pytest.mark.integration
@pytest.mark.smoke # 一个测试可以有多个标记
def test_api_login():
    # 模拟 API 登录
    assert True

运行特定标记的测试:

使用 -m 命令行选项:

pytest -m smoke  # 只运行标记为 smoke 的测试
pytest -m "not slow" # 运行所有未标记为 slow 的测试
pytest -m "smoke and integration" # 运行同时标记为 smoke 和 integration 的测试
pytest -m "smoke or slow" # 运行标记为 smoke 或 slow 的测试

组织测试:测试类

虽然 pytest 不需要你把测试写在类里,但对于组织一组相关的测试,使用类是一个不错的选择。

  • 类名必须以 Test 开头。
  • 类中的测试方法名必须以 test_ 开头。
  • 不需要继承任何特定的基类 (如 unittest.TestCase)。
# test_calculator_class.py

class Calculator:
    def add(self, a, b):
        return a + b
    def subtract(self, a, b):
        return a - b
    def multiply(self, a, b):
        return a * b
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

class TestCalculator:
    # 你可以在类级别使用 fixture,它会对该类的所有测试方法生效
    # (如果 fixture scope 是 'class' 或更广)
    # 例如,可以在这里创建一个 Calculator 实例供所有测试使用

    def test_addition(self): # 注意方法需要 self 参数
        calc = Calculator()
        assert calc.add(2, 3) == 5
        assert calc.add(-1, 1) == 0

    def test_subtraction(self):
        calc = Calculator()
        assert calc.subtract(5, 3) == 2

    # ... 其他测试方法 ...

配置文件 (pytest.inipyproject.toml)

你可以通过在项目根目录创建 pytest.ini 文件或在 pyproject.toml 中添加 [tool.pytest.ini_options] 部分,来自定义 pytest 的行为。

pytest.ini 示例:

[pytest]
# 改变测试文件的发现模式
python_files = test_*.py check_*.py example_*.py

# 改变测试函数/方法的发现模式
python_functions = test_* check_* example_*

# 改变测试类的发现模式
python_classes = Test* Check* Example*

# 默认添加的命令行选项
addopts = -v --cov=. --cov-report=html

# 注册自定义标记 (避免警告)
markers =
    slow: marks tests as slow to run
    serial: marks tests that cannot be run in parallel

# 忽略某些目录
norecursedirs = .git venv build *.egg-info

这只是冰山一角,pytest 的配置选项非常丰富。

总结与后续学习

恭喜你!通过这个扩展教程,你已经掌握了 pytest 的许多核心概念和实用技巧:

  • 编写和运行基础测试。
  • 理解不同的输出模式 (-v, -q)。
  • 使用强大的 assert 语句进行断言和异常测试。
  • 通过 @pytest.mark.parametrize 实现参数化测试,提高测试覆盖率和代码复用。
  • 掌握了 Fixture 的核心用法,包括定义、使用、作用域 (function, class, module, session)、带 yield 的 setup/teardown 模式,以及如何通过 conftest.py 共享 fixtures。
  • 了解了如何使用标记 (@pytest.mark.*) 来管理和选择性地运行测试。
  • 知道了如何将测试组织在类中。
  • pytest 的配置文件有了初步认识。

接下来你可以探索:

  • 更高级的 Fixture 用法:autouse fixtures,fixture 的参数化,使用 fixture 返回工厂函数等。
  • 插件的使用:
    • pytest-cov: 测试覆盖率。
    • pytest-xdist: 并行测试。
    • pytest-mock: 方便地使用 mocking。
    • 针对你使用的框架(如 Django, Flask, FastAPI)的 pytest 插件。
  • 生成 HTML 测试报告: 使用 pytest-html 插件。
  • pytest 官方文档: 这是最权威和最全面的学习资源 (https://docs.pytest.org/)。

编写测试是保证代码质量、提升开发信心的关键环节。pytest 以其简洁和强大,让编写测试不再是一件苦差事,反而可以成为一种乐趣。希望这篇教程能帮助你轻松入门并爱上 pytest