pytest入门:简洁高效的测试框架

Python 测试与调试专题 · 更简洁、更强大的Python测试体验

专题:Python 测试与调试系统学习

关键词:Python, 测试, 调试, pytest, 测试框架, assert, fixture, 参数化, Python测试

一、pytest概述

pytest是Python生态中最受欢迎的测试框架之一,由Holger Krekel于2010年创建。其核心理念是"简洁胜于复杂",旨在让测试代码的编写变得直观、高效且富有表达力。与Python标准库中的unittest相比,pytest无需继承特定基类,无需使用繁琐的assertEqual、assertTrue等方法,只需使用Python原生的assert语句即可完成断言,大大降低了学习和编写测试的门槛。

pytest的核心优势包括:自动发现测试用例、丰富的插件生态、强大的fixture系统、灵活的标记机制、以及出色的参数化支持。这些特性使得pytest不仅适用于小型项目的单元测试,也能胜任大型项目的集成测试和端到端测试。目前,pytest已被大量开源项目和商业项目采用,如NumPy、SciPy、Matplotlib、Django REST Framework等。

安装pytest非常简单,推荐使用pip进行安装。在虚拟环境中执行以下命令即可完成安装,同时建议安装一些常用插件以扩展pytest的能力。以下是安装和基本验证的代码示例:

# 使用pip安装pytest pip install pytest # 验证安装成功 pytest --version # 安装推荐插件 pip install pytest-cov # 覆盖率插件 pip install pytest-xdist # 并行执行插件 pip install pytest-mock # mock支持插件

基本使用只需创建一个以test_开头的Python文件,编写以test_开头的函数,然后在终端中执行pytest命令即可。以下是一个最简单的pytest测试示例:

# test_demo.py def test_addition(): assert 1 + 1 == 2 def test_subtraction(): assert 3 - 1 == 2 assert 5 - 3 == 2 # 在终端执行: pytest test_demo.py # 输出会显示两个测试用例的运行结果

与unittest的对比尤为直观。同样的测试逻辑,unittest需要编写类继承、setUp/tearDown方法,而pytest只需一个函数即可。这种简洁性正是pytest广受欢迎的关键原因之一。下面是一个unittest与pytest的对比示例:

# unittest方式 import unittest class TestMath(unittest.TestCase): def setUp(self): self.data = [1, 2, 3] def test_sum(self): self.assertEqual(sum(self.data), 6) def test_max(self): self.assertEqual(max(self.data), 3) # pytest方式(同样功能,无需继承和断言方法) def test_sum(): data = [1, 2, 3] assert sum(data) == 6 def test_max(): data = [1, 2, 3] assert max(data) == 3

二、测试发现与运行

pytest最便捷的特性之一就是自动测试发现。按照约定好的命名规则放置测试文件,pytest能够自动递归扫描指定目录,找到所有符合条件的测试用例并执行。这一机制省去了手动注册测试用例的繁琐步骤,让开发者可以专注于测试逻辑本身。默认情况下,pytest会在当前目录及其子目录中查找所有test_*.py或*_test.py文件,并执行其中所有以test_开头的函数和以Test开头的类中的test_方法。

pytest提供了极其灵活的运行方式。除了运行整个测试目录,还可以精确指定要运行的文件、类、函数,甚至可以通过关键字表达式来筛选测试用例。这在大型项目中尤为重要——当测试用例数量达到数千个时,能够快速定位并运行特定子集的测试,可以大幅提升开发效率。以下展示了各种运行方式:

# 运行所有测试 pytest # 运行指定文件 pytest tests/test_user_api.py # 运行指定类中的测试 pytest tests/test_user_api.py::TestUserCreation # 运行指定测试函数 pytest tests/test_user_api.py::test_create_user # 使用关键字表达式筛选(运行名称包含"login"的测试) pytest -k "login" # 使用and/or/not组合关键字 pytest -k "login and not slow" # 显示详细输出(每个测试一行) pytest -v # 安静模式,只显示关键信息 pytest -q

