pytest-django深度使用手册:核心机制与数据库策略实战
2026/9/9 10:55:10 网站建设 项目流程

先聊点实在的。Django项目写测试,早期大家基本靠unittestsetUpTestData撑场面,后来pytest以极其舒服的 fixture 和断言风格席卷 Python 圈,Django 官方文档里也专门给了兼容方案。但真正让 pytest 在 Django 项目里“落地生根”的,是pytest-django这个插件。

这些年我在好几个中型 Django 项目里把测试体系从零搭起来,也从 unittest 迁移到 pytest-django,踩过不少坑,也总结出一套比较顺手的用法。这篇东西不打算把官方文档翻译一遍,而是按“为什么要这么用”“实际项目里怎么落地”“出了问题怎么排查”这条线,把 pytest-django 的关键机制、fixture、标记、数据库策略这些核心内容完整过一遍,适合正在搭建或重构 Django 测试体系的人参考,也适合刚接触 pytest-django 的人当一份深度使用手册。

1. 整体设计思路:pytest-django 到底帮我们解决了什么

很多人在 Django 里写测试,第一个困惑是:Django 自带manage.py test,为什么还要引入 pytest-django?这背后其实是一套测试范式的问题,先把这个想明白,后面的操作才不是机械套用。

1.1 从 unittest 到 pytest:为什么测试代码比测试工具更重要

Django 自带的测试框架基于 unittest,写出来的测试天然是类组织的:

from django.test import TestCase class UserTestCase(TestCase): def setUp(self): self.user = User.objects.create(username="test") def test_user_created(self): self.assertEqual(self.user.username, "test")

这套写法没什么不好,但在实际项目里,测试代码多了之后有一个很现实的问题:setUp里的对象构造逻辑经常要在多个测试类里重复,改一个字段要全局搜索替换。pytest 的 fixture 机制把“准备数据”和“执行断言”彻底解耦,你需要什么就声明什么:

import pytest from myapp.models import User @pytest.fixture def user(db): return User.objects.create(username="test") def test_user_created(user): assert user.username == "test"

从可维护性角度看,fixture 是函数级的、可组合的、作用域可控的,这比类继承要优雅得多。pytest-django 就是这座桥:它让 pytest 的 fixture 体系能够理解 Django 的 ORM、数据库事务、请求客户端等机制,两边能无缝协作。

1.2 pytest-django 的核心职责拆解

这个插件不是简单地在 pytest 里注册几个 fixture,它做的事可以拆成四块:

  • 测试数据库生命周期管理:Django 要求测试跑在一个独立的测试数据库里,pytest-django 负责在会话开始时按 Django 的配置创建测试库,跑完销毁或复用。
  • 数据库访问策略控制:Django 测试用例默认在一个事务里跑,pytest 则是函数级隔离。pytest-django 提供了django_db标记,只有标记了才允许测试访问数据库,这个“显式优于隐式”的设计能防止误操作真库。
  • Django 专属 fixtureclientadmin_clientsettingsdjango_user_model等,把 Django 的测试工具包装成 pytest 风格,用的时候直接声明参数即可。
  • 配置与命令行集成:支持--reuse-db--create-db--keepdb--nomigrations等参数,让你能控制测试库的创建策略,大幅提升本地和 CI 上的测试速度。

一句话总结思路:pytest-django 不是让你抛弃 Django 的测试工具,而是把 Django 的测试工具重新做成符合 pytest 哲学的样子,然后你可以在上面叠加自己的 fixture 层。

2. 环境搭建与基础配置:从安装到第一个用例落地

2.1 安装与依赖说明

安装本身没什么特殊,pip install pytest-django一行命令。但要注意它的依赖关系:它会自动带上 pytest,不会自动带 Django,Django 是你项目里已有的。建议用虚拟环境管理,避免系统 Python 被污染。

装完以后,第一步是在项目根目录(和 manage.py 平级)创建或修改pytest.ini,写入基础配置:

[pytest] DJANGO_SETTINGS_MODULE = myproject.settings python_files = tests.py test_*.py *_tests.py

