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
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
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。这个特性在大型项目中非常实用。
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"
好了,这一章的内容就到这里。Fixture、参数化、conftest.py、标记跳过,这四个技巧掌握好了,你的Pytest功力至少提升一个档次。下一章我们聊聊Mock和猴子补丁,那才是真正的“黑科技”。