在Python自动化测试领域,pytest已经成为事实上的标准测试框架。作为一名长期使用pytest进行自动化测试开发的工程师,我想分享一些实战经验和深度用法,帮助大家避开我踩过的那些坑。
pytest相比Python自带的unittest框架有几个显著优势:
实际项目中,pytest可以将测试代码量减少30%-50%,同时提高测试的可读性和可维护性。
安装pytest非常简单:
bash复制pip install pytest
验证安装是否成功:
bash复制pytest --version
建议同时安装常用插件:
bash复制pip install pytest-xdist pytest-html pytest-cov
良好的项目结构是高效测试的基础。推荐的结构如下:
code复制project/
├── src/ # 源代码
├── tests/ # 测试代码
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ └── conftest.py # 共享fixture
├── requirements.txt # 依赖文件
└── pytest.ini # 配置文件
数据驱动测试(DDT)是自动化测试的核心技术,pytest通过@pytest.mark.parametrize提供了优雅的实现。
python复制import pytest
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(5, 5, 10),
(10, -5, 5),
])
def test_addition(a, b, expected):
assert a + b == expected
实际项目中,测试数据往往存储在外部文件中。以下是几种常见方案:
python复制import json
import pytest
def load_test_data(json_file):
with open(json_file) as f:
return json.load(f)
@pytest.mark.parametrize("data", load_test_data("test_data.json"))
def test_with_json(data):
assert data["input"] + data["delta"] == data["expected"]
python复制import pandas as pd
import pytest
def excel_to_test_data(file_path):
df = pd.read_excel(file_path)
return [tuple(row) for row in df.values]
@pytest.mark.parametrize("username,password,expected",
excel_to_test_data("login_cases.xlsx"))
def test_login(username, password, expected):
# 测试逻辑
pass
有时我们需要在运行时动态生成测试数据:
python复制import pytest
def generate_test_cases():
# 可以从数据库、API等动态获取数据
return [(x, x*2) for x in range(1, 6)]
@pytest.mark.parametrize("input,expected", generate_test_cases())
def test_doubling(input, expected):
assert input * 2 == expected
Fixture是pytest最强大的功能之一,它提供了灵活的setup/teardown机制。
pytest支持多种作用域的fixture:
| 作用域 | 执行频率 | 典型用途 |
|---|---|---|
| function | 每个测试函数执行一次 | 测试级别的资源准备 |
| class | 每个测试类执行一次 | 类级别的共享资源 |
| module | 每个模块执行一次 | 模块级别的配置 |
| session | 整个测试会话执行一次 | 全局资源初始化 |
python复制import pytest
@pytest.fixture(scope="module")
def db_connection():
# 模块级别的数据库连接
conn = create_db_connection()
yield conn
conn.close()
@pytest.fixture
def temp_file():
# 函数级别的临时文件
file = tempfile.NamedTemporaryFile()
yield file
file.close()
Fixture可以相互依赖,形成清晰的资源管理链:
python复制import pytest
@pytest.fixture
def user():
return {"name": "Alice", "age": 30}
@pytest.fixture
def admin_user(user):
user["role"] = "admin"
return user
def test_admin_access(admin_user):
assert admin_user["role"] == "admin"
设置autouse=True可以让fixture自动应用于所有测试:
python复制import pytest
@pytest.fixture(autouse=True)
def log_test_start():
print("\n=== 测试开始 ===")
yield
print("\n=== 测试结束 ===")
Mark标记可以让我们对测试用例进行分类和筛选。
@pytest.mark.skip:跳过测试@pytest.mark.skipif:条件跳过@pytest.mark.xfail:预期失败@pytest.mark.parametrize:参数化python复制import pytest
import sys
@pytest.mark.skip(reason="功能尚未实现")
def test_unimplemented():
assert False
@pytest.mark.skipif(sys.version_info < (3, 8),
reason="需要Python 3.8+")
def test_python38_feature():
assert True
@pytest.mark.xfail
def test_known_bug():
assert False
在pytest.ini中定义自定义标记:
ini复制[pytest]
markers =
slow: 标记为慢测试
integration: 集成测试
smoke: 冒烟测试
使用自定义标记:
python复制@pytest.mark.slow
def test_performance():
# 耗时测试
pass
运行指定标记的测试:
bash复制pytest -m "slow" # 只运行慢测试
pytest -m "not slow" # 排除慢测试
pytest会重写assert语句,提供更详细的失败信息:
python复制def test_complex_assert():
result = some_complex_calculation()
assert result == {
"status": "success",
"code": 200,
"data": {"id": 123, "name": "Alice"}
}
当断言失败时,pytest会显示详细的差异比较。
测试预期会抛出异常的情况:
python复制import pytest
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError) as excinfo:
1 / 0
assert str(excinfo.value) == "division by zero"
使用pytest-assume进行多重验证:
python复制import pytest
def test_user_profile(user):
pytest.assume(user.name is not None, "用户名缺失")
pytest.assume(len(user.name) >= 3, "用户名太短")
pytest.assume(user.email is not None, "邮箱缺失")
pytest.assume("@" in user.email, "邮箱格式错误")
| 选项 | 说明 | 示例 |
|---|---|---|
| -v | 详细输出 | pytest -v |
| -s | 显示print输出 | pytest -s |
| -k | 按名称过滤 | pytest -k "test_login" |
| -x | 遇到失败立即停止 | pytest -x |
| --lf | 只运行上次失败的测试 | pytest --lf |
| --ff | 先运行上次失败的测试 | pytest --ff |
使用pytest-xdist插件实现并行测试:
bash复制pytest -n 4 # 使用4个worker并行执行
生成HTML报告:
bash复制pytest --html=report.html
生成覆盖率报告:
bash复制pytest --cov=src --cov-report=html
当需要动态创建测试数据时:
python复制import pytest
@pytest.fixture
def make_user():
def _make_user(name, age):
return {"name": name, "age": age}
return _make_user
def test_user_factory(make_user):
user = make_user("Bob", 25)
assert user["age"] == 25
修改运行时的环境:
python复制import os
import pytest
def test_home_dir(monkeypatch):
monkeypatch.setenv("HOME", "/tmp")
assert os.environ["HOME"] == "/tmp"
python复制import pytest
def test_create_file(tmp_path):
file = tmp_path / "test.txt"
file.write_text("content")
assert file.read_text() == "content"
python复制import pytest
import time
@pytest.mark.benchmark
def test_performance():
start = time.time()
# 被测代码
elapsed = time.time() - start
assert elapsed < 1.0 # 必须在1秒内完成
问题:Fixture "db" not found
解决:
conftest.py或当前测试文件中问题:测试被标记为SKIPPED但没看到skip标记
解决:
问题:参数化测试显示参数数量不匹配
解决:
问题:测试在单独运行时通过,但在整套测试中失败
解决:
--random-order插件检测测试依赖对于大型项目,建议采用功能模块划分测试:
code复制tests/
├── conftest.py
├── unit/
│ ├── test_models.py
│ └── test_utils.py
├── integration/
│ ├── test_api.py
│ └── test_db.py
└── e2e/
├── test_workflow.py
└── test_ui.py
.github/workflows/test.yml示例:
yaml复制name: Python Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: |
pytest --cov=./ --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v1
--durations=10找出最慢的10个测试@pytest.mark.slow并默认排除pytest-xdist并行执行安装所有推荐插件:
bash复制pip install pytest-cov pytest-xdist pytest-html pytest-mock pytest-asyncio pytest-bdd pytest-timeout
在实际项目中使用pytest时,最重要的是建立适合团队工作流程的测试规范和约定。从我的经验来看,良好的测试组织结构比任何高级技巧都更能提高测试代码的长期可维护性。