DJANGO_SETTINGS_MODULE 是这个插件唯一真正必须的配置项,它告诉 pytest-django 使用哪个 Django 配置。如果漏了,你运行 pytest 的时候会直接报错ImproperlyConfigured,提示你设置这个环境变量。除了放在 pytest.ini 里,也可以在命令行指定:

DJANGO_SETTINGS_MODULE=myproject.settings pytest

但写成配置文件里才是正道,不然团队里每个人跑测试都要在心里记着这个环境变量,迟早有人忘记。

2.2 conftest.py 的作用与第一个自定义 fixture

pytest 的 fixture 发现机制依赖conftest.py。这个文件放在哪一层,里面的 fixture 就作用在哪一层。项目根目录的 conftest.py 里的 fixture 全局可见,子应用目录下的 conftest.py 只对该目录下的测试可见。

我一般会在根目录 conftest.py 里放两类东西:一个是跨测试的通用工厂函数,另一个是 pytest-django 原生 fixture 的二次封装。举个例子,如果项目里需要频繁创建带完整关联数据的订单,我可以这样封装:

import pytest from myapp.models import Order, User @pytest.fixture def create_order(db): def _create_order(username="test", amount=100, **kwargs): user = User.objects.create(username=username) return Order.objects.create(user=user, amount=amount, **kwargs) return _create_order def test_order_total(create_order): order = create_order(amount=200) assert order.total == 200

这样封装的好处是测试代码里不再出现Order.objects.create这样冗长的构造逻辑,改字段默认值时也只需要改一处工厂函数。注意这里 fixture 的参数是db,它不是凭空出现的,正是 pytest-django 提供的数据库访问 fixture(后面会详细讲)。

写到这里,环境已经通了,可以跑第一个测试了。但只是“能跑”离“好用”还有距离,接下来的内容才是真正的关键:pytest-django 那些 fixture 和标记,到底各自是什么意思、什么时候用哪个。

3. 核心机制剖析:django_db 标记与数据库访问控制

pytest-django 最容易被忽略但最核心的设计,就是它不会自动让你访问数据库。这看起来是限制,其实是对你的保护。

3.1 数据库默认禁用的理念与 db fixture

在 Django 的 unittest 里,Test 类跑起来就有数据库事务包裹,self.client.get()随便调。但 pytest-django 的默认行为是:你写的测试函数如果没有声明需要数据库,那它就不会去碰数据库,即使碰了也会报错Failed: Access to database in a test is not allowed

为什么这样设计?因为 pytest 里大量测试根本不需要数据库,比如纯函数、算法逻辑、静态代码检查。如果每个测试都初始化数据库,再快的机器也扛不住几百个测试的叠加。

所以 pytest-django 规定了:要访问数据库,必须在测试函数上打标记,或者使用dbfixture:

import pytest from myapp.models import User # 方式一:显式声明 db fixture def test_user_count(db): assert User.objects.count() == 0 # 方式二:用 django_db 标记 @pytest.mark.django_db def test_user_count_2(): assert User.objects.count() == 0

两者效果一样。dbfixture 本质上是内部定义好的一个 fixture,它内部会执行django_db标记的逻辑。个人习惯上,测试函数名里带db参数看得更直观,但用标记可以配合参数化场景。哪种都行,关键是:你要明确知道自己写的测试是否依赖数据库

3.2 django_db 标记的高级参数:transaction、serialized_rollback、reset_sequences

django_db标记不是简单的开关,它还能控制数据库访问的事务行为。在日常项目中,最常见的是这样几个参数。

transaction=True。默认情况下,dbfixture 和django_db标记会把整个测试包在一个外层事务里,测试结束回滚,数据不落库。但有些测试要主动调用transaction.atomic()或者测试并发行为,外层事务会把内层提交阻塞住。这时候需要:

@pytest.mark.django_db(transaction=True) def test_commit_behavior(): with transaction.atomic(): User.objects.create(username="test") # 到这里数据在数据库里是可见的 assert User.objects.filter(username="test").exists()

注意,transaction=True会让测试不再包在外层事务中,函数里的写入会真实落库。所以跑完这种测试,数据库里会残留测试数据,这也是为什么要用独立测试库的原因之一。

