ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

pytest-django深度使用手册:核心机制与数据库策略实战

pytest-django深度使用手册:核心机制与数据库策略实战 先聊点实在的。Django项目写测试早期大家基本靠unittest和setUpTestData撑场面后来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(usernametest) 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(usernametest) 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 专属 fixtureclient、admin_client、settings、django_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不会自动带 DjangoDjango 是你项目里已有的。建议用虚拟环境管理避免系统 Python 被污染。装完以后第一步是在项目根目录和 manage.py 平级创建或修改pytest.ini写入基础配置[pytest] DJANGO_SETTINGS_MODULE myproject.settings python_files tests.py test_*.py *_tests.pyDJANGO_SETTINGS_MODULE 是这个插件唯一真正必须的配置项它告诉 pytest-django 使用哪个 Django 配置。如果漏了你运行 pytest 的时候会直接报错ImproperlyConfigured提示你设置这个环境变量。除了放在 pytest.ini 里也可以在命令行指定DJANGO_SETTINGS_MODULEmyproject.settings pytest但写成配置文件里才是正道不然团队里每个人跑测试都要在心里记着这个环境变量迟早有人忘记。2.2 conftest.py 的作用与第一个自定义 fixturepytest 的 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(usernametest, amount100, **kwargs): user User.objects.create(usernameusername) return Order.objects.create(useruser, amountamount, **kwargs) return _create_order def test_order_total(create_order): order create_order(amount200) 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 规定了要访问数据库必须在测试函数上打标记或者使用dbfixtureimport 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_sequencesdjango_db标记不是简单的开关它还能控制数据库访问的事务行为。在日常项目中最常见的是这样几个参数。transactionTrue。默认情况下dbfixture 和django_db标记会把整个测试包在一个外层事务里测试结束回滚数据不落库。但有些测试要主动调用transaction.atomic()或者测试并发行为外层事务会把内层提交阻塞住。这时候需要pytest.mark.django_db(transactionTrue) def test_commit_behavior(): with transaction.atomic(): User.objects.create(usernametest) # 到这里数据在数据库里是可见的 assert User.objects.filter(usernametest).exists()注意transactionTrue会让测试不再包在外层事务中函数里的写入会真实落库。所以跑完这种测试数据库里会残留测试数据这也是为什么要用独立测试库的原因之一。serialized_rollbackTrue。这个参数用于处理“测试修改了数据库序列比如自增主键”的场景。默认情况下外层事务回滚时序列不会回滚导致测试跑完后自增主键继续递增。如果你的测试断言依赖主键 ID 的精确值可以加上serialized_rollbackTrue强制回滚序列pytest.mark.django_db(serialized_rollbackTrue) def test_pk_value(): user User.objects.create(usernametest) assert user.pk 1reset_sequencesTrue。在 pytest 的参数化测试中如果每个参数都新建数据默认情况下主键会持续递增。要重置每个参数的主键从 1 开始在pytest.mark.parametrize和django_db联合使用时它很有用pytest.mark.django_db(reset_sequencesTrue) pytest.mark.parametrize(name, [a, b, c]) def test_create_user(name): user User.objects.create(usernamename) 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(transactionTrue)跑。我在日志系统测试里就踩过这个坑排查了半天发现是事务行为差异改过来就好了。这个点官方文档写得比较隐晦实际项目里非常容易遇到。4. 实用 fixture 全解析client、admin_client、settings、django_user_modelpytest-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(usernamet, passwordx) assert user.is_authenticateddjango_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 次查询意味着你期望该视图只查一次列表、一次计数或类似。如果你的视图有 N1 查询问题这个 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-dbCI 里可加速默认情况下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做的数据迁移不会执行。比如某个迁移把旧数据格式转换成了新格式而你的测试数据创建逻辑没有覆盖这个转换测试环境和本地开发环境就会出现差异。我的建议是本地开发开启--nomigrationsCI 上跑一次完整的迁移流程两者互补。5.3 并发跑测试的注意事项用 pytest-xdist 配合 pytest-django 并行跑测试时多个 worker 会共享同一个测试数据库。dbfixture 默认每个测试一个事务并行时不会有数据冲突因为事务是隔离的。但使用transactionTrue的测试在并发时可能会出现写冲突建议把这类测试挑出来单独跑或者给它们标记为串行pytest -n 4 --distloadgroup先规划好哪些测试可以并行哪些必须串行再启动并发。我见过有团队一上来就-n auto结果一堆事务型测试互相干扰排查了半天才意识到是并行导致的。6. 进阶实战异步测试、迁移本地化与 CI 集成6.1 异步 Django 视图与 pytest-django 的异步支持Django 3.1 之后支持异步视图pytest-django 也随之增加了异步测试支持。如果你写了async def test_xxx的测试函数需要用到pytest.mark.asyncio配合async_db或django_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_db或django_db标记配合pytest.mark.asyncio。异步客户端async_client是 pytest-django 提供的封装了 Django 异步测试客户端。这个领域的坑比同步多得多最典型的是异步测试里的 ORM 查询必须用sync_to_async包裹或使用aget、acreate这类异步 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 提供了标记级别的nomigrationspytest.mark.nomigrations def test_something(): ...这个标记让 pytest-django 只对当前测试的数据库创建时使用--run-syncdb而其他测试仍然走完整迁移。适合只验证模型层逻辑而不用管历史迁移数据的测试。和全局参数一样它最大的作用是加速但代价是缺少数据迁移的历史状态。在复杂项目里把这些标记用在纯新增模型的领域逻辑测试上是安全的。7. 常见问题与排查技巧实录7.1 报错 ImproperlyConfiguredDJANGO_SETTINGS_MODULE 没有设置这是最常遇到的错误。pytest-django 找不到 Django 配置就直接抛异常。解决思路依次排查pytest.ini 里是否写了DJANGO_SETTINGS_MODULEconftest.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 用户都遇到过。原因就是测试函数访问了数据库但没有声明db或django_db。排查方法查看调用链里哪一步触发了 ORM 查询在测试函数参数里加上db或用pytest.mark.django_db如果是 fixture 内部访问了数据库确保该 fixture 返回前声明了db或使用django_db注意如果在 conftest.py 里定义了一个内部访问数据库的 fixture而该 fixture 声明为session或module作用域dbfixture 默认是函数作用域此时会报作用域冲突。解决办法是把这个 fixture 也改成函数级作用域或者写一个 session 级且内部自行创建事务的 fixture。这是我自己踩过的一个比较隐蔽的坑。7.3 测试数据库被锁定could not connect to databasePostgres 下有时会报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 测试数据残留为什么我跑完测试开发库里多了数据这通常是因为你用了transactionTrue的标记以及测试里显式调用了create之后外层没有回滚。排查关键确认测试库配置用的是独立的test_前缀或独立库名不要和开发库共用一个谨慎使用transactionTrue无必要就不要用断言数据变化时优先用assert而不是直接打库查看实践中把 DATABASES 配置里的TEST字典显式写清楚比如DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: myproject_dev, TEST: {NAME: myproject_test}, } }这样即使误操作也不会污染开发库。7.5 参数化与数据库组合时的性能陷阱pytest.mark.parametrize和dbfixture 配合时pytest 会在每个参数上都跑一遍测试每个参数都会启动一个事务数据准备成本会成倍增长。对于需要大量数据的测试建议把数据准备放到 session 级或 module 级 fixture 中函数级只做轻量操作。示例import pytest pytest.fixture(scopemodule) def django_db_setup(django_db_setup, django_db_blocker): with django_db_blocker.unblock(): User.objects.bulk_create([User(usernamefu{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(usernamename).exists()这种模式下数据只准备一次参数化只做查询断言速度会快很多。8. 几个值得记住的小技巧与扩展建议在项目里用了这么久最后分享几个锦上添花的小技巧。第一把 pytest-django 的 fixture 分层组织。我在项目里通常这样分三层基础层pytest-django 自带的db、client、settings业务层conftest.py 里定义的create_user、create_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 --covmyapp --cov-reportterm-missing这能帮你快速定位哪些模块测试覆盖不足值得加入 CI 的门禁指标。pytest-django 看起来只是个插件但它决定了 Django 项目的测试体验上限。把它的 fixture 体系、数据库策略、标记逻辑理解透你会发现写测试这件事本身变得轻松很多——测试不再是一堆重复代码的堆叠而是一套可复用、可维护、能快速反馈迭代质量的工具链。希望这篇内容对你的 Django 测试之旅有帮助。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表