我接过最痛苦的一个Django项目测试要跑一个多小时。CI排队排到天荒地老每次改完代码等结果就像开盲盒——你永远不知道是业务挂了还是测试挂了更可怕的是有时候两者一起挂。那时候项目里全是基于unittest的TestCasesetUp里塞了一大堆数据初始化逻辑继承链条绕得人头大想单独跑一个用例还得先忍受全量迁移和建库。有一天我实在忍不了花了一下午研究pytest怎么接入Django把第一批TestCase重构成了pytest风格等结果从一整个晚上变成了几分钟。这个故事里最关键的那个工具就是pytest-django。这篇博文写给谁给那些正在用Django自带的TestCase被测试速度、维护成本和用例组织折磨的人给那些已经装了pytest但不知道怎么处理Django的settings和数据库的人也给那些刚入门、想直接走对路的Django开发者。读完你不仅能跑通pytest-django还能理解它背后那一套数据库管理逻辑知道怎么设计测试数据、怎么提速、怎么从TestCase平滑迁移。这些内容不是官网文档的复述是我在多个真实项目里摸爬滚打总结出来的实操经验。1. 为什么我从Django TestCase迁移到了pytest-django1.1 原生TestCase最大的问题不是慢是难以复用Django自带的django.test.TestCase在功能上并不弱它封装了数据库事务、提供了一个测试Client开箱即用。但真正写多了之后你会发现它的设计理念还停留在面向类的测试组织阶段。我见过太多项目里出现这种场景一个基础的BaseTestCase下面挂了十几个子类子类之间又要共享一部分setUp逻辑于是又抽出来IntermediateTestCase三层继承起步。改一个公共字段连带七八个测试类一起挂排查起来血压直接拉满。更致命的是setUp/tearDown的复用方式。你想在多个测试里复用同一套登录逻辑唯一的办法就是写个mixin或者放到基类里翻来覆去还是继承那一套。而pytest的fixture机制完全解决了这个问题fixture是按名字依赖注入的一个测试需要什么就声明什么不需要的东西绝不强行加载。fixture之间还可以互相依赖、自由组合作用域还能控制在不同层级。这种感觉就像装修房子以前TestCase是把整栋楼的水管电线全埋好你只是其中一间房pytest是在你需要的房间里单独布线用哪个接哪个。1.2 断言体验和插件生态决定了我回不去了原生assertEqual、assertTrue这些方法用起来中规中矩但失败时的报错信息实在不够直观。尤其是对比两个字典或者两个列表时它只会告诉你两个对象不相等至于哪里不等你得自己肉眼去比对。pytest对Python原生assert做了重写失败时直接把两边的实际值展开在报错里省去了大量打印-排查-修改-重跑的无聊循环。更香的还在后面。pytest的插件体系太成熟了只要组合得当能省下太多重复劳动需求原生Testcasepytest-django 插件代码覆盖率要自己集成coverage库额外配置装一个pytest-cov命令行加--covmyapp并发提速官方几乎没有好用的方案pytest-xdist一个-n auto搞定用例参数化subTest()写起来啰嗦pytest.mark.parametrize清晰优雅mockpatch装饰器层层嵌套pytest-mock的mockerfixture自动清理失败重跑自己写循环或等CI重试pytest-rerunfailures指定次数即可这些都是真实生产力。尤其参数化一个登录接口需要测十几种非法输入原生写法要写十来个测试方法pytest一个函数加上参数列表就完事报错还能精确到是哪组参数挂了。2. 环境搭建与最小配置先让第一个测试跑起来2.1 安装和pytest.ini配置安装没有任何悬念两条命令pip install pytest pytest-django然后你需要一个pytest.ini这是pytest的配置文件放在项目根目录[pytest] DJANGO_SETTINGS_MODULE config.settings python_files tests.py test_*.py *_tests.py解释一下这两行。DJANGO_SETTINGS_MODULE告诉pytest-django你的Django设置文件在哪这是最重要的配置项没有它pytest跑起来根本找不到Django环境。python_files是pytest收集测试文件的规则Django项目里测试文件命名只要包含test_或_test都会被自动识别。为什么不建议用setup.cfg或者pyproject.toml去写pytest配置因为Django项目里manage.py默认也是读DJANGO_SETTINGS_MODULE环境变量大家习惯了在命令行里设置环境变量的方式如果你把配置散落在pyproject.toml里不同人、不同CI的配置容易产生分歧pytest.ini作为默认优先级最高的配置文件反而最不容易出错。2.2 多套settings怎么处理大多数正式项目至少有两套settings开发环境和生产环境。测试一般不应该直接用生产配置推荐单独建一个config/settings_test.py然后通过环境变量切换DJANGO_SETTINGS_MODULEconfig.settings_test pytest如果嫌每次敲环境变量麻烦可以在项目根目录的conftest.py里写一行默认值import os os.environ.setdefault(DJANGO_SETTINGS_MODULE, config.settings_test)conftest.py是pytest的插件入口文件放在根目录时对整个项目生效。注意用setdefault而不是直接赋值这样命令行里显式指定的环境变量优先级更高。2.3 第一个测试用例跑通一个最简单的用例验证模型和视图都正常import pytest from django.urls import reverse from myapp.models import Article pytest.mark.django_db def test_article_list(client): Article.objects.create(titleHello, contentpytest-django) resp client.get(reverse(article-list)) assert resp.status_code 200 assert Hello in resp.content.decode()这段代码暴露了两个最关键的概念。第一pytest.mark.django_db标记允许测试访问数据库没有这个标记直接ORM操作会抛DatabaseAccessNotAllowed错误。第二client是pytest-django提供的内置fixture就是Django测试客户端的一个实例直接用就行。后面我会详细拆这两个机制。3. 测试数据库的管理逻辑pytest-django怎么保证测试隔离3.1 数据库是懒创建的不会空跑全套建库流程pytest-django对数据库的管理策略跟Django原生TestCase有很大区别。原生TestCase在跑每个测试类之前都会做一次完整的测试库创建、迁移、销毁循环测试类多了时间全耗在这一步。pytest-django做了个关键优化只有真有几个测试用到了数据库它才会去创建测试数据库。如果一个测试文件里全是纯函数测试或者mock测试完全不会碰数据库那它跑起来飞快。这个懒创建逻辑也体现在命令行参数上--reuse-db第一次创建完测试数据库后保留它后续跑测试直接复用省掉建库和迁移时间。--create-db强制删掉旧测试库重新建适合改了模型迁移之后跑一次全量验证。这两个参数我基本每天都用。本地开发时一直挂着--reuse-db只有改了模型字段才手动加一次--create-db。CI里则每次全新创建保证环境干净。3.2django_db标记到底在做什么Django测试框架经常被诟病的一点是测试代码里隐藏的数据库操作太多一个测试跑起来你根本不知道它背后偷偷查了多少表。pytest-django用django_db标记把这个问题显式化了。pytest.mark.django_db def test_model_operation(): Article.objects.create(titledatabase) assert Article.objects.count() 1不加这个标记测试一碰ORM就会报错。它的设计意图是逼你明确这个测试依赖数据库让测试的依赖关系一目了然。有人觉得这很麻烦但从工程角度看这反而防止了测试之间隐式的数据耦合。一个测试该不该用数据库你从标记上就能判断而不是等它跑挂了才恍然大悟。还有一个容易忽略的细节client这个fixture本身已经自动处理了数据库访问权限因为发起HTTP请求必然要查用户、会话数据所以官方为client、admin_client这些内置fixture都隐含了数据库访问能力。你直接用client不需要额外加django_db标记但如果测试里还手动查了ORM那还得显式标记。3.3 每个测试跑在存档点里事务回滚是隔离的根基pytest-django默认的数据库隔离逻辑可以这么理解每次测试开始前在数据库上开启一个事务事务里套一个savepoint测试结束后直接回滚到这个savepoint。这样测试里创建的、修改的、删除的数据全部被撤销下一个测试启动时数据库跟上一个测试结束前一模一样。这意味着什么你不需要在每个测试末尾手动清理数据不需要在tearDown里写Article.objects.all().delete()数据污染问题被框架层面解决掉了。但有一个例外需要特别注意当你给django_db标记传了transactionTrue时pytest.mark.django_db(transactionTrue) def test_needs_real_commit(): Article.objects.create(titlewill stay)这种测试跑在一个真实事务里数据不会自动回滚跑完它会留在测试数据库里。什么场景需要它比如测试里要验证post_save信号收到的数据、测试某个代码路径依赖事务提交后的行为、或者让多个线程/进程感知到数据变更。能用默认事务模式解决的就别开这个transaction模式的测试天然比普通模式慢一个量级这是实实在在的性能成本。4. client、admin_client和django_user_model内置fixture的正确用法4.1 client fixture的多种操作方式client是对django.test.Client的封装API跟原生一致。最简单的GETdef test_home_page(client): resp client.get(/) assert resp.status_code 200更推荐用reverse而不是硬编码URL路径。Django项目里的URL随时会改硬编码等于埋雷from django.urls import reverse def test_article_detail(client, article): resp client.get(reverse(article-detail, args[article.pk])) assert resp.context[article] article涉及登录状态的测试可以用client.force_login跳过密码验证直接以某个用户身份发起请求。这是测试里最高效的登录方式不需要走完整的表单登录流程def test_private_view(client, user): client.force_login(user) resp client.get(reverse(private-dashboard)) assert resp.status_code 2004.2 admin_client想在测试里直接测Django后台如果只是验证后台能正常加载不用手动造admin用户再登录内置的admin_client直接给你返回一个已经登录了超级管理员账号的客户端def test_admin_article_changelist(admin_client): resp admin_client.get(/admin/myapp/article/) assert resp.status_code 200注意这个fixture默认创建的是一个超级管理员用于测后台足够。但如果你想测特定权限、特定角色的行为还是要自己组装用户。这时候就轮到django_user_model出场def test_user_with_perm(client, django_user_model): user django_user_model.objects.create_user(usernamealice, passwordtest123) # 给用户加权限、组再来测权限控制逻辑django_user_model返回的是项目当前配置的用户模型类兼容自定义用户模型这是它比直接from django.contrib.auth.models import User更安全的原因。如果哪天项目换成了自定义用户表测试代码不用跟着改。4.3 fixture依赖注入自定义fixture的正确打开方式fixture最爽的地方是能互相组合。比如创建一个已登录的普通用户pytest.fixture def user(db, django_user_model): return django_user_model.objects.create_user(usernamebob, passwordsecret) pytest.fixture def client_with_user(client, user): client.force_login(user) return client def test_profile(client_with_user): resp client_with_user.get(reverse(profile)) assert resp.status_code 200这里client_with_user依赖client和user两个fixturepytest自动解析依赖关系并依次执行。user这个fixture参数里带db就是显式声明它要访问数据库。看到这种写法整个测试的依赖链非常清晰我需要一个已登录用户那么这个用户怎么来的看fixture定义就行。5. 测试数据构造的最优解fixture与工厂模式怎么搭配5.1 直接在fixture里ORM建数据的问题刚开始用pytest-django的人最常见的做法是在fixture里直接建数据pytest.fixture def article(user): return Article.objects.create( authoruser, titletest article, contentsome content, statuspublished, category_id1, tags[python, django], )一个两个fixture还好等模型字段多起来就完全失控。比如项目里Article模型有20个字段你每个测试只需要改其中一两个字段但fixture里必须把其他18个字段都补上否则创建失败。更头疼的是模型字段一变所有相关fixture都要跟着改改漏一个就挂一片。5.2 factory_boy解决默认字段问题factory_boy是专门解决这个痛点的库安装一条命令pip install factory_boy定义工厂的方式其实非常直观import factory from django.contrib.auth import get_user_model from myapp.models import Article class UserFactory(factory.django.DjangoModelFactory): class Meta: model get_user_model() username factory.Sequence(lambda n: fuser_{n}) email factory.LazyAttribute(lambda obj: f{obj.username}example.com) class ArticleFactory(factory.django.DjangoModelFactory): class Meta: model Article author factory.SubFactory(UserFactory) title factory.Sequence(lambda n: fArticle {n}) content factory.Faker(paragraph) status publishedSequence保证用户名唯一LazyAttribute根据已有字段生成关联值SubFactory自动创建关联对象Faker用假数据生成器填充文本。每个字段都有合理默认值测试里想改哪个就覆盖哪个pytest.fixture def article(db): return ArticleFactory() def test_article_detail(client, article): resp client.get(reverse(article-detail, args[article.pk])) assert resp.context[article].title article.title def test_draft_article(client, db): draft ArticleFactory(statusdraft) # 验证草稿不应该出现在公开列表里这样拆完之后每个测试只关心自己关心的字段其他字段交给工厂兜底。模型加字段了改一个工厂定义就行测试代码几乎不用动。注意ArticleFactory()直接创建需要数据库访问权限所以fixture里要带db参数或者给fixture加pytest.mark.django_db标记。5.3 别把fixture写成测试数据仓库fixture用得太滥同样会带来维护噩梦。有些人喜欢把所有可能的业务场景都做成fixturepublished_article、draft_article、deleted_article、article_with_comments……最后生成了一个无比庞大的fixture集合测试里根本分不清该用哪个。我的建议是fixture只做环境准备数据构造尽量交给工厂。比如你要测文章列表页只显示已发布文章那么环境准备是存在已发布和未发布的文章具体数据长什么样交给工厂def test_published_articles_only_in_list(client, db): ArticleFactory(statuspublished, titlevisible) ArticleFactory(statusdraft, titlehidden) resp client.get(reverse(article-list)) content resp.content.decode() assert visible in content assert hidden not in content这样fixture数量能控制住测试意图也更明确。每次看到那种几十个fixture堆叠的conftest.py我都会忍不住想重构。6. 提速与稳定并发、mock与真实依赖解耦6.1 数据库引擎怎么选SQLite并不总是最优解很多教程喜欢让读者用SQLite内存库跑测试因为--reuse-db配合内存库速度极快。但这里藏着一个大坑SQLite和MySQL、PostgreSQL在行为上有不少差异比如JSONField的查询语法、并发写事务的处理方式、全文索引的支持程度。一个在SQLite上跑得全绿的测试一到生产用的PostgreSQL上可能直接报错。我的建议是本地开发为了速度可以用SQLite但CI里必须用和生产一致的数据库引擎。如果项目本来就用PostgreSQL测试库也配PostgreSQL最多在django.conf.settings里分开配置测试库的连接信息。与其等线上出问题不如一开始就把测试环境贴近真实。6.2 pytest-xdist并发时每个worker要自己的数据库并行是pytest提速的常规手段pip install pytest-xdist pytest -n 4-n 4表示启动4个并行worker。pytest-django对xdist有原生支持每个worker会自动创建独立的测试数据库命名规则类似test_myapp_gw0、test_myapp_gw1。这是必须的否则两个worker同时写同一个测试库事务回滚互相干扰测出来的结果根本没法看。这里有个现实问题数据库账号必须有创建数据库的权限。很多公司的数据库账号只给了读写某几个库的权限跑并行测试时会报permission denied to create database。解决方式是运维侧给账号加上CREATEDB权限或者在CI里专门配置一个有建库权限的测试库账号。6.3 测试绝不能依赖真实外部服务我一直强调一个原则单测和集测的过程必须确定性可控一旦测试依赖外部网络服务失败就不是代码问题而是玄学问题。处理外部HTTP调用最顺手的做法是pytest-mockdef test_fetch_weather(mocker): mocked_get mocker.patch(myapp.services.requests.get) mocked_get.return_value.status_code 200 mocked_get.return_value.json.return_value {temperature: 22} result fetch_weather(Beijing) assert result[temperature] 22 mocked_get.assert_called_once_with( https://api.example.com/weather?cityBeijing )要点是mock的路径必须指向被测试代码里实际调用requests.get的位置。如果代码里写的是import requests然后requests.get(...)那就mockmyapp.services.requests.get如果代码写的是from requests import get那就mockmyapp.services.get。搞错路径mock了也白mock测试照样发真实请求。如果项目里集成了大量第三方API更推荐引入responses或requests-mock这类库直接在更底层拦截HTTP请求比手动patch每个函数更省事。团队里统一一种mock方案别今天用mocker明天用responses不然review代码时很分裂。7. 从unittest TestCase迁移时最容易踩的坑7.1 渐进式替换别搞一刀切我见过有人立Flag要在一个迭代里把所有TestCase重构成pytest最后无一例外全翻车。TestCase和pytest可以共存pytest-django能识别Django的TestCase类你完全可以混合着跑。推荐的迁移路线是把最慢、最常失败、最依赖复杂setUp的那批测试先迁出去其他暂时不动。等pytest的好处被团队感受到后剩下的迁移就是顺水推舟。7.2 迁移时那些原生写法直接搬过来就挂的瞬间几个高频坑位提前说清楚能帮你少走很多弯路setUp里的数据初始化改成fixture不要试图在pytest测试里继续写self.setUp()迁移的时候把一个类里的setUp逻辑拆成按需加载的几个fixture这是核心工作量。self.client变成clientfixture函数参数加上client就行注意不能直接在测试类里用self.client。self.assertRaises改成pytest.raisesimport pytest def test_duplicate_article_raises(db): ArticleFactory(titlesame) with pytest.raises(IntegrityError): ArticleFactory(titlesame)self.assertEqual改成原生assertpytest会对断言做重写报错信息比TestCase还详细。依赖self.assertNumQueries的用例pytest-django提供了django_assert_num_queriesfixture用法类似但更灵活。别忘pytest.mark.django_db迁移初期最常见的报错就是某个fixture里操作了ORM但没声明数据库访问报错信息极其明显看到Database access not allowed就回去补标记。7.3 测试代码的维护值得当成核心资产迁移不是终点。等项目完全切到pytest-django之后维护逻辑同样重要。尽量把公共fixture放在conftest.py里但别把所有fixture都堆在根目录那个文件里否则每个测试收集时都要多花时间加载无关fixture。我的习惯是根目录的conftest.py只放真正全局通用的fixture比如user、client各业务模块的fixture放在对应app目录下的conftest.py里。还有一点是控制测试粒度。pytest-django让人很容易写出一条测试里测了一整套业务流程的用例比如创建用户、发文章、评论、再验证聚合数据。这种用例一旦失败定位成本极高。尽量让单个测试聚焦一个行为一个测试只验证一件事数据准备交给工厂环境准备交给fixture断言只写最关键的那几个。我个人在实际操作中还有一个体会测试代码跑得快不快很多时候取决于你愿不愿意花时间优化。--reuse-db、xdist并行、合理的mock、按需加载fixture这些技巧叠加起来一个曾经跑一小时的Django项目测试集完全能压缩到十分钟以内。省下来的时间做点什么不好呢。