serialized_rollback=True。这个参数用于处理“测试修改了数据库序列(比如自增主键)”的场景。默认情况下,外层事务回滚时序列不会回滚,导致测试跑完后自增主键继续递增。如果你的测试断言依赖主键 ID 的精确值,可以加上serialized_rollback=True强制回滚序列:

@pytest.mark.django_db(serialized_rollback=True) def test_pk_value(): user = User.objects.create(username="test") assert user.pk == 1

reset_sequences=True。在 pytest 的参数化测试中,如果每个参数都新建数据,默认情况下主键会持续递增。要重置每个参数的主键从 1 开始,在pytest.mark.parametrizedjango_db联合使用时它很有用:

@pytest.mark.django_db(reset_sequences=True) @pytest.mark.parametrize("name", ["a", "b", "c"]) def test_create_user(name): user = User.objects.create(username=name) assert user.pk == 1

这三个参数在实际项目中不是每个都会用到,transaction 常用在带事务逻辑的测试里,serialized_rollback 和 reset_sequences 则用在断言 ID 或序列状态的场景。

3.3 事务测试中常见的一个大坑

用 pytest-django 默认的dbfixture 时,Django 的信号不会在测试结束后触发“提交”逻辑,因为测试本身就在一个外层事务中,永远不会真正 commit。如果你依赖某个信号的post_save在 commit 后执行(比如给用户发邮件、写日志),在dbfixture 模式下信号可能不会触发或表现和线上不一致。

解决办法:涉及 commit/signal 行为的测试,用@pytest.mark.django_db(transaction=True)跑。我在日志系统测试里就踩过这个坑,排查了半天发现是事务行为差异,改过来就好了。这个点官方文档写得比较隐晦,实际项目里非常容易遇到。

4. 实用 fixture 全解析:client、admin_client、settings、django_user_model

pytest-django 最让人舒服的,是它为 Django 的测试工具做了 pytest 化包装。这一节把最常用的几个 fixture 全部讲透。

4.1 client 与 admin_client:测试 HTTP 请求的正确姿势

Django 的测试客户端django.test.Client是模拟浏览器请求的核心工具,pytest-django 把它做成了clientfixture。用的时候直接在测试函数里声明:

def test_home_page(client): resp = client.get("/") assert resp.status_code == 200

这里有个关键点:client fixture 默认访问数据库吗?答案是要看请求的视图是否碰数据库。如果视图内查了 ORM,而测试函数没声明db,请求会报数据库访问错误。所以我看很多人写的测试是:

def test_home_page(client, db): resp = client.get("/")

这不是冗余,而是明确告诉 pytest-django:这个测试会通过请求间接访问数据库。另一种更地道的方式是用django_db标记。加上就完事。

admin_client是包装好的 admin 后台客户端,它自动创建超级用户并登录。适合测试 Django admin 后台的页面:

def test_admin_page(admin_client): resp = admin_client.get("/admin/") assert resp.status_code == 200

使用 admin_client 的前提是 admin 站点已注册了模型。这个 fixture 内部会用django_user_model创建用户,所以它会访问数据库,测试函数要声明db(或者其内部已声明,实际使用中把 db 加上更稳)。

4.2 settings fixture:临时改配置的利器

测试时经常需要临时改配置,比如改缓存后端、关掉 Celery 任务、调整分页大小。不要手动改 settings 对象再恢复,pytest-django 提供了settingsfixture,它会在测试结束自动恢复原值:

def test_pagination(client, settings, db): settings.PAGE_SIZE = 10 resp = client.get("/users/") assert len(resp.context["users"]) == 10

注意 settings fixture 的生效范围是测试函数内,对于@override_settings能覆盖的场景它都适用。但它不会自动同步到 Django 的 settings 模块缓存里,如果你改的是CACHES这种会建立连接池的配置,测试结束后的清理要留意,之前我遇到过改 CACHES 导致后续测试连接残留的情况,建议能用django.test.override_settings的场景优先用 override_settings,需要动态改的场景再用 settings fixture。

4.3 django_user_model 与 django_assert_num_queries:进阶操作

django_user_model返回当前项目的用户模型类,方便创建用户而不用直接引自定义模型:

def test_normal_user(django_user_model, db): user = django_user_model.objects.create_user(username="t", password="x") assert user.is_authenticated

