pytest 实战指南:从单元测试到自动化测试体系
pytest 实战指南:从单元测试到自动化测试体系
没有测试的代码就像没有刹车的汽车——跑得快,但你不知道它什么时候会撞。
如果你写 Python 已经超过一年,大概率经历过这样的场景:改了 A 模块的一个函数,结果 C 模块的某个边缘逻辑崩了。你花了两个小时才定位到问题,然后发誓”下次一定要写测试”。
但下次往往还是没写。
原因很简单:测试不好写。unittest 的 boilerplate 太多,mock 的 API 反人类,测试跑起来慢得像蜗牛。直到你遇到 pytest——它把测试从”不得不做的苦差事”变成了”写了真香”的体验。
这篇文章不教你 assert 1 + 1 == 2。我们直接上实战:fixtures 依赖注入、mock 的艺术、参数化测试、异步测试,以及如何用 pytest 搭建一套真正能保护你代码的测试体系。
为什么是 pytest?
Python 标准库自带的 unittest 不是不能用,但对比一下你就知道差距在哪:
# unittest 风格 — 写测试像写 Javaimport unittest
class TestCalculator(unittest.TestCase): def setUp(self): self.calc = Calculator()
def test_add(self): result = self.calc.add(2, 3) self.assertEqual(result, 5)
def tearDown(self): self.calc.cleanup()# pytest 风格 — 测试就是 Python 代码@pytest.fixturedef calc(): c = Calculator() yield c c.cleanup()
def test_add(calc): assert calc.add(2, 3) == 5核心差异:
| 维度 | unittest | pytest |
|---|---|---|
| 断言 | 需要 self.assertEqual 等 20+ 种方法 | 原生 assert,失败信息自动解析 |
| Fixture | setUp/tearDown,只能类级别 | 函数级 fixture,支持依赖注入 |
| 参数化 | @parameterized.expand 第三方 | 内置 @pytest.mark.parametrize |
| Mock | unittest.mock,API 繁琐 | monkeypatch + mocker 插件 |
| 插件生态 | 无 | 800+ 插件,覆盖异步、数据库、HTTP |
| 运行速度 | 较慢 | 支持 -x(首次失败停止)、--lf(上次失败重试) |
一句话总结:unittest 是”标准库给的玩具”,pytest 是”工业级武器”。
一、Fixture 依赖注入:测试的”乐高积木”
Fixture 是 pytest 最强大的特性,也是和 unittest 最大的分水岭。它的本质是依赖注入——测试函数需要什么,fixture 就提供什么。
1.1 基础 fixture
import pytestimport tempfileimport os
@pytest.fixturedef temp_file(): """创建一个临时文件,测试完成后自动清理""" fd, path = tempfile.mkstemp(suffix='.txt') with os.fdopen(fd, 'w') as f: f.write('hello pytest') yield path # 把路径传给测试函数 os.unlink(path) # 清理
def test_read_temp_file(temp_file): """测试函数直接声明依赖,pytest 自动注入""" with open(temp_file) as f: content = f.read() assert content == 'hello pytest'注意 yield 的用法:yield 之前是 setup,yield 之后是 teardown。比 unittest 的 setUp/tearDown 配对清晰得多——setup 和 teardown 在同一个函数里,不会错位。
1.2 Fixture 作用域:别重复造轮子
@pytest.fixture(scope='session')def db_connection(): """整个测试会话只创建一次数据库连接""" conn = create_db_connection() yield conn conn.close()
@pytest.fixture(scope='module')def api_client(db_connection): """每个模块创建一次,复用数据库连接""" return APIClient(db_connection)
@pytest.fixture(scope='function')def test_user(api_client): """每个测试函数创建独立用户""" user = api_client.create_user(name='test_user') yield user api_client.delete_user(user.id)作用域选择原则:
| 作用域 | 创建频率 | 适用场景 | 注意事项 |
|---|---|---|---|
session | 整个测试运行一次 | 数据库连接、配置加载 | 确保线程安全 |
module | 每个文件一次 | API 客户端、HTTP 服务 | 测试间共享状态要小心 |
class | 每个类一次 | 类级别的共享资源 | 配合 pytest.mark.usefixtures |
function | 每个函数一次 | 临时文件、测试数据 | 默认值,最安全 |
常见坑:把 function 级别的 fixture 写成 session 级别,导致测试之间互相污染。记住:除非创建成本很高,否则默认用 function。
1.3 Fixture 组合:依赖链
Fixture 可以依赖其他 fixture,pytest 自动解析依赖图:
@pytest.fixturedef postgres_url(): return 'postgresql://localhost/test_db'
@pytest.fixturedef engine(postgres_url): return create_engine(postgres_url)
@pytest.fixturedef db_session(engine): Session = sessionmaker(bind=engine) session = Session() yield session session.rollback() session.close()
@pytest.fixturedef sample_user(db_session): user = User(name='Alice', email='alice@example.com') db_session.add(user) db_session.commit() return user
def test_user_email(sample_user): # pytest 自动执行:postgres_url → engine → db_session → sample_user assert sample_user.email == 'alice@example.com'这种依赖链让测试代码极其简洁——测试函数只关心”我要什么”,不关心”怎么来的”。
1.4 conftest.py:跨文件共享 fixture
把通用 fixture 放到 conftest.py,pytest 自动发现,不需要 import:
tests/├── conftest.py # 放通用 fixture├── test_user.py├── test_order.py└── api/ ├── conftest.py # API 专用 fixture,覆盖上层 └── test_endpoints.py@pytest.fixturedef sample_user(db_session): """所有测试模块共享的示例用户""" user = User(name='Test User', email='test@example.com') db_session.add(user) db_session.commit() return user技巧:子目录的 conftest.py 可以覆盖父目录的同名 fixture,实现测试环境的分层定制。
二、Mock 的艺术:隔离外部依赖
测试数据库调用、HTTP 请求、文件系统——这些外部依赖会让测试变慢、变脆弱、不可重复。mock 就是用来隔离它们的。
2.1 monkeypatch:替换全局状态
def get_api_key(): return os.environ.get('API_KEY')
def test_get_api_key(monkeypatch): # 替换 os.environ 中的值 monkeypatch.setenv('API_KEY', 'test-key-123') assert get_api_key() == 'test-key-123'
# monkeypatch 自动在测试结束后恢复原值monkeypatch 是 pytest 内置的 fixture,不需要安装任何插件。它能修改环境变量、模块属性、字典内容,并且测试结束后自动恢复——这比 unittest.mock.patch 的上下文管理器优雅得多。
2.2 mocker 插件:mock 函数和类
安装 pytest-mock:
pip install pytest-mockimport requestsfrom myapp import fetch_user_data
def test_fetch_user_data(mocker): # mock requests.get mock_response = mocker.Mock() mock_response.json.return_value = {'id': 1, 'name': 'Alice'} mock_response.status_code = 200
mocker.patch.object(requests, 'get', return_value=mock_response)
result = fetch_user_data(user_id=1) assert result['name'] == 'Alice'
# 验证被调用了一次,参数正确 requests.get.assert_called_once_with('https://api.example.com/users/1')为什么用 mocker.patch 而不是 @patch 装饰器?
# ❌ unittest.mock.patch — 参数顺序反直觉@patch('myapp.requests.get')def test_something(mock_get): pass
# ✅ pytest-mock — fixture 注入,顺序自然def test_something(mocker): mocker.patch('myapp.requests.get')装饰器方式需要在函数签名里按装饰器从下到上的顺序声明参数,容易搞混。fixture 方式直接在函数体内操作,更直观。
2.3 高级 mock:side_effect 模拟异常
def test_fetch_timeout(mocker): mock_get = mocker.patch('myapp.requests.get') mock_get.side_effect = requests.exceptions.Timeout('Connection timed out')
with pytest.raises(requests.exceptions.Timeout): fetch_user_data(user_id=1)side_effect 比 return_value 强大得多——它可以是异常、可迭代对象(每次调用返回不同值)、或者一个函数。
# 模拟重试场景:第一次失败,第二次成功def test_retry_logic(mocker): mock_get = mocker.patch('myapp.requests.get') mock_get.side_effect = [ requests.exceptions.ConnectionError(), mocker.Mock(status_code=200, json=lambda: {'id': 1}) ]
result = fetch_with_retry(user_id=1) assert result['id'] == 1 assert mock_get.call_count == 2 # 验证重试了一次2.4 Mock 的边界:别 mock 你不拥有的东西
这是一个经常被忽视的原则。只 mock 你自己代码中的依赖,不要 mock 第三方库的内部实现。
# ❌ 错误:mock 了 requests 的内部实现mocker.patch('requests.adapters.HTTPAdapter.send')
# ✅ 正确:mock 了 requests.get 这个公共 APImocker.patch('myapp.requests.get')
# ✅ 更好:通过接口抽象,mock 你自己的抽象层mocker.patch('myapp.http_client.get')mock 第三方库的内部实现会让你的测试对库的升级极度敏感——requests 换个内部方法名,你的测试就挂了。
三、参数化测试:一组测试逻辑,多组输入
如果你写过这样的测试:
def test_add_1(): assert add(1, 2) == 3
def test_add_2(): assert add(-1, 1) == 0
def test_add_3(): assert add(0, 0) == 0停下来。用 @pytest.mark.parametrize 一行搞定:
@pytest.mark.parametrize('a, b, expected', [ (1, 2, 3), (-1, 1, 0), (0, 0, 0), (100, -50, 50), (float('inf'), 1, float('inf')),])def test_add(a, b, expected): assert add(a, b) == expected运行结果会显示每个参数组合:
test_math.py::test_add[1-2-3] PASSEDtest_math.py::test_add[-1-1-0] PASSEDtest_math.py::test_add[0-0-0] PASSEDtest_math.py::test_add[100--50-50] PASSEDtest_math.py::test_add[inf-1-inf] PASSED3.1 多参数组合
@pytest.mark.parametrize('user_role', ['admin', 'editor', 'viewer'])@pytest.mark.parametrize('endpoint', ['/users', '/posts', '/settings'])def test_access_control(user_role, endpoint): """自动生成 3 × 3 = 9 个测试用例""" client = create_client(role=user_role) response = client.get(endpoint) # 根据 role 和 endpoint 检查权限注意:装饰器从上到下执行,所以 endpoint 是内层循环,user_role 是外层循环。
3.2 使用 pytest.param 添加标记
@pytest.mark.parametrize('url, expected_status', [ ('https://httpbin.org/get', 200), pytest.param('https://slow-server.com', 200, marks=pytest.mark.slow), pytest.param('https://invalid-url', 404, marks=pytest.mark.xfail(reason='DNS 解析失败')),])def test_http_request(url, expected_status): response = requests.get(url, timeout=5) assert response.status_code == expected_status这样可以用 pytest -m "not slow" 跳过慢测试,用 pytest --runxfail 运行预期失败的测试。
四、异步测试:async/await 时代的测试
Python 异步编程越来越普及,但异步测试是 unittest 的盲区。pytest 通过插件完美支持。
安装 pytest-asyncio:
pip install pytest-asyncio4.1 基础异步测试
import pytestimport httpx
@pytest.mark.asyncioasync def test_fetch_async(): async with httpx.AsyncClient() as client: response = await client.get('https://httpbin.org/get') assert response.status_code == 200 data = response.json() assert 'url' in data@pytest.mark.asyncio 告诉 pytest 这个测试函数需要在一个事件循环中运行。
4.2 异步 fixture
@pytest.fixtureasync def async_db(): """异步数据库连接""" conn = await asyncpg.connect('postgresql://localhost/test') yield conn await conn.close()
@pytest.mark.asyncioasync def test_async_query(async_db): rows = await async_db.fetch('SELECT 1 AS value') assert rows[0]['value'] == 14.3 异步 + mock 组合
@pytest.mark.asyncioasync def test_async_fetch_with_mock(mocker): # mock 异步函数 mock_get = mocker.AsyncMock() mock_get.return_value.json.return_value = {'status': 'ok'} mocker.patch('httpx.AsyncClient.get', mock_get)
async with httpx.AsyncClient() as client: result = await client.get('https://api.example.com/health') data = result.json() assert data['status'] == 'ok'注意这里用 mocker.AsyncMock 而不是 mocker.Mock——异步函数必须用 AsyncMock 才能正确 await。
五、测试组织与运行策略
5.1 项目结构
myproject/├── src/│ └── myapp/│ ├── __init__.py│ ├── models.py│ ├── services.py│ └── api.py├── tests/│ ├── conftest.py│ ├── __init__.py│ ├── unit/│ │ ├── test_models.py│ │ └── test_services.py│ ├── integration/│ │ ├── test_api.py│ │ └── test_db.py│ └── e2e/│ └── test_user_flow.py├── pyproject.toml└── pytest.ini5.2 pytest.ini 配置
[pytest]testpaths = testspython_files = test_*.pypython_classes = Test*python_functions = test_*addopts = -v --tb=short --strict-markersmarkers = slow: marks tests as slow (deselect with '-m "not slow"') integration: marks tests as integration tests e2e: marks tests as end-to-end tests5.3 常用运行命令
# 运行所有测试pytest
# 只运行单元测试pytest tests/unit/
# 首次失败就停止pytest -x
# 只运行上次失败的测试(开发时反复调试用)pytest --lf
# 并行运行(需要 pytest-xdist)pytest -n auto
# 生成覆盖率报告pytest --cov=myapp --cov-report=html
# 按标记运行pytest -m "not slow"pytest -m integration
# 显示 print 输出pytest -s
# 失败时进入调试器pytest --pdb5.4 覆盖率目标
| 项目阶段 | 目标覆盖率 | 说明 |
|---|---|---|
| 新项目 | 80%+ | 从一开始就保持高覆盖率 |
| 老项目重构 | 60%+ → 80% | 先覆盖改动区域,逐步提升 |
| 遗留系统 | 40%+ | 覆盖关键路径即可 |
| 核心库 | 95%+ | 被广泛依赖的库需要极高覆盖率 |
重要提醒:覆盖率是手段不是目的。100% 覆盖率不代表没有 bug,60% 覆盖率也不代表代码质量差。关键是覆盖核心逻辑和边界条件。
# 生成详细的 HTML 覆盖率报告pytest --cov=myapp --cov-report=term-missing --cov-report=html
# 查看哪些行没覆盖cat htmlcov/index.html # 用浏览器打开六、实战案例:完整的 API 测试
让我们把前面学的串起来,写一个真实的 API 测试:
import pytestfrom httpx import AsyncClientfrom myapp.main import appfrom myapp.models import Userfrom myapp.database import get_db_session
# ===== Fixtures =====
@pytest.fixtureasync def client(): """创建测试用的 HTTP 客户端""" async with AsyncClient(app=app, base_url='http://test') as c: yield c
@pytest.fixtureasync def auth_headers(async_db, mocker): """生成认证 token""" # mock JWT 生成,避免真实加密计算 mock_encode = mocker.patch('myapp.auth.jwt.encode', return_value='fake-token') user = User(name='Tester', email='test@example.com') async_db.add(user) await async_db.commit() return {'Authorization': f'Bearer fake-token'}
# ===== 单元测试 =====
@pytest.mark.unitdef test_user_model_validation(): """测试用户模型的数据校验""" with pytest.raises(ValueError, match='email is required'): User(name='No Email')
user = User(name='Alice', email='alice@example.com') assert user.email == 'alice@example.com'
# ===== 集成测试 =====
@pytest.mark.integration@pytest.mark.asyncioasync def test_create_user(client, async_db): """测试创建用户 API""" response = await client.post('/api/users', json={ 'name': 'Bob', 'email': 'bob@example.com' }) assert response.status_code == 201 data = response.json() assert data['name'] == 'Bob' assert 'id' in data
@pytest.mark.integration@pytest.mark.asyncioasync def test_get_user_not_found(client): """测试获取不存在的用户""" response = await client.get('/api/users/99999') assert response.status_code == 404 assert 'not found' in response.json()['detail']
@pytest.mark.integration@pytest.mark.asyncioasync def test_list_users_pagination(client, async_db, mocker): """测试分页功能""" # 创建 25 个测试用户 for i in range(25): async_db.add(User(name=f'User{i}', email=f'user{i}@test.com')) await async_db.commit()
# 第一页,每页 10 条 response = await client.get('/api/users?page=1&per_page=10') assert response.status_code == 200 data = response.json() assert len(data['items']) == 10 assert data['total'] == 25 assert data['page'] == 1 assert data['pages'] == 3这个案例展示了:
- fixture 依赖注入:
client、async_db、mocker自动注入 - 异步测试:
@pytest.mark.asyncio+async def - mock 外部依赖:JWT 生成用 mock 替代
- 标记分类:
@pytest.mark.unit/@pytest.mark.integration - 断言丰富:状态码、JSON 结构、分页数据
七、CI/CD 集成
测试不跑在 CI 上等于没写。GitHub Actions 配置示例:
name: Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: test_db POSTGRES_PASSWORD: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps: - uses: actions/checkout@v4
- name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12'
- name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pytest pytest-asyncio pytest-cov pytest-xdist
- name: Run tests env: DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db run: | pytest tests/unit/ -v -n auto pytest tests/integration/ -v --cov=myapp --cov-report=xml
- name: Upload coverage uses: codecov/codecov-action@v4 with: file: coverage.xml八、常见坑与避坑指南
坑 1:fixture 共享状态导致测试互相影响
# ❌ 危险:列表在测试间共享@pytest.fixture(scope='module')def shared_list(): return []
def test_a(shared_list): shared_list.append(1) assert len(shared_list) == 1
def test_b(shared_list): # 如果 test_a 先跑,这里会失败 assert len(shared_list) == 0 # 实际是 1!解法:可变对象(列表、字典、数据库连接)永远用 function 作用域。
坑 2:mock 了不该 mock 的东西
# ❌ mock 了内置函数mocker.patch('builtins.open', ...) # 会导致所有文件操作失效
# ✅ 只 mock 特定路径mocker.patch('myapp.config.open', ...)坑 3:测试太慢
如果测试跑超过 30 秒,开发者就不会频繁运行。优化策略:
- 并行运行:
pytest -n auto利用多核 - 标记慢测试:
@pytest.mark.slow,日常跑pytest -m "not slow" - mock 外部调用:HTTP 请求、数据库查询用 mock 替代
- 只跑变更相关的测试:
pytest --lf只跑上次失败的
坑 4:断言信息不清晰
# ❌ 失败时只知道 "AssertionError"assert len(users) == expected_count
# ✅ 失败时显示实际值和期望值assert len(users) == expected_count, f"Expected {expected_count} users, got {len(users)}"
# ✅✅ 用 pytest 的 assert 就够了——它自动解析表达式assert len(users) == expected_count # pytest 失败时显示: assert 5 == 3pytest 的 assert 会自动解析表达式,失败时显示左右两边的实际值。不需要手动写 message,除非你想加额外上下文。
九、进阶技巧
9.1 使用 pytest-lazy-fixture 处理动态依赖
from pytest_lazyfixture import lazy_fixture
@pytest.fixturedef user_admin(): return User(role='admin')
@pytest.fixturedef user_guest(): return User(role='guest')
@pytest.mark.parametrize('user', [ lazy_fixture('user_admin'), lazy_fixture('user_guest'),])def test_role_permission(user): assert user.role in ('admin', 'guest')9.2 自定义断言消息
def test_complex_assertion(): result = calculate_metrics(data) # 用 pytest.approx 处理浮点数比较 assert result['accuracy'] == pytest.approx(0.95, rel=1e-2) assert result['loss'] < pytest.approx(0.1, abs=1e-3)9.3 测试生成器
# 用 pytest_generate_tests 动态生成参数def pytest_generate_tests(metafunc): if 'csv_data' in metafunc.fixturenames: # 从 CSV 文件读取测试数据 import csv with open('test_data.csv') as f: reader = csv.DictReader(f) metafunc.parametrize('csv_data', list(reader))十、总结:测试不是负担,是保险
写测试的回报不是”代码覆盖率数字好看”,而是:
- 重构时有信心——改了代码跑一遍测试,全绿就放心提交
- Bug 定位更快——失败的测试直接告诉你哪段逻辑出了问题
- 文档即代码——测试就是最好的 API 文档,而且永远不会过时
- 设计倒逼——为了好测试,你会自然写出低耦合、高内聚的代码
起步建议:
- 新项目:从第一天就写测试,目标 80%+ 覆盖率
- 老项目:先给要改的代码加测试,不追求全覆盖
- 核心逻辑:边界条件、异常处理、数据转换——这些最容易出 bug,优先覆盖
- UI 和集成测试:少而精,覆盖关键用户路径
最好的测试时机是写代码的时候。其次是现在。
本文代码示例:所有代码均可直接运行,需要 pytest >= 8.0、pytest-asyncio >= 0.23、pytest-mock >= 3.14。
pip install pytest pytest-asyncio pytest-mock pytest-cov pytest-xdist如果你还在用 unittest,试试把最近的几个测试用 pytest 重写。你会发现——测试本来可以这么舒服。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!