pytest的测试发现规则可以总结为三条黄金法则。理解这些规则能帮助你更好地组织测试代码,避免测试被遗漏或误发现。规则清晰且直观:文件名必须以test_开头或以_test结尾,测试函数必须以test_开头,测试类必须以Test开头且不能包含__init__方法。下面是一个完整的测试文件示例,展示了这些命名规则:

# tests/test_order.py — 符合命名规则的测试文件 import pytest # 测试函数 — 以test_开头 def test_order_total(): assert calculate_total([10, 20]) == 30 def test_order_discount(): assert apply_discount(100, 10) == 90 # 测试类 — 以Test开头 class TestOrderStatus: def test_pending_status(self): order = Order(status='pending') assert order.is_pending() is True def test_shipped_status(self): order = Order(status='shipped') assert order.is_shipped() is True # 不会被执行 — 不以test_开头的函数 def helper_function(): pass # 不会被执行 — 不以Test开头的类中的test_方法 class OrderHelper: def test_not_found(self): pass

关键字表达式(-k选项)是日常开发中非常实用的功能。它支持Python表达式语法,可以按测试名称的模式匹配进行过滤,配合not、and、or等逻辑操作符能实现复杂的筛选逻辑。例如,在持续集成流水线中,可以排除耗时的集成测试,仅运行单元测试快速获取反馈。此外,pytest还支持通过标记(mark)进行过滤,这将在后续章节中详细介绍。

# 关键字表达式高级用法 # 只运行名称包含"api"但不包含"slow"的测试 pytest -k "api and not slow" # 运行名称包含"create"或"update"的测试 pytest -k "create or update" # 排除特定环境的测试 pytest -k "not windows_only" # 结合目录和关键字使用 pytest tests/integration/ -k "database" # 使用Python类语法筛选(仅TestUser类中的测试) pytest -k "TestUser"

三、断言机制

pytest的断言机制是其最具吸引力的特性之一。它完全基于Python原生的assert语句,无需记忆和使用大量的断言方法(如assertEqual、assertTrue、assertIn等)。这意味着开发者可以直接使用assert比较各种类型的数据结构,pytest会自动在断言失败时提供详细的上下文信息,包括参与比较的实际值和期望值,以及它们之间的差异。

当assert断言失败时,pytest会通过智能的断言重写(assert rewriting)机制,将原始的assert语句重写为包含丰富细节的报告。这不仅显示了断言失败的位置,还精确标明了哪个子表达式导致了失败。对于复杂的数据结构,pytest还会计算并显示差异(diff),帮助你快速定位问题所在。这一特性大幅降低了调试测试失败的成本。

# pytest的智能断言重写示例 def test_string_assertion(): name = "Hello, World" # 断言失败时,pytest会显示具体差异 assert name.startswith("Hi"), f"期望以'Hi'开头,实际为'{name[:10]}...'" def test_list_comparison(): expected = [1, 2, 3, 4, 5] actual = [1, 2, 3, 5, 4] # 失败时pytest会显示两个列表的差异 assert actual == expected def test_dict_comparison(): expected = {"name": "Alice", "age": 30, "email": "alice@test.com"} actual = {"name": "Alice", "age": 30, "email": "bob@test.com"} # 失败时pytest会高亮显示不同的键值对 assert actual == expected def test_nested_structure(): data = {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} expected = {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Charlie"}]} # 对于嵌套结构,pytest会逐层显示差异 assert data == expected

异常断言是测试中非常重要的场景。pytest提供了pytest.raises上下文管理器,用于验证代码是否抛出了预期的异常。相比unittest中的assertRaises,pytest.raises更灵活,可以精确检查异常的消息内容、异常属性的值等。此外,pytest.warns用于断言警告信息,确保代码在预期的情况下正确触发警告。