django_assert_num_queries是性能测试的神器。它作为一个上下文管理器包装代码块,断言执行期间 SQL 查询次数:

def test_user_list_query_count(client, django_assert_num_queries, db): with django_assert_num_queries(2): resp = client.get("/users/") assert resp.status_code == 200

这里的 2 次查询意味着你期望该视图只查一次列表、一次计数(或类似)。如果你的视图有 N+1 查询问题,这个 fixture 能立刻暴露出来。实际操作中,我先用django_assert_num_queries不加参数跑一遍,打印实际 SQL 数量,再分析有没有冗余查询,优化完后把期望值写死。这个 fixture 对排查 ORM 查询次数、验证 select_related/prefetch_related 是否生效,非常有效。

5. 数据库策略深度调优:--create-db、--reuse-db、--keepdb 与迁移处理

这是 pytest-django 提升测试速度最直接的地方。很多团队的测试越跑越慢,很大程度是数据库反复重建导致的,而不是测试本身的问题。

5.1 三种命令的参数对比:什么时候该用哪个

pytest-django 提供几个命令行参数控制测试库的生命周期:

参数作用使用场景
--create-db每次运行都重新创建测试数据库默认行为,适合验证环境一致性,但最慢
--reuse-db复用上次创建的测试数据库,若结构不变则不重建本地开发推荐,速度提升明显
--keepdb测试结束后保留测试数据库(默认会销毁),下次运行直接复用配合--reuse-db,CI 里可加速

默认情况下,pytest-django 每次运行都会销毁旧的测试库、重新创建,这个流程在模型多、迁移文件多的大项目里可能要花几十秒甚至几分钟。--reuse-db的价值在本地开发时尤其明显:我没有关掉过测试库,连续跑测试,第一次初始化花 30 秒,后面每次跑只要 3 秒。

注意:--reuse-db适合模型结构没有变化的场景。如果改了模型字段或迁移文件,pytest-django 会检测到测试库结构不匹配而自动重建,所以不用担心用了这个参数就吃旧数据。它的检测逻辑是基于 Django 的 migration 执行记录,不是简单的“库存在就不管”。

5.2 禁用迁移提升速度:--nomigrations 与 nomigrations 标记

另一个高度推荐的加速手段是禁用迁移。Django 每个测试库创建时默认会跑一遍所有迁移文件,如果项目有几百个迁移,这一步占用了大量时间。--nomigrations参数让 pytest-django 直接用migrate --run-syncdb的方式建表,只建当前模型对应的表,不执行历史迁移文件。

pytest --nomigrations

实测下来,一个有约 200 个迁移文件的项目,开启--nomigrations后测试库初始化时间从 50 秒降到 5 秒左右,效果立竿见影。

代价是:迁移文件中通过RunPython做的数据迁移不会执行。比如某个迁移把旧数据格式转换成了新格式,而你的测试数据创建逻辑没有覆盖这个转换,测试环境和本地开发环境就会出现差异。我的建议是:本地开发开启--nomigrations,CI 上跑一次完整的迁移流程,两者互补。

5.3 并发跑测试的注意事项

用 pytest-xdist 配合 pytest-django 并行跑测试时,多个 worker 会共享同一个测试数据库。dbfixture 默认每个测试一个事务,并行时不会有数据冲突,因为事务是隔离的。但使用transaction=True的测试在并发时可能会出现写冲突,建议把这类测试挑出来单独跑,或者给它们标记为串行:

pytest -n 4 --dist=loadgroup

先规划好哪些测试可以并行,哪些必须串行,再启动并发。我见过有团队一上来就-n auto,结果一堆事务型测试互相干扰,排查了半天才意识到是并行导致的。

6. 进阶实战:异步测试、迁移本地化与 CI 集成

6.1 异步 Django 视图与 pytest-django 的异步支持

Django 3.1 之后支持异步视图,pytest-django 也随之增加了异步测试支持。如果你写了async def test_xxx的测试函数,需要用到pytest.mark.asyncio配合async_dbdjango_db_async来处理数据库:

