← 编程返回顶部

pytest:Python 自动化测试

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 TestHTTP 接口行为验证状态码、数据、权限
Regression Test已有功能是否被破坏防止重构引入错误
End-to-End Test完整用户操作流程覆盖面广,成本较高

对于 Django + DRF 项目,建议优先测试:

  1. 关键业务逻辑。
  2. Serializer 数据验证。
  3. 重要 API 的成功和错误响应。
  4. 用户认证及对象级权限。
  5. 数据库修改是否正确。
  6. 外部服务调用失败时的处理。
  7. 关键查询是否发生明显的性能退化。

不必为每个简单的 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 / DRFdjango_db、APIClient、认证权限、数据库验证
工程实践Coverage、查询次数、CI/CD、回归测试

对于 Django REST Framework 项目,最常用的组合是:

pytest
    +
pytest-django
    +
DRF APIClient
    +
Fixture
    +
pytest-mock
    +
pytest-cov

其中 Fixture、APIClient、django_db、参数化和 Mock 是日常开发最值得优先掌握的部分。


参考文档