pytest 实战指南:从单元测试到自动化测试体系

3669 字
18 分钟
pytest 实战指南:从单元测试到自动化测试体系

pytest 实战指南:从单元测试到自动化测试体系#

没有测试的代码就像没有刹车的汽车——跑得快,但你不知道它什么时候会撞。

如果你写 Python 已经超过一年,大概率经历过这样的场景:改了 A 模块的一个函数,结果 C 模块的某个边缘逻辑崩了。你花了两个小时才定位到问题,然后发誓”下次一定要写测试”。

但下次往往还是没写。

原因很简单:测试不好写。unittest 的 boilerplate 太多,mock 的 API 反人类,测试跑起来慢得像蜗牛。直到你遇到 pytest——它把测试从”不得不做的苦差事”变成了”写了真香”的体验。

这篇文章不教你 assert 1 + 1 == 2。我们直接上实战:fixtures 依赖注入、mock 的艺术、参数化测试、异步测试,以及如何用 pytest 搭建一套真正能保护你代码的测试体系。

为什么是 pytest?#

Python 标准库自带的 unittest 不是不能用,但对比一下你就知道差距在哪:

# unittest 风格 — 写测试像写 Java
import 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.fixture
def calc():
c = Calculator()
yield c
c.cleanup()
def test_add(calc):
assert calc.add(2, 3) == 5

核心差异:

维度unittestpytest
断言需要 self.assertEqual 等 20+ 种方法原生 assert,失败信息自动解析
FixturesetUp/tearDown,只能类级别函数级 fixture,支持依赖注入
参数化@parameterized.expand 第三方内置 @pytest.mark.parametrize
Mockunittest.mock,API 繁琐monkeypatch + mocker 插件
插件生态800+ 插件,覆盖异步、数据库、HTTP
运行速度较慢支持 -x(首次失败停止)、--lf(上次失败重试)

一句话总结:unittest 是”标准库给的玩具”,pytest 是”工业级武器”。

一、Fixture 依赖注入:测试的”乐高积木”#

Fixture 是 pytest 最强大的特性,也是和 unittest 最大的分水岭。它的本质是依赖注入——测试函数需要什么,fixture 就提供什么。

1.1 基础 fixture#

import pytest
import tempfile
import os
@pytest.fixture
def 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.fixture
def postgres_url():
return 'postgresql://localhost/test_db'
@pytest.fixture
def engine(postgres_url):
return create_engine(postgres_url)
@pytest.fixture
def db_session(engine):
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.rollback()
session.close()
@pytest.fixture
def 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
tests/conftest.py
@pytest.fixture
def 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

Terminal window
pip install pytest-mock
import requests
from 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_effectreturn_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 这个公共 API
mocker.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] PASSED
test_math.py::test_add[-1-1-0] PASSED
test_math.py::test_add[0-0-0] PASSED
test_math.py::test_add[100--50-50] PASSED
test_math.py::test_add[inf-1-inf] PASSED

3.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

Terminal window
pip install pytest-asyncio

4.1 基础异步测试#

import pytest
import httpx
@pytest.mark.asyncio
async 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.fixture
async def async_db():
"""异步数据库连接"""
conn = await asyncpg.connect('postgresql://localhost/test')
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_async_query(async_db):
rows = await async_db.fetch('SELECT 1 AS value')
assert rows[0]['value'] == 1

4.3 异步 + mock 组合#

@pytest.mark.asyncio
async 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.ini

5.2 pytest.ini 配置#

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
e2e: marks tests as end-to-end tests

5.3 常用运行命令#

Terminal window
# 运行所有测试
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 --pdb

5.4 覆盖率目标#

项目阶段目标覆盖率说明
新项目80%+从一开始就保持高覆盖率
老项目重构60%+ → 80%先覆盖改动区域,逐步提升
遗留系统40%+覆盖关键路径即可
核心库95%+被广泛依赖的库需要极高覆盖率

重要提醒:覆盖率是手段不是目的。100% 覆盖率不代表没有 bug,60% 覆盖率也不代表代码质量差。关键是覆盖核心逻辑和边界条件

Terminal window
# 生成详细的 HTML 覆盖率报告
pytest --cov=myapp --cov-report=term-missing --cov-report=html
# 查看哪些行没覆盖
cat htmlcov/index.html # 用浏览器打开

六、实战案例:完整的 API 测试#

让我们把前面学的串起来,写一个真实的 API 测试:

import pytest
from httpx import AsyncClient
from myapp.main import app
from myapp.models import User
from myapp.database import get_db_session
# ===== Fixtures =====
@pytest.fixture
async def client():
"""创建测试用的 HTTP 客户端"""
async with AsyncClient(app=app, base_url='http://test') as c:
yield c
@pytest.fixture
async 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.unit
def 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.asyncio
async 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.asyncio
async 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.asyncio
async 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 依赖注入clientasync_dbmocker 自动注入
  • 异步测试@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 秒,开发者就不会频繁运行。优化策略:

  1. 并行运行pytest -n auto 利用多核
  2. 标记慢测试@pytest.mark.slow,日常跑 pytest -m "not slow"
  3. mock 外部调用:HTTP 请求、数据库查询用 mock 替代
  4. 只跑变更相关的测试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 == 3

pytest 的 assert 会自动解析表达式,失败时显示左右两边的实际值。不需要手动写 message,除非你想加额外上下文。

九、进阶技巧#

9.1 使用 pytest-lazy-fixture 处理动态依赖#

from pytest_lazyfixture import lazy_fixture
@pytest.fixture
def user_admin():
return User(role='admin')
@pytest.fixture
def 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))

十、总结:测试不是负担,是保险#

写测试的回报不是”代码覆盖率数字好看”,而是:

  1. 重构时有信心——改了代码跑一遍测试,全绿就放心提交
  2. Bug 定位更快——失败的测试直接告诉你哪段逻辑出了问题
  3. 文档即代码——测试就是最好的 API 文档,而且永远不会过时
  4. 设计倒逼——为了好测试,你会自然写出低耦合、高内聚的代码

起步建议

  • 新项目:从第一天就写测试,目标 80%+ 覆盖率
  • 老项目:先给要改的代码加测试,不追求全覆盖
  • 核心逻辑:边界条件、异常处理、数据转换——这些最容易出 bug,优先覆盖
  • UI 和集成测试:少而精,覆盖关键用户路径

最好的测试时机是写代码的时候。其次是现在。


本文代码示例:所有代码均可直接运行,需要 pytest >= 8.0pytest-asyncio >= 0.23pytest-mock >= 3.14

Terminal window
pip install pytest pytest-asyncio pytest-mock pytest-cov pytest-xdist

如果你还在用 unittest,试试把最近的几个测试用 pytest 重写。你会发现——测试本来可以这么舒服。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

pytest 实战指南:从单元测试到自动化测试体系
https://boke.hackerdream.xyz/posts/pytest-practical-guide/
作者
晴天
发布于
2026-05-16
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录