import pytest @pytest.mark.asyncio @pytest.mark.django_db() async def test_async_view(async_client, db): resp = await async_client.get("/async-view/") assert resp.status_code == 200

异步测试里不能再直接用同步的dbfixture,要用async_dbdjango_db标记配合pytest.mark.asyncio。异步客户端async_client是 pytest-django 提供的,封装了 Django 异步测试客户端。

这个领域的坑比同步多得多,最典型的是:异步测试里的 ORM 查询必须用sync_to_async包裹或使用agetacreate这类异步 API。写异步测试前先确认模型 API 是兼容异步的,Django 官网说async安全不是绝对的,像queryset.filter()这种惰性查询依然要在sync_to_async里调用。

6.2 按需创建测试库:databases 标签与 multi-db 支持

多数据库项目(主从、分库)里,pytest-django 默认只创建DATABASES配置里的 default 库。要测试其他库,需要在django_db标记里指定:

@pytest.mark.django_db(databases=["default", "replica"]) def test_multi_db(): # 同时访问两个库 ...

如果不指定,测试访问非 default 库时会报数据库未配置错误。多数据库场景还容易遇到事务和序列的问题,建议明确画出每个测试到底使用哪些库,别让一个测试默默访问所有库,速度慢且容易出错。

6.3 CI 集成:GitLab CI 与 GitHub Actions 的配置要点

把 pytest-django 集成进 CI,核心是保证每次跑测试的环境一致,同时尽量复用 DB 以提速。

GitLab CI 的示例配置(.gitlab-ci.yml片段):

test: stage: test image: python:3.11-slim services: - postgres:15 variables: POSTGRES_DB: test_db POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres DJANGO_SETTINGS_MODULE: myproject.settings before_script: - pip install -r requirements.txt - apt-get update && apt-get install -y libpq-dev script: - pytest --create-db --nomigrations -n 4

注意 CI 上每次都是从零开始,--create-db可以保证结构一致性。如果服务里每次都挂一个新的 Postgres,那--reuse-db意义不大,直接用--create-db

GitHub Actions 的思路类似:用一个 PostgreSQL 服务容器,然后跑 pytest。关键是DJANGO_SETTINGS_MODULE必须在 CI 环境变量里设好,否则 pytest-django 同样会报 ImproperlyConfigured。

6.4 局部禁用迁移的标记:需要的时候再细调

全局--nomigrations太一刀切,pytest-django 提供了标记级别的nomigrations

@pytest.mark.nomigrations def test_something(): ...

这个标记让 pytest-django 只对当前测试的数据库创建时使用--run-syncdb,而其他测试仍然走完整迁移。适合只验证模型层逻辑而不用管历史迁移数据的测试。和全局参数一样,它最大的作用是加速,但代价是缺少数据迁移的历史状态。在复杂项目里,把这些标记用在纯新增模型的领域逻辑测试上是安全的。

7. 常见问题与排查技巧实录

7.1 报错 ImproperlyConfigured:DJANGO_SETTINGS_MODULE 没有设置

这是最常遇到的错误。pytest-django 找不到 Django 配置就直接抛异常。解决思路依次排查:

  • pytest.ini 里是否写了DJANGO_SETTINGS_MODULE
  • conftest.py 是否被 pytest 正确加载(可以在 conftest.py 里 print 一下验证)
  • 环境变量是否被覆盖(有些 IDE 的测试 runner 会注入自己的 DJANGO_SETTINGS_MODULE)

一个容易忽略的细节:conftest.py 里如果 import 了 Django 模型模块,而 DJANGO_SETTINGS_MODULE 还没有设置,可能在导入阶段就报错。最好在 pytest.ini 配置好后先跑一个最简单的不依赖 Django 的测试,确认 pytest 本身正常,再叠加 Django 相关测试。

7.2 数据库访问被禁止:Access to database in a test is not allowed

这个报错几乎每个 pytest-django 用户都遇到过。原因就是测试函数访问了数据库但没有声明dbdjango_db。排查方法:

  • 查看调用链里哪一步触发了 ORM 查询
  • 在测试函数参数里加上db,或用@pytest.mark.django_db
  • 如果是 fixture 内部访问了数据库,确保该 fixture 返回前声明了db或使用django_db

