4、Pytest进阶技巧:Fixture详解与作用域、参数化测试、conftest.py共享机制、标记与跳过测试

4.1 Fixture:不只是“前置条件”那么简单

说实话,很多同学刚开始用Pytest时,都把Fixture当成“setup”的替代品。嗯,这么理解也没错,但Fixtrue远不止于此。

我个人习惯把Fixture看作一个“依赖注入器”。什么意思呢?就是你的测试函数需要什么资源,直接声明参数就行,Pytest会自动帮你准备好。我在项目中遇到过最典型的场景——测试数据库连接。每个测试用例都要连数据库,总不能每个用例里都写一遍连接代码吧?

import pytest

@pytest.fixture
def db_connection():
    # 创建数据库连接
    conn = create_database_connection()
    yield conn  # 注意这里用yield,不是return
    # 测试结束后自动清理
    conn.close()

def test_user_query(db_connection):
    result = db_connection.query("SELECT * FROM users")
    assert len(result) > 0

你想想看,yield前后的代码分别对应“前置准备”和“后置清理”。这比传统的setup/teardown优雅太多了。我曾经在维护一个老项目时,看到满屏的setup_method和teardown_method,那叫一个头疼。

4.2 Fixture作用域:别让资源白白浪费

Fixture的作用域是个好东西,但很多人用错了。默认是function级别,也就是每个测试函数都重新执行一次。这没问题,但有些场景下太浪费了。

作用域 说明 适用场景
function 每个测试函数执行一次 默认,通用场景
class 每个测试类执行一次 类内测试共享资源
module 每个模块执行一次 模块级配置加载
session 整个测试会话只执行一次 全局配置、数据库连接池

举个例子,如果你要测试一个大型配置文件的加载,用session作用域就非常合适。我有个项目,配置文件有200多行,每次测试都重新加载,跑一次全量测试要多花3分钟。改成session作用域后,瞬间就快了。

@pytest.fixture(scope="session")
def global_config():
    # 整个测试过程只加载一次
    config = load_heavy_config_file()
    return config

def test_config_item1(global_config):
    assert global_config["timeout"] == 30

def test_config_item2(global_config):
    assert global_config["retry_count"] == 3
注意:session作用域的Fixture要小心使用。如果测试之间互相影响,比如修改了共享数据,那排查问题会非常痛苦。我曾经因为一个session级别的列表被意外修改,排查了整整一个下午。

4.3 参数化测试:一个测试函数跑N种情况

说白了,参数化测试就是让你用一套测试逻辑,验证多组输入输出。我见过最蠢的写法——把同样的测试逻辑复制粘贴十几遍,就为了换几个参数值。

import pytest

# 单参数参数化
@pytest.mark.parametrize("input_value", [1, 2, 3, 4, 5])
def test_is_positive(input_value):
    assert input_value > 0

# 多参数参数化
@pytest.mark.parametrize("username,password,expected", [
    ("admin", "123456", True),
    ("user", "wrong", False),
    ("", "any", False),
    ("admin", "", False),
])
def test_login(username, password, expected):
    result = login_service(username, password)
    assert result == expected

这里有个小技巧——参数化可以和Fixture结合使用。我在项目中遇到过需要测试不同数据库类型的场景,就是靠这个组合搞定的。

@pytest.fixture
def db_connection(request):
    db_type = request.param
    if db_type == "mysql":
        return MySQLConnection()
    elif db_type == "postgres":
        return PostgresConnection()

@pytest.mark.parametrize("db_connection", ["mysql", "postgres"], indirect=True)
def test_query(db_connection):
    result = db_connection.query("SELECT 1")
    assert result is not None
个人经验:参数化测试的用例数量不要太多。超过20组参数时,建议用外部数据文件(如JSON、YAML)来管理。我曾经把100多组测试数据直接写在装饰器里,那代码简直没法看。

4.4 conftest.py:共享Fixture的“秘密武器”

conftest.py是Pytest里一个非常巧妙的设计。你把它放在某个目录下,该目录及其子目录下的所有测试文件都能自动使用它定义的Fixture。不需要import,不需要继承,就是这么神奇。

我习惯把项目级别的共享Fixture放在根目录的conftest.py里。比如测试环境的URL、全局的Mock对象、日志配置等。

# 项目根目录/conftest.py
import pytest

@pytest.fixture(scope="session")
def base_url():
    return "https://api.example.com"

@pytest.fixture
def logger():
    import logging
    logger = logging.getLogger("test")
    logger.setLevel(logging.DEBUG)
    return logger

# tests/test_api.py - 直接使用,无需导入
def test_get_user(base_url, logger):
    logger.info(f"Testing URL: {base_url}")
    response = requests.get(f"{base_url}/users/1")
    assert response.status_code == 200

你想想看,如果每个测试文件都要自己import一遍这些公共资源,那得多麻烦。而且conftest.py支持层级覆盖——子目录的conftest.py可以覆盖父目录的同名Fixture。这个特性在大型项目中非常实用。

避坑指南:conftest.py的命名必须完全一致,大小写都不能错。我曾经因为把文件名写成"conftest.py"(少了个e),结果Fixture死活不生效,排查了半小时才发现是文件名写错了。

4.5 标记与跳过测试:精准控制测试执行

测试不是越多越好,有时候你需要跳过某些测试。比如某个功能还没开发完,或者只在特定环境下才运行。Pytest的标记系统就是干这个的。

import pytest

# 跳过测试 - 无条件跳过
@pytest.mark.skip(reason="该功能尚未实现")
def test_new_feature():
    pass

# 条件跳过 - 只在特定条件下跳过
@pytest.mark.skipif(sys.version_info < (3, 8), reason="需要Python 3.8+")
def test_python_version():
    pass

# 预期失败 - 知道会失败,但不想让它影响测试结果
@pytest.mark.xfail(reason="已知的bug,下个版本修复")
def test_known_bug():
    assert 1 == 2

自定义标记也是个好东西。我习惯用标记来区分测试的优先级或类型。

# 先注册自定义标记(在pytest.ini或pyproject.toml中)
# [pytest]
# markers =
#     smoke: 冒烟测试
#     regression: 回归测试
#     slow: 慢速测试

@pytest.mark.smoke
def test_login_smoke():
    """冒烟测试 - 核心功能"""
    assert login("admin", "123456") == True

@pytest.mark.slow
def test_large_data_processing():
    """慢速测试 - 大数据处理"""
    process_large_dataset()
    assert True

运行的时候可以灵活筛选:

# 只跑冒烟测试
pytest -m smoke

# 跳过慢速测试
pytest -m "not slow"

# 同时跑冒烟和回归测试
pytest -m "smoke or regression"
我的习惯:每次提交代码前,先跑一遍冒烟测试(-m smoke),确保核心功能没问题。全量测试留给CI/CD去做。这样既保证了效率,又不会漏掉关键验证。

好了,这一章的内容就到这里。Fixture、参数化、conftest.py、标记跳过,这四个技巧掌握好了,你的Pytest功力至少提升一个档次。下一章我们聊聊Mock和猴子补丁,那才是真正的“黑科技”。