# 异常断言: pytest.raises import pytest def test_zero_division(): with pytest.raises(ZeroDivisionError): 1 / 0 def test_exception_message(): with pytest.raises(ValueError) as exc_info: int("not_a_number") assert "invalid literal" in str(exc_info.value) def test_custom_exception(): class CustomError(Exception): def __init__(self, code, message): self.code = code self.message = message with pytest.raises(CustomError) as exc_info: raise CustomError(404, "Not Found") assert exc_info.value.code == 404 assert "Not Found" in exc_info.value.message # 精确匹配异常类型(子类不会匹配) def test_exact_exception_type(): with pytest.raises(LookupError, match="index"): # LookupError是IndexError的父类 [1, 2, 3][10]
# 警告断言: pytest.warns import warnings import pytest def test_deprecation_warning(): with pytest.warns(DeprecationWarning): warnings.warn("此功能已废弃", DeprecationWarning) def test_warning_with_message(): with pytest.warns(UserWarning, match="即将废弃"): warnings.warn("此功能即将废弃", UserWarning) def test_no_warning(): # 验证代码块没有产生警告 with pytest.warns(None): result = 1 + 1 assert result == 2 # 记录所有警告并检查 def test_record_warnings(): with pytest.warns(Warning) as record: warnings.warn("警告一", UserWarning) warnings.warn("警告二", DeprecationWarning) assert len(record) == 2 assert record[0].category == UserWarning

四、fixture入门

fixture是pytest最强大也最独特的特性之一。fixture是一个装饰器标记的函数,用于提供测试所需的固定基境(fixture),例如数据库连接、临时文件、HTTP客户端等。与unittest中的setUp/tearDown模式不同,pytest的fixture采用依赖注入的方式工作,测试函数通过参数声明其需要的fixture,pytest会自动创建并注入。这种机制使得fixture的作用域、复用和组合变得异常灵活。

fixture通过scope参数控制生命周期。scope有五个级别:function(默认,每个测试函数执行前后创建和销毁)、class(每个测试类共享)、module(每个模块共享)、package(每个包共享)和session(整个测试会话共享)。合理设置scope可以显著提升测试效率,例如数据库连接这种重量级资源应该使用session或module scope,而临时文件等轻量资源使用function scope即可。

# fixture的基本定义和使用 import pytest # 定义一个简单的fixture @pytest.fixture def sample_data(): """提供测试用的示例数据""" return {"name": "Alice", "age": 30, "scores": [85, 92, 78]} # 在测试函数中使用fixture def test_average_score(sample_data): scores = sample_data["scores"] avg = sum(scores) / len(scores) assert avg == pytest.approx(85.0, rel=0.1) def test_user_name(sample_data): assert sample_data["name"] == "Alice" # 多个fixture组合使用 @pytest.fixture def db_connection(): """模拟数据库连接""" return {"host": "localhost", "port": 5432, "connected": True} def test_user_in_database(sample_data, db_connection): assert db_connection["connected"] is True assert sample_data["name"] in ["Alice", "Bob", "Charlie"]

yield fixture是pytest实现setup/teardown模式的优雅方式。在yield之前的代码相当于setUp,在yield之后的代码相当于tearDown。无论测试通过还是失败,yield之后的代码都会被执行,这保证了资源的正确释放。autouse参数允许fixture自动应用于所有测试,无需显式声明依赖,非常适合全局性的前置或后置操作。

# yield fixture实现setup/teardown import pytest import tempfile import os @pytest.fixture def temp_file(): """创建临时文件,测试结束后自动清理""" # setup部分 tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) tmp.write("Hello, pytest!") tmp.close() file_path = tmp.name print(f"\n[setup] 创建临时文件: {file_path}") # 提供fixture值给测试 yield file_path # teardown部分(无论测试是否通过都执行) if os.path.exists(file_path): os.unlink(file_path) print(f"\n[teardown] 已删除临时文件: {file_path}") def test_read_temp_file(temp_file): with open(temp_file, 'r') as f: content = f.read() assert content == "Hello, pytest!" # autouse fixture — 自动应用于所有测试 @pytest.fixture(autouse=True) def print_test_boundary(): """每个测试执行前后打印边界线""" print("\n--- 测试开始 ---") yield print("\n--- 测试结束 ---") def test_example_one(): assert 1 + 1 == 2 def test_example_two(): assert 2 * 3 == 6

fixture的作用域设置对于测试性能优化至关重要。下面是一个作用域设置的完整示例,展示了如何在不同层级共享资源。需要注意的是,高作用域的fixture必须确保线程安全(如果使用了并行执行插件),避免测试之间的状态污染。