注意,如果在 conftest.py 里定义了一个内部访问数据库的 fixture,而该 fixture 声明为sessionmodule作用域,dbfixture 默认是函数作用域,此时会报作用域冲突。解决办法是把这个 fixture 也改成函数级作用域,或者写一个 session 级且内部自行创建事务的 fixture。这是我自己踩过的一个比较隐蔽的坑。

7.3 测试数据库被锁定:could not connect to database

Postgres 下有时会报could not connect to database "test_db" ... database is being accessed by other users,多半是上次测试没清理干净进程。最直接的急救办法:

pytest --create-db

强制重建测试库。如果还是报错,手动进 Postgres 删除残留测试库:

DROP DATABASE IF EXISTS test_db;

在本地开发中,这个现象经常出现在 IDE 的调试进程把持了数据库连接时。关掉所有占用连接的进程再跑就好。

7.4 测试数据残留:为什么我跑完测试,开发库里多了数据

这通常是因为你用了transaction=True的标记,以及测试里显式调用了create之后,外层没有回滚。排查关键:

  • 确认测试库配置用的是独立的test_前缀或独立库名,不要和开发库共用一个
  • 谨慎使用transaction=True,无必要就不要用
  • 断言数据变化时,优先用assert而不是直接打库查看

实践中把 DATABASES 配置里的TEST字典显式写清楚,比如:

DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "myproject_dev", "TEST": {"NAME": "myproject_test"}, } }

这样即使误操作也不会污染开发库。

7.5 参数化与数据库组合时的性能陷阱

@pytest.mark.parametrizedbfixture 配合时,pytest 会在每个参数上都跑一遍测试,每个参数都会启动一个事务,数据准备成本会成倍增长。对于需要大量数据的测试,建议把数据准备放到 session 级或 module 级 fixture 中,函数级只做轻量操作。示例:

import pytest @pytest.fixture(scope="module") def django_db_setup(django_db_setup, django_db_blocker): with django_db_blocker.unblock(): User.objects.bulk_create([User(username=f"u{i}") for i in range(1000)]) @pytest.mark.django_db @pytest.mark.parametrize("name", ["u1", "u2", "u3"]) def test_user_exists(name): assert User.objects.filter(username=name).exists()

这种模式下数据只准备一次,参数化只做查询断言,速度会快很多。

8. 几个值得记住的小技巧与扩展建议

在项目里用了这么久,最后分享几个锦上添花的小技巧。

第一,把 pytest-django 的 fixture 分层组织。我在项目里通常这样分三层:

  • 基础层:pytest-django 自带的dbclientsettings
  • 业务层:conftest.py 里定义的create_usercreate_order等工厂 fixture
  • 场景层:组合多个业务 fixture 封装成“带权限的用户”“带订单的店铺”等复合 fixture

这样测试代码里几乎不出现 ORM 创建对象的代码,绝大多数测试能压缩到 5 行以内。

第二,用 pytest 的--reuse-db--nomigrations结合,本地测试体验会好非常多。我通常直接写进 pytest.ini:

[pytest] addopts = --reuse-db --nomigrations DJANGO_SETTINGS_MODULE = myproject.settings

这样每个开发者 clone 项目后第一次跑测试会自动建库,之后每次都在秒级启动。CI 上则单独用--create-db保证干净环境。

第三,对测试数据库做合理命名和定期清理。Postgres 数据库数量多了以后,垃圾测试库会占磁盘。可以写个小脚本定期删掉test_*前缀的库,配合 CI 和本地开发使用。

第四,结合 coverage 和 pytest-cov 统计测试覆盖度。pytest-django 和 pytest-cov 兼容得很好:

pytest --cov=myapp --cov-report=term-missing

这能帮你快速定位哪些模块测试覆盖不足,值得加入 CI 的门禁指标。

pytest-django 看起来只是个插件,但它决定了 Django 项目的测试体验上限。把它的 fixture 体系、数据库策略、标记逻辑理解透,你会发现写测试这件事本身变得轻松很多——测试不再是一堆重复代码的堆叠,而是一套可复用、可维护、能快速反馈迭代质量的工具链。希望这篇内容对你的 Django 测试之旅有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询