1. 基本概念
pytest 是 Python 生态中广泛使用的自动化测试框架。
主要用于验证:
- 业务逻辑是否正确。
- API 是否按照预期返回。
- 认证和权限是否生效。
- 数据库操作是否正确。
- 异常和边界情况是否正确处理。
- 修改代码后是否破坏已有功能(Regression Testing)。
基本流程:
编写代码
↓
编写测试
↓
运行 pytest
↓
执行测试用例
↓
通过 / 失败
基础示例
业务代码:
def add(a, b):
return a + b
测试:
def test_add():
assert add(1, 2) == 3
如果将业务代码错误地修改为:
def add(a, b):
return a - b
测试会失败:
AssertionError: assert -1 == 3
pytest 使用 Python 原生 assert,并提供详细的失败信息。
2. 安装和项目配置
Django + DRF 项目常用:
pip install pytest pytest-django pytest-mock pytest-cov
| 工具 | 作用 |
|---|---|
| pytest | 基础测试框架 |
| pytest-django | 集成 Django 测试环境 |
| pytest-mock | 提供 mocker Fixture |
| pytest-cov | 统计代码测试覆盖率 |
pytest.ini
在项目根目录创建:
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
testpaths = tests apps common/tests
addopts = --strict-config --strict-markers --import-mode=importlib
其中 testpaths 需要根据实际项目目录调整,列出的目录应当存在。
重要配置:
DJANGO_SETTINGS_MODULE:指定 Django Settings。python_files:指定测试文件命名规则。testpaths:指定默认查找测试的目录。strict-config:严格检查配置。strict-markers:防止使用未注册的自定义 Marker。import-mode=importlib:使用 importlib 导入测试模块,减少路径和模块命名冲突。
3. 常用运行命令
# 执行全部测试
pytest
# 执行指定文件
pytest tests/test_user.py
# 执行指定函数
pytest tests/test_user.py::test_get_profile
# 显示详细信息
pytest -v
# 第一个失败后停止
pytest -x
# 按名称筛选
pytest -k profile
# 显示 print 输出
pytest -s
# 只执行上次失败的测试
pytest --lf
# 显示最慢的 10 个测试
pytest --durations=10
4. Fixture:测试依赖与环境准备
Fixture 用于复用测试数据、依赖对象和环境初始化逻辑。
使用:
import pytest
@pytest.fixture
def user(db):
return User.objects.create_user(
email="test@example.com",
password="password123",
)
测试函数:
def test_user_email(user):
assert user.email == "test@example.com"
pytest 会自动根据函数参数名称查找并执行 Fixture,然后将返回值注入测试函数。
Fixture 依赖
Fixture 可以依赖其他 Fixture:
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def authenticated_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def itinerary(db, user):
return Itinerary.objects.create(
user=user,
title="Original Trip",
)
使用:
def test_update_itinerary(
authenticated_client,
itinerary,
):
response = authenticated_client.patch(
f"/api/v1/itineraries/{itinerary.id}/",
{"title": "Tokyo Trip"},
format="json",
)
assert response.status_code == 200
这里假设接口允许该用户修改自己的行程。
执行过程:
test_update_itinerary
↓
authenticated_client ──→ api_client
│
└──→ user
↓
Database
itinerary ──→ user
└─→ Database
同一个测试中,相同作用域的 Fixture 通常只执行一次,结果可以被其他依赖复用。
conftest.py
多个测试文件共享 Fixture 时,可以定义在:
tests/
├── conftest.py
├── test_users.py
├── test_pois.py
└── test_itineraries.py
conftest.py 中的 Fixture 会自动被对应目录范围内的测试发现,无需显式导入。
Fixture Scope
| Scope | 生命周期 |
|---|---|
| function | 每个测试函数执行一次,默认 |
| class | 每个测试类执行一次 |
| module | 每个测试模块执行一次 |
| package | 每个测试包执行一次 |
| session | 整个 pytest 测试会话执行一次 |
例如:
@pytest.fixture(scope="module")
def sample_config():
return {"timeout": 30}
对于涉及 Django 数据库的 Fixture,不能随意使用更大的 Scope,需要考虑数据库 Fixture 的生命周期和数据隔离。
yield Fixture
用于需要清理资源的情况:
@pytest.fixture
def temporary_file(tmp_path):
file_path = tmp_path / "test.txt"
file_path.write_text("hello")
yield file_path
file_path.unlink(missing_ok=True)
yield 前负责初始化,后面负责清理。
5. 单元测试(Unit Test)
单元测试主要验证单个函数或模块的业务逻辑。
def calculate_score(a, b):
return a * 2 + b
测试:
def test_calculate_score():
assert calculate_score(3, 4) == 10
特点:
- 通常不依赖数据库。
- 执行速度快。
- 容易定位问题。
- 适合测试计算、转换、验证等纯业务逻辑。
建议尽可能将复杂业务计算拆分为独立函数,以便进行单元测试。
6. Django 数据库测试
pytest-django 默认禁止未明确授权的数据库访问。
可以使用:
import pytest
@pytest.mark.django_db
def test_create_user():
user = User.objects.create(
username="alice"
)
assert user.username == "alice"
assert User.objects.count() == 1
也可以依赖内置 Fixture:
def test_create_user(db):
user = User.objects.create(
username="alice"
)
assert User.objects.filter(
username="alice"
).exists()
两种方式都能允许数据库访问。
测试数据库
pytest-django 通常会使用 Django 测试数据库机制创建独立测试数据库,并在测试之间隔离数据。
正常配置下不会直接修改生产数据库,但必须正确设置测试环境和数据库连接配置。
数据库事务
普通 django_db 测试通常使用事务回滚等机制隔离数据。
如果需要测试真实事务提交、on_commit() 等行为:
@pytest.mark.django_db(transaction=True)
def test_transaction():
...
这种模式会使用事务测试机制,通常比普通数据库测试更慢。
重用测试数据库
pytest --reuse-db
可以在多次测试运行之间保留测试数据库,减少重复创建数据库的开销。
模型结构或 Migration 变化后,可以使用:
pytest --reuse-db --create-db
强制重新创建测试数据库。
7. API 集成测试(Integration Test)
Django REST Framework 提供 APIClient,用于模拟 HTTP 请求。
from rest_framework.test import APIClient
Fixture:
@pytest.fixture
def api_client():
return APIClient()
测试:
@pytest.mark.django_db
def test_get_profile(api_client, user):
api_client.force_authenticate(user=user)
response = api_client.get(
"/api/v1/users/me/"
)
assert response.status_code == 200
assert response.data["email"] == user.email
测试过程:
APIClient
↓
Django URL Routing
↓
Authentication / Permissions
↓
View
↓
Serializer
↓
Database
↓
Response
这里模拟的是 Django/DRF 内部的请求处理,不需要启动真正的 HTTP 服务器。
因此它能测试接口逻辑,但不代表已经测试了 Nginx、TLS、浏览器或完整网络传输链路。
POST 测试
response = api_client.post(
"/api/v1/pois/",
{
"name": "Tokyo Tower",
"score": 90,
},
format="json",
)
assert response.status_code == 201
PATCH 测试
response = api_client.patch(
"/api/v1/pois/1/",
{"name": "New Name"},
format="json",
)
DELETE 测试
response = api_client.delete(
"/api/v1/pois/1/"
)
assert response.status_code == 204
8. 认证与权限测试
认证(Authentication)验证用户身份,权限(Authorization)决定用户能否执行操作。
这两者应该分别测试。
未登录访问
@pytest.mark.django_db
def test_anonymous_access(api_client):
response = api_client.get(
"/api/v1/users/me/"
)
assert response.status_code in (401, 403)
DRF 在不同认证类配置下,未认证请求可能返回 401 或 403。
正式测试最好根据项目配置断言准确的状态码,而不是长期允许两者。
已登录访问
@pytest.mark.django_db
def test_authenticated_access(api_client, user):
api_client.force_authenticate(user=user)
response = api_client.get(
"/api/v1/users/me/"
)
assert response.status_code == 200
对象级权限
假设用户只能修改自己的行程:
@pytest.mark.django_db
def test_cannot_update_other_user_itinerary(
api_client,
user,
other_user,
other_user_itinerary,
):
api_client.force_authenticate(user=user)
response = api_client.patch(
f"/api/v1/itineraries/{other_user_itinerary.id}/",
{"title": "Unauthorized"},
format="json",
)
assert response.status_code in (403, 404)
other_user_itinerary.refresh_from_db()
assert other_user_itinerary.title != "Unauthorized"
403 或 404 取决于 API 的资源可见性设计。
权限测试除了检查状态码,还应检查数据库是否发生了非预期修改。
force_authenticate 与真实认证
api_client.force_authenticate(user=user)
会强制将用户设置为已认证状态,绕过实际认证类的验证逻辑。
适合测试:
- 已认证用户能否访问。
- 对象级权限。
- 业务规则。
- 数据更新。
但不能验证:
- Session Cookie 是否正确。
- JWT 签名和过期校验。
- 登录流程是否正常。
- CSRF 是否生效。
Session 认证测试
对于基于 Django Session 的认证,可以使用:
api_client.login(
email="test@example.com",
password="password123",
)
login() 使用 Django 配置的 Authentication Backend,因此参数需要与项目实际认证方式相匹配。
CSRF 测试
DRF 的 APIClient 默认不会强制执行 CSRF 检查。
如果需要验证 CSRF:
api_client = APIClient(
enforce_csrf_checks=True
)
再通过真实 Session 登录、携带或缺少 CSRF Token 等方式验证对应请求。
注意:CSRF 保护与 SessionAuthentication 的行为相关,具体测试应根据项目采用的认证架构设计。
9. 参数化测试(parametrize)
使用 @pytest.mark.parametrize 可以让同一个测试函数自动运行多组数据。
@pytest.mark.parametrize(
"username",
[
"",
"a",
"x" * 101,
],
)
def test_invalid_username(username):
...
相当于执行三组测试。
多参数测试
@pytest.mark.parametrize(
"a,b,expected",
[
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
],
)
def test_add(a, b, expected):
assert add(a, b) == expected
API 参数化测试
@pytest.mark.django_db
@pytest.mark.parametrize(
"payload",
[
{"username": ""},
{"username": "x" * 101},
{"username": None},
],
)
def test_invalid_user_creation(
api_client,
payload,
):
response = api_client.post(
"/api/v1/users/",
payload,
format="json",
)
assert response.status_code == 400
这种写法非常适合测试 Serializer 的字段验证。
10. 异常测试
使用 pytest.raises() 验证函数是否抛出指定异常。
def parse_age(age):
if age < 0:
raise ValueError("Age must be positive")
return age
测试:
def test_invalid_age():
with pytest.raises(ValueError):
parse_age(-1)
验证错误消息:
def test_invalid_age():
with pytest.raises(
ValueError,
match="Age must be positive",
):
parse_age(-1)
也可以检查异常对象:
def test_invalid_age():
with pytest.raises(ValueError) as exc_info:
parse_age(-1)
assert str(exc_info.value) == (
"Age must be positive"
)
11. Mock:模拟外部依赖
Mock 用于替换不希望在测试中真实执行的外部依赖。
常见场景:
- 外部 HTTP API。
- 邮件发送。
- 第三方支付。
- LLM API。
- 文件存储。
- 耗时任务。
- 难以稳定复现的外部服务。
pytest-mock
安装 pytest-mock 后可以使用 mocker Fixture。
业务代码:
# services/weather.py
import requests
def get_weather():
response = requests.get(
"https://api.example.com/weather",
timeout=5,
)
response.raise_for_status()
return response.json()
测试:
def test_get_weather(mocker):
mock_get = mocker.patch(
"services.weather.requests.get"
)
mock_get.return_value.json.return_value = {
"temperature": 20
}
result = get_weather()
assert result["temperature"] == 20
mock_get.assert_called_once()
Mock 的核心概念
| 方法 | 作用 |
|---|---|
mocker.patch() | 替换指定对象 |
return_value | 设置模拟返回值 |
side_effect | 设置异常或自定义行为 |
assert_called_once() | 验证调用一次 |
assert_called_with() | 验证调用参数 |
assert_not_called() | 验证未调用 |
mocker.spy() | 监视真实函数调用 |
模拟异常
def test_weather_error(mocker):
mock_get = mocker.patch(
"services.weather.requests.get"
)
mock_get.side_effect = requests.Timeout()
with pytest.raises(requests.Timeout):
get_weather()
Patch 的重要原则
Patch 应该针对被测试代码查找依赖的位置,而不一定是依赖最初定义的位置。
例如业务代码使用:
from services.email import send_email
那么测试通常需要 Patch 业务模块中的 send_email 引用,而不是盲目 Patch 原始模块。
Mock 的局限
Mock 可以提高测试速度和稳定性,但不能证明外部系统真正可用。
因此,对关键第三方集成,还应该保留少量集成测试或契约测试。
12. 数据库查询次数测试
Django 可以检查测试代码执行了多少次 SQL。
from django.test.utils import CaptureQueriesContext
from django.db import connection
例如:
@pytest.mark.django_db
def test_query_count():
with CaptureQueriesContext(connection) as queries:
list(POI.objects.select_related("city"))
assert len(queries) <= 2
pytest-django 也提供 django_assert_num_queries Fixture:
def test_query_count(
db,
django_assert_num_queries,
):
with django_assert_num_queries(1):
list(
POI.objects.select_related("city")
)
特别适合检测 N+1 Query 问题。
例如,某个接口原本执行 2 次 SQL,修改 Serializer 后变成 101 次 SQL,就可以通过查询次数测试发现问题。
注意:查询次数可能受到认证、Session、缓存和数据库初始化等因素影响,最好针对稳定、明确的代码路径进行断言。
13. Factory:测试数据工厂
当测试需要创建大量复杂 Model 实例时,可以使用 Factory 工具,例如 factory_boy。
普通 Fixture:
@pytest.fixture
def user(db):
return User.objects.create_user(
email="test@example.com",
password="password123",
)
如果有大量测试用户、POI、行程等模型,手工创建会出现重复代码。
factory_boy 示例
import factory
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
email = factory.Sequence(
lambda n: f"user{n}@example.com"
)
password = factory.PostGenerationMethodCall(
"set_password",
"password123",
)
使用:
@pytest.mark.django_db
def test_users():
user1 = UserFactory()
user2 = UserFactory()
assert user1.email != user2.email
Factory 与 Fixture 的区别:
- Fixture:控制测试依赖的创建和生命周期。
- Factory:便捷生成具有不同属性的测试对象。
两者可以结合使用。
14. 测试覆盖率(Coverage)
测试覆盖率表示测试执行过程中覆盖了多少代码。
使用 pytest-cov:
pytest --cov=apps
显示缺失行:
pytest --cov=apps --cov-report=term-missing
生成 HTML 报告:
pytest --cov=apps --cov-report=html
会生成:
htmlcov/
└── index.html
覆盖率指标
| 指标 | 含义 |
|---|---|
| Line Coverage | 行覆盖率 |
| Branch Coverage | 分支覆盖率 |
| Function Coverage | 函数覆盖率 |
启用分支覆盖率:
pytest --cov=apps --cov-branch
设置最低覆盖率门槛:
pytest --cov=apps --cov-fail-under=80
如果覆盖率低于 80%,命令会失败。
注意:覆盖率高不等于测试质量高。
例如测试执行了某段代码,但没有有效检查结果,依然可能获得较高覆盖率。
相比单纯追求 100%,更应关注关键业务逻辑和风险较高的代码路径。
15. 测试分类与优先级
常见测试类型:
| 类型 | 测试内容 | 特点 |
|---|---|---|
| Unit Test | 单个函数或模块 | 快速、易定位 |
| Integration Test | 多个组件协作 | 覆盖真实业务流程 |
| API Test | HTTP 接口行为 | 验证状态码、数据、权限 |
| Regression Test | 已有功能是否被破坏 | 防止重构引入错误 |
| End-to-End Test | 完整用户操作流程 | 覆盖面广,成本较高 |
对于 Django + DRF 项目,建议优先测试:
- 关键业务逻辑。
- Serializer 数据验证。
- 重要 API 的成功和错误响应。
- 用户认证及对象级权限。
- 数据库修改是否正确。
- 外部服务调用失败时的处理。
- 关键查询是否发生明显的性能退化。
不必为每个简单的 Model 字段编写独立测试,应该优先覆盖有业务规则、分支逻辑和失败风险的功能。
16. CI/CD 中运行 pytest
pytest 通常作为持续集成(CI)中的自动测试环节。
例如 GitHub Actions:
name: Backend Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest -v
- name: Validate OpenAPI
run: |
python manage.py spectacular \
--file openapi.yaml \
--validate \
--fail-on-warn
这是简化示例,假设项目测试配置使用 SQLite,或已经能连接到所需的测试数据库。
如果项目依赖 PostgreSQL、Redis 等服务,需要在 CI 中额外配置对应的服务容器、环境变量和依赖。
典型流程:
Push / Pull Request
↓
GitHub Actions
↓
Install Dependencies
↓
pytest
↓
OpenAPI Validation
↓
Build
↓
Deploy
测试失败时可以阻止后续部署,避免明显有问题的代码进入生产环境。
17. 测试编写原则
1. Arrange / Act / Assert
推荐按照三个阶段组织测试:
def test_add():
# Arrange
a = 1
b = 2
# Act
result = add(a, b)
# Assert
assert result == 3
分别对应:
- Arrange:准备数据和环境。
- Act:执行被测试操作。
- Assert:验证结果。
2. 测试应当独立
不同测试不应该依赖执行顺序,也不应该依赖其他测试产生的数据。
3. 避免过度 Mock
只 Mock 必要的外部依赖,避免把所有组件都替换成假对象,导致测试无法验证真实集成关系。
4. 优先检查行为,而不是实现细节
例如测试一个 API 时,通常应该重点检查:
assert response.status_code == 200
assert response.data["name"] == "Tokyo"
而不是过度依赖某个内部函数具体调用了多少次。
5. 同时覆盖成功和失败场景
例如行程修改接口:
用户修改自己的行程 → 200
未登录用户修改 → 401/403
修改其他用户行程 → 403/404
行程不存在 → 404
参数非法 → 400
数据库修改成功 → 数据正确
状态码应以实际 API 契约为准。
总结
pytest 的重点可以分为四个层次:
| 层次 | 需要掌握的内容 |
|---|---|
| 基础 | assert、测试发现、运行命令、parametrize、raises |
| 测试依赖 | Fixture、conftest.py、Mock、Factory |
| Django / DRF | django_db、APIClient、认证权限、数据库验证 |
| 工程实践 | Coverage、查询次数、CI/CD、回归测试 |
对于 Django REST Framework 项目,最常用的组合是:
pytest
+
pytest-django
+
DRF APIClient
+
Fixture
+
pytest-mock
+
pytest-cov
其中 Fixture、APIClient、django_db、参数化和 Mock 是日常开发最值得优先掌握的部分。