# fixture作用域示例 import pytest # session级别 — 整个测试会话只创建一次 @pytest.fixture(scope="session") def db_config(): """数据库配置(重量级资源,只初始化一次)""" print("\n[session] 初始化数据库配置") return {"host": "prod-db.example.com", "pool_size": 10} # module级别 — 每个模块共享 @pytest.fixture(scope="module") def module_data(): """模块级别的测试数据""" print("\n[module] 加载模块测试数据") return list(range(100)) # function级别(默认) — 每个测试函数独立 @pytest.fixture def fresh_user(): """每个测试获得一个全新用户对象""" return {"id": None, "name": "New User", "created_at": "2026-01-01"} def test_user_creation(db_config, fresh_user): assert fresh_user["id"] is None def test_module_data_length(module_data): assert len(module_data) == 100

五、参数化测试

参数化测试是pytest的又一杀手级特性。当需要对同一段测试逻辑使用多组输入数据进行验证时,参数化可以避免编写大量重复的测试函数。pytest通过@pytest.mark.parametrize装饰器实现参数化,支持单参数、多参数组合,甚至可以从外部数据源(如JSON、YAML、CSV文件)动态加载测试数据。每一组参数在pytest看来都是一个独立的测试用例,运行结果会被单独报告。

参数化测试的最佳实践是将测试数据与测试逻辑分离。对于边界值分析、等价类划分等测试设计方法,参数化可以非常自然地表达。例如测试一个判断闰年的函数,需要覆盖普通闰年、世纪闰年、普通年、世纪年等场景,通过参数化可以一一列举,清晰且易于维护。以下是一个基本的参数化测试示例:

# 基本的parametrize使用 import pytest # 单参数参数化 @pytest.mark.parametrize("input_val", [1, 2, 3, 4, 5]) def test_positive_numbers(input_val): assert input_val > 0 # 多参数参数化 @pytest.mark.parametrize("a, b, expected", [ (1, 1, 2), (2, 3, 5), (100, 200, 300), (-1, 1, 0), (0, 0, 0), ]) def test_addition(a, b, expected): assert a + b == expected # 参数化单个fixture @pytest.mark.parametrize("count, expected", [(0, 0), (1, 1), (10, 55), (20, 6765)]) def test_fibonacci(count, expected): def fib(n): if n <= 1: return n return fib(n-1) + fib(n-2) assert fib(count) == expected

使用id参数可以为每组测试数据设置可读性更强的测试名称,这在大量参数化测试中尤其有用。当某个测试失败时,清晰的id能让你一眼看出是哪个场景出了问题。此外,pytest支持多个parametrize装饰器的嵌套组合,用于生成多组参数的笛卡尔积,覆盖所有可能的组合场景。

# 参数化测试的进阶用法 import pytest # 使用id自定义测试名称 @pytest.mark.parametrize("username, age, expected", [ ("Alice", 20, True), ("Bob", 17, False), ("Charlie", 18, True), ], ids=["adult_alice", "minor_bob", "adult_charlie"]) def test_is_adult(username, age, expected): result = age >= 18 assert result == expected # 多个parametrize嵌套(笛卡尔积) @pytest.mark.parametrize("x", [1, 2]) @pytest.mark.parametrize("y", [10, 20, 30]) def test_cartesian_product(x, y): """生成 2 x 3 = 6 个测试用例""" result = x + y assert result > 0 # 从列表参数化 test_data = [ pytest.param("admin", "123456", True, id="valid_admin"), pytest.param("user", "wrong", False, id="wrong_password"), pytest.param("", "password", False, id="empty_username"), ] @pytest.mark.parametrize("username, password, expected", test_data) def test_login(username, password, expected): def login(u, p): return u == "admin" and p == "123456" assert login(username, password) == expected

从外部数据源加载测试数据是参数化测试的高级用法,适合数据量较大或需要与业务数据保持同步的场景。以下示例展示了如何从JSON文件和CSV文件中读取测试数据,实现数据驱动的测试架构。

# 从外部数据源加载测试数据 import pytest import json import csv def load_test_data_from_json(file_path): """从JSON文件加载测试数据""" with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) return [(item["input"], item["expected"]) for item in data] def load_test_data_from_csv(file_path): """从CSV文件加载测试数据""" data = [] with open(file_path, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: data.append((row["input"], row["expected"])) return data # 使用外部数据参数化(假设有test_data.json文件) # @pytest.mark.parametrize("input_val, expected", load_test_data_from_json("test_data.json")) # def test_from_json(input_val, expected): # assert double(input_val) == expected # 使用动态生成的测试数据 @pytest.mark.parametrize("n, expected", [ pytest.param(0, 1, id="zero"), pytest.param(1, 1, id="one"), pytest.param(5, 120, id="five"), pytest.param(10, 3628800, id="ten"), ]) def test_factorial(n, expected): import math assert math.factorial(n) == expected

六、mark标记

mark标记是pytest提供的元数据标注系统,允许开发者给测试用例附加各种标签和信息。通过标记,可以实现条件跳过、预期失败、分组运行等高级功能。pytest内置了多种实用的标记,如skip(无条件跳过)、skipif(条件跳过)、xfail(预期失败),同时支持用户自定义标记来实现灵活的测试组织和管理。

自定义标记必须先在配置文件中注册,否则pytest会发出警告。注册后,标记可以像内置标记一样用来过滤测试用例。在大型项目中,常用标记包括smoke(冒烟测试)、slow(慢速测试)、integration(集成测试)、unit(单元测试)等。通过在运行命令中组合标记筛选,可以快速针对不同场景运行不同子集的测试。

# 内置标记使用 import pytest import sys # skip — 无条件跳过测试 @pytest.mark.skip(reason="此测试暂时不需要运行") def test_skip_example(): assert False # skipif — 条件跳过 @pytest.mark.skipif(sys.version_info < (3, 9), reason="需要Python 3.9+") def test_python_version_feature(): # 使用Python 3.9+特有的语法 data = {"a": 1, "b": 2} | {"c": 3} assert data == {"a": 1, "b": 2, "c": 3} # skipif — 平台相关跳过 @pytest.mark.skipif(sys.platform == "win32", reason="不在Windows上运行") def test_unix_only_feature(): import os assert os.name == "posix" # xfail — 预期失败(不会计为失败,而是xfail) @pytest.mark.xfail(reason="已知bug #123,待修复") def test_known_bug(): result = buggy_function() assert result == "expected" # xfail(strict=True) — 如果意外通过了,反而报错 @pytest.mark.xfail(strict=True, reason="预期失败,通过则说明bug已修复") def test_xfail_strict(): assert False

自定义标记需要在pytest.ini或pyproject.toml中注册。注册后,可以通过-m选项按标记筛选运行测试。多个标记可以使用and、or、not逻辑组合,实现精确的测试选择。以下展示了标记注册和过滤的完整流程:

# 自定义标记使用 import pytest @pytest.mark.smoke def test_login_basic(): assert True @pytest.mark.smoke def test_logout_basic(): assert True @pytest.mark.slow def test_large_file_processing(): import time time.sleep(5) # 模拟耗时操作 assert True @pytest.mark.integration @pytest.mark.database def test_database_connection(): assert True @pytest.mark.slow @pytest.mark.integration def test_full_import_workflow(): assert True # 终端执行示例: # 运行冒烟测试: pytest -m smoke # 运行非慢速测试: pytest -m "not slow" # 运行集成或数据库测试: pytest -m "integration or database" # 运行冒烟加快速测试: pytest -m "smoke and not slow"
# 标记注册配置文件示例 # pytest.ini 注册方式 [pytest] markers = smoke: 冒烟测试,核心功能验证 slow: 运行时间较长的测试 integration: 集成测试 database: 需要数据库连接的测试 webtest: Web端到端测试 performance: 性能测试 # pyproject.toml 注册方式(推荐新项目使用) [tool.pytest.ini_options] markers = [ "smoke: 冒烟测试,核心功能验证", "slow: 运行时间较长的测试", "integration: 集成测试", "database: 需要数据库连接的测试", ] # 查看所有已注册的标记 # 终端执行: pytest --markers

七、conftest.py

conftest.py是pytest中实现测试配置共享的核心机制。它是一个特殊的Python文件,pytest会自动加载测试目录树中各个层级下的conftest.py文件。conftest.py中可以定义共享的fixture、钩子函数(hook functions)、命令行选项以及插件配置。它的作用域规则是:每个conftest.py对其所在目录及其所有子目录中的测试文件可见,这一层级关系为测试配置提供了精细的粒度控制。

conftest.py最典型的用途是共享fixture。将通用的fixture(如数据库连接、HTTP客户端、认证Token)定义在顶层conftest.py中,不同模块的测试文件无需重复定义即可直接使用。这遵循了DRY(Don't Repeat Yourself)原则,同时也让测试文件更专注于测试逻辑本身。值得注意的是,conftest.py的嵌套使用可以实现配置覆盖——子目录中的conftest.py可以定义与父级同名的fixture,从而覆盖父级配置。

# 项目根目录 conftest.py — 全局fixture # 路径: tests/conftest.py import pytest import json @pytest.fixture(scope="session") def app_config(): """加载应用全局配置""" return { "debug": False, "secret_key": "test-secret", "database_url": "sqlite:///:memory:", "api_version": "v2" } @pytest.fixture def api_client(app_config): """创建API测试客户端""" class TestClient: def __init__(self, config): self.base_url = f"https://api.example.com/{config['api_version']}" self.headers = {"Authorization": f"Bearer {config['secret_key']}"} def get(self, path): # 模拟GET请求 return {"status": 200, "data": None} def post(self, path, data): # 模拟POST请求 return {"status": 201, "data": data} return TestClient(app_config) @pytest.fixture def auth_token(app_config): """生成测试用的认证令牌""" return f"token_{app_config['secret_key']}"
# 子目录 conftest.py — 模块级配置覆盖 # 路径: tests/api/conftest.py import pytest @pytest.fixture(scope="module") def api_base_path(): """API模块的基础路径配置""" return "/api/v2" # 覆盖父级conftest中的auth_token(仅在该目录生效) @pytest.fixture def auth_token(): """为API测试生成特定的认证令牌""" return "api_test_specific_token_2026" @pytest.fixture(autouse=True) def setup_api_test(): """API测试模块的前置操作""" print("\n[API测试] 准备API测试环境") yield print("\n[API测试] 清理API测试环境") # conftest中的钩子函数 def pytest_configure(config): """pytest配置阶段执行""" config.addinivalue_line("markers", "api: API相关的测试标记") def pytest_collection_modifyitems(config, items): """收集测试用例后的处理""" for item in items: if "api" in item.nodeid: item.add_marker(pytest.mark.api)

conftest.py的钩子函数(hook)是其另一个强大的功能。pytest在测试运行的不同阶段提供了丰富的钩子接口,允许开发者介入测试生命周期。常见的钩子包括pytest_configure(配置阶段)、pytest_collection_modifyitems(用例收集后)、pytest_runtest_setup(每个测试执行前)等。这些钩子是实现测试框架扩展和定制的基石。

# conftest.py目录层级管理示例 # 项目结构: # tests/ # conftest.py — 全局配置(数据库、客户端等) # unit/ # conftest.py — 单元测试专用配置 # test_models.py # test_services.py # integration/ # conftest.py — 集成测试专用配置(覆盖部分fixture) # test_api.py # test_database.py # e2e/ # conftest.py — E2E测试专用配置 # test_workflow.py # 全局conftest提供: app_config, db_session # unit/conftest提供: mock_db (模拟数据库) # integration/conftest提供: real_db_session (真实数据库) # e2e/conftest提供: browser (浏览器驱动) # 测试文件无需关心fixture来自哪个conftest # 只需声明参数即可: def test_user_service(db_session): """db_session是来自conftest的fixture""" assert db_session is not None

八、命令行与配置

pytest提供了丰富的命令行选项,让测试执行的控制粒度非常精细。从基本的-v(详细输出)到高级的--durations(性能分析)、--last-failed(仅运行上次失败的测试)、-x(首次失败即停止),这些选项能满足从日常开发到持续集成的各种需求。熟练掌握这些命令行选项,可以显著提升测试驱动开发(TDD)的效率。

pytest的配置文件系统支持多种格式,包括传统的pytest.ini、现代的pyproject.toml、以及setup.cfg。推荐新项目使用pyproject.toml,它已成为Python项目的标准配置文件格式。配置文件中可以设置默认命令行参数、注册标记、配置路径、设置超时时间等。合理的配置文件可以让团队成员统一测试行为,避免因个人环境差异导致的测试不一致。

# 常用命令行选项大全 # 输出控制 pytest -v # 详细模式,每个测试一行 pytest -q # 安静模式,精简输出 pytest -s # 显示print输出(默认捕获输出) pytest --tb=short # 缩短的traceback pytest --tb=long # 最详细的traceback pytest --tb=no # 不显示traceback # 运行控制 pytest -x # 第一次失败即停止 pytest --maxfail=5 # 失败5次后停止 pytest -k "login" # 按关键字过滤 pytest -m "smoke" # 按标记过滤 pytest -lf # 只运行上次失败的测试(--last-failed) pytest -ff # 先运行上次失败的,再运行其他的(--failed-first) # 调试与输出 pytest --pdb # 测试失败时进入pdb调试器 pytest --setup-show # 显示fixture的创建和销毁过程 pytest --fixtures # 列出所有可用的fixture pytest --co # 生成彩色输出(需要pytest-rich) pytest -p no:cacheprovider # 禁用缓存 # 性能分析 pytest --durations=10 # 显示最慢的10个测试 pytest --durations-min=0.1 # 只显示耗时超过0.1秒的测试 pytest --collect-only # 只收集测试但不运行
# pytest.ini 配置 [pytest] minversion = 7.0 testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* # 默认命令行参数 addopts = -v --tb=short -p no:warnings # 标记注册 markers = smoke: 冒烟测试 slow: 慢速测试 integration: 集成测试 database: 数据库相关测试 # 文件路径忽略 norecursedirs = .git venv env .tox __pycache__ # 超时设置(需要pytest-timeout插件) timeout = 60 timeout_method = thread
# pyproject.toml 配置(推荐) [tool.pytest.ini_options] minversion = "7.0" testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = [ "-v", "--tb=short", "-p no:warnings", ] markers = [ "smoke: 冒烟测试,核心功能验证", "slow: 运行时间较长的测试", "integration: 集成测试", "database: 需要数据库连接的测试", ] norecursedirs = [ ".git", "venv", "env", ".tox", "__pycache__", "node_modules", ] # 缓存与上次失败配置 filterwarnings = [ "error", "ignore::DeprecationWarning:some_deprecated_module.*", ] # 日志配置 log_cli = true log_cli_level = "INFO" log_cli_format = "%(asctime)s [%(levelname)s] %(message)s" log_cli_date_format = "%Y-%m-%d %H:%M:%S"

九、实战案例

理论知识最终需要通过实战来巩固。本节提供三个完整的实战案例:从unittest迁移到pytest的改造方案、冒烟测试集的搭建方法、以及数据驱动测试的实现模式。这些案例涵盖了实际项目中从传统测试框架迁移到pytest的核心场景,帮助你将前面章节的知识串联起来。

第一个案例展示了从unittest迁移到pytest的完整过程。许多历史项目使用unittest编写了数千个测试用例,逐步迁移到pytest可以享受更简洁的语法和更强大的特性。迁移策略遵循"最小改动原则":利用pytest对unittest.TestCase的兼容性,逐步替换断言方法、引入fixture、使用参数化。下面是一个具体的迁移示例:

# 案例1:从unittest迁移到pytest # 原有的unittest方式 import unittest from calculator import Calculator class TestCalculator(unittest.TestCase): def setUp(self): self.calc = Calculator() def test_add(self): self.assertEqual(self.calc.add(2, 3), 5) def test_subtract(self): self.assertEqual(self.calc.subtract(10, 4), 6) def test_multiply(self): self.assertEqual(self.calc.multiply(3, 4), 12) def test_divide(self): self.assertEqual(self.calc.divide(10, 2), 5) with self.assertRaises(ValueError): self.calc.divide(10, 0) # 迁移后的pytest方式 import pytest from calculator import Calculator @pytest.fixture def calc(): return Calculator() def test_add(calc): assert calc.add(2, 3) == 5 def test_subtract(calc): assert calc.subtract(10, 4) == 6 @pytest.mark.parametrize("a, b, expected", [ (3, 4, 12), (0, 5, 0), (-1, 5, -5) ]) def test_multiply(calc, a, b, expected): assert calc.multiply(a, b) == expected def test_divide(calc): assert calc.divide(10, 2) == 5 with pytest.raises(ValueError): calc.divide(10, 0) # 注意: pytest完全兼容unittest.TestCase # 可以混合使用两种风格,逐步迁移

第二个案例是冒烟测试集的搭建。冒烟测试(Smoke Test)是每次代码提交后最先运行的测试,用于快速验证核心功能是否正常。在pytest中,通过smoke标记和conftest.py的配合,可以轻松构建一个高效的冒烟测试体系。合理设计冒烟测试既能快速反馈,又不会消耗太多CI资源。

# 案例2:构建冒烟测试集 import pytest import requests # 冒烟测试标记 — 在pytest.ini中注册 # [pytest] # markers = smoke: 冒烟测试,核心功能验证 # 核心API冒烟测试 @pytest.mark.smoke class TestCoreAPI: """核心API冒烟测试""" @pytest.fixture(scope="class") def base_url(self): return "https://api.example.com/v2" def test_health_check(self, base_url): """健康检查端点必须响应200""" resp = requests.get(f"{base_url}/health", timeout=5) assert resp.status_code == 200 assert resp.json()["status"] == "healthy" def test_auth_endpoint(self, base_url): """认证端点必须可用""" resp = requests.post(f"{base_url}/auth/login", json={"username": "test", "password": "test"}, timeout=5) assert resp.status_code in (200, 401) # 可用即可 def test_version_endpoint(self, base_url): """版本信息端点""" resp = requests.get(f"{base_url}/version", timeout=5) assert resp.status_code == 200 assert "version" in resp.json() # 数据库冒烟测试 @pytest.mark.smoke @pytest.mark.database def test_database_connection(db_session): """数据库连接可用""" result = db_session.execute("SELECT 1") assert result.scalar() == 1 # 运行命令: pytest -m smoke -v # 这会运行所有标记为smoke的测试,快速验证核心功能

第三个案例是数据驱动测试的实现。在真实项目中,测试数据往往来自多个来源(数据库、API响应、配置文件等),数据驱动测试架构可以优雅地组织这些场景。通过pytest的参数化机制结合conftest.py中的fixture,可以构建灵活且可维护的数据驱动测试框架。这个模式特别适合测试数据处理管道、API网关、推荐引擎等数据密集型系统。

# 案例3:数据驱动测试实践 import pytest import json from pathlib import Path # conftest.py 中定义数据加载fixture @pytest.fixture(scope="session") def test_data_dir(): """测试数据的目录路径""" return Path(__file__).parent / "test_data" @pytest.fixture def load_json(test_data_dir): """从JSON文件加载数据的工厂函数""" def _load(filename): filepath = test_data_dir / filename with open(filepath, 'r', encoding='utf-8') as f: return json.load(f) return _load # 使用参数化+外部数据的测试 @pytest.mark.parametrize("scenario", [ "normal_order", "discount_order", "international_order", "order_with_coupon", ]) def test_order_processing(scenario, load_json): """数据驱动的订单处理测试""" # 加载测试数据 order_data = load_json(f"orders/{scenario}.json") # 执行订单处理 processor = OrderProcessor() result = processor.process(order_data) # 验证结果 assert result["success"] is True assert result["order_id"] is not None # 加载预期结果并验证 expected = load_json(f"orders/{scenario}_expected.json") assert result["status"] == expected["status"] # 动态生成的测试场景 def generate_test_scenarios(): """从目录动态发现测试场景""" data_dir = Path("tests/test_data/orders") for f in data_dir.glob("*.json"): if not f.name.endswith("_expected.json"): yield pytest.param( f.stem, marks=getattr(pytest.mark, f.stem, None) ) # 运行所有数据驱动的测试,输出清晰的场景名称 # pytest test_data_driven.py -v --collect-only