pytest-django接入CI/CD全流程实战指南 两年多的时间里我前后经手了三四个Django项目每个项目走到后期都会遇到同一个尴尬本地跑测试一片绿合并代码进主干就翻车。最后定位下来问题几乎都出在“测试只在本地跑过”这件事上。很多团队不是不想做自动化测试而是不知道把 pytest-django 接进 CI/CD 之后整个流程应该如何设计——测试策略怎么定、数据库怎么处理、流水线怎么编排、测试时间怎么压缩。这篇文章就是我自己拔过几层皮之后总结出来的整套做法适合已经能用 pytest-django 写用例、准备把测试流程正式接进持续集成的团队参考。1. 项目整体设计与核心痛点拆解1.1 什么是真正完整的自动化测试流程很多人以为在 Git 提交后面挂一条pytest --cov命令就叫接入了 CI/CD其实这只是最表层的动作。完整的自动化测试流程至少包含三层第一层是本地开发阶段的行为约束比如强制检查、快速失败第二层是提交代码后持续集成环境里的完整回归涵盖数据库迁移、多环境依赖安装、覆盖率门槛第三层是持续交付阶段的构建产物验证确保测试通过的东西真的能跑起来。这三层之间是递进关系。本地跑得快偏好小范围的 quick checkCI 里跑得全用完整测试集覆盖核心业务CD 里跑得稳验证构建出来的镜像和部署包没有跑偏。pytest-django 在这个链条里的角色不只是提供一个--ds参数指定 Django 配置而已它本质上是把 Django 的测试框架、数据库行为、请求处理逻辑全部重新适配到了 pytest 的 fixture 体系里让测试代码的编写和生命周期管理更加贴近 Python 社区的习惯。# 一个典型的本地快速检查命令 pytest -m not slow and not integration -q --no-migrations这条命令背后蕴含的设计思路是开发反馈要快迁移重放可以交给 CI 做本地不跑迁移能省下大把时间。这算是整套流程设计的第一个核心决策点。1.2 接 CI 前必须想明白的三件事第一测试集的分层粒度。我见过太多团队把所有测试塞进一个目录既有访问数据库的用例又有纯函数用例还有几十秒起步的接口型用例。这种混在一起的结果就是 CI 跑一次二十分钟大家为了下班只能砍用例。合理的做法是至少按 quick、api、slow、integration 这样打标签让本地和 CI 能按需选取。第二数据库策略。Django 的测试框架默认每次测试都会创建一个新的测试数据库跑完销毁。这在项目初期没有任何问题等用例数量上来之后就开始痛苦了——每次跑测试都要等数据库迁移、等 fixture 加载纯属重复劳动。pytest-django 提供的--reuse-db、--create-db组合可以在快和干净之间做取舍但这个取舍必须预先设计好否则到 CI 上同样会踩坑因为 CI 环境里每次都是全新容器不存在“复用本地数据库”这种说法要么走迁移重放要么走 --no-migrations 加速。第三CI 环境的可重复性。我踩过最大的坑是本地测试依赖了开发环境里已经存在的某些数据比如本地的 Redis 里有缓存数据、数据库里有手工造的历史记录这类用例一到 CI 就红。所以接 CI 之前必须检查测试代码是否做到了环境无依赖所有数据由 fixture 自给自足。提示如果你发现某个用例只在本地过、CI 必挂大概率是因为测试断言里隐含了环境信息。优先检查文件路径、数据库 id、时间相关断言这三种。1.3 技术选型为什么是 pytest-django 而不是 Django 原生 testDjango 自带的manage.py test其实并不弱也有 TestCase、DiscoverRunner 这类好用的机制。但真实项目一旦跑起来原生方案在几个点上会比较憋屈fixture 体系不如 pytest 灵活、插件生态弱、参数化支持不够直觉、报表输出也不好接 CI 平台。pytest-django 并没有推翻 Django 的测试能力它提供的是一种适配层让你能用 pytest 的写法组织测试同时又完整保留 Django 的 ORM、事务回滚、测试客户端这些能力。我自己的体验是从原生 TestCase 迁移到 pytest-django 之后用例代码量大概缩了三分之一因为很多重复的 setUp 都改成 fixture 了尤其 factory 类的数据创建代码清爽很多。2. pytest-django 环境搭建与核心配置2.1 安装与目录规划不要直接在全局环境里装 pytest-django务必用虚拟环境最好是 uv 或者 pip-tools 管理的锁文件环境。测试相关的依赖可以单独放在 requirements-dev.txt 里和运行环境的版本解耦避免开发依赖把生产依赖污染了。uv pip install pytest pytest-django pytest-cov pytest-xdist factory-boy目录规划上我推荐把 tests 作为顶层包和项目主应用平级。对比一下两种布局# 推荐布局 project_root/ ├── config/ # Django 项目配置 ├── apps/ │ ├── users/ │ └── orders/ ├── tests/ │ ├── conftest.py │ ├── factories.py │ ├── test_models/ │ ├── test_apis/ │ └── test_services/ └── pytest.ini# 不推荐的布局 project_root/ ├── app1/tests.py ├── app2/tests.py第一种布局的优势非常明显全局共享的 conftest.py 可以统一定义 fixture不会出现跨应用引用困难测试数据工厂单独成模块不会被业务代码误 import按模块分组天然对应测试分层。第二种布局在 pytest 下虽然也能跑但 conftest 的作用域会变得很绕fixture 互相引用时你就知道有多痛了。2.2 pytest.ini 核心配置逐项解析一份可以开箱即用的 pytest.ini 长这样[pytest] DJANGO_SETTINGS_MODULE config.settings.test python_files tests.py test_*.py *_tests.py addopts -ra --strict-markers --strict-config -q markers slow: 长时间运行的测试 api: 接口测试 integration: 需要外部服务的集成测试 smoke: 冒烟测试 filterwarnings error::DeprecationWarning ignore::UserWarning重点解释几个容易被忽略的细节。--strict-markers和--strict-config是很多人不会主动开的两项。前者强制你只能使用 markers 里声明过的标签可以有效避免团队里各自发明标签导致测试调度混乱后者强制检查配置文件里每一项的合法性拼错了一个参数直接报错退出而不是默默忽略。filterwarnings这里要特别谨慎。把 DeprecationWarning 升级为 error是为了让依赖库的弃用警告尽早暴露而不是等项目升完级才发现 CI 挂了。UserWarning 忽略是因为很多第三方库在生产代码里有意为之屏蔽掉能少刷屏。这两条组合能让你在测试日志里一眼看到真正需要关注的问题。DJANGO_SETTINGS_MODULE指向 config/settings/test.py这个测试配置模块和开发配置最大的区别是启动 DEBUG 关闭、指定独立的数据库名、使用本地文件存储或内存存储、减少不必要的中间件。强推单独搞一个 test 配置而不是在 base settings 里靠判断环境变量切换因为 CI 上直接指定 test 配置模块开发人员本地也不会误用生产配置。2.3 conftest.py 里的关键 fixtureconftest.py 是整个测试体系的枢纽。我见过不少团队把 fixture 到处写只在一个用例文件里定义其他地方用不到等到多个文件需要共享时又复制粘贴最后 fixture 逻辑分叉排查问题找到怀疑人生。正确做法是作用域较大的、跨模块共享的 fixture 全部放顶层 conftest.py。# tests/conftest.py import pytest from django.core.cache import cache pytest.fixture(autouseTrue) def clear_redis_cache(): 每个用例之前清空缓存避免用例间数据污染。 cache.clear() yield cache.clear() pytest.fixture def api_client(): 提供一个已认证的 API client。 from rest_framework.test import APIClient from tests.factories import UserFactory user UserFactory() client APIClient() client.force_authenticate(useruser) return client这里要啰嗦一句autouse fixture 看起来方便但不要滥用。像清缓存这种全局行为autouse 能有效避免脏数据跨用例但如果每个用例都在自动执行一些重操作比如重新初始化一次服务对象会让测试逻辑完全失控。我的建议是凡是你无法预判对哪些用例有影响的一律显式声明只有那种“不执行就是错”的行为才值得用 autouse。注意Django 的manage.py test自带数据库事务回滚pytest-django 里只有标记了pytest.mark.django_db的用例才会访问数据库。如果你忘了加装饰器用例会在查询数据库时抛DatabaseAccessDenied这是最常见的 pytest-django 新手错误。3. 测试策略分层与数据库行为设计3.1 测试用例应该覆盖哪几层自动化的价值不是追求覆盖率数字而是构建一张安全网。这张网需要覆盖以下几个层面每一层都有不同的生命周期和运行频率。模型层测试是成本最低、收益最直接的部分。不要只测字段是否存在更应该测自定义方法、属性、约束、信号行为。我通常为每个模型的核心业务方法至少写两条用例一条验证正常输入的结果一条验证边界输入的结果。这样即使某天重构代码把某个字段改坏了测试能在几秒内告诉你哪块逻辑受影响。API 层测试是大多数业务系统的重头戏。Django REST Framework 项目推荐用APIClient或者 pytest-django 的clientfixture 直接发起真实请求不要直接调用视图函数。因为直接调函数会绕过路由解析、认证、权限、序列化器校验测出来的结果和真实环境差别很大。API 用例至少要覆盖正常响应、认证失败、权限不足、参数校验失败、数据不存在这五类场景。服务层测试容易被忽略但往往服务层的 bug 才是最隐蔽的。比如“下单成功后发送通知”这段逻辑写在 view 里可能约等于没有服务层但这种代码一旦拆出来就应该有独立测试。服务层测试往往不需要数据库可以直接 mock 外部依赖用pytest.mark.django_db控制是否需要真实数据。import pytest from django.core import mail from apps.orders.services import create_order pytest.mark.django_db def test_create_order_sends_email(user, product): order create_order(useruser, productproduct) assert order.total product.price assert len(mail.outbox) 1 assert mail.outbox[0].to [user.email]3.2 数据库访问策略从 --reuse-db 到事务回滚pytest-django 的数据库行为设计是一个让我前期挣扎了很久的点。理解之后其实可以用一句话概括每个测试用例都在一个事务里运行测试结束回滚因此用例之间的数据天然隔离。这套机制对应 Django 原生 TestCase 的class TransactionTestCase和TestCase的区别pytest-django 默认采用的是更接近于后者的方案。先说--reuse-db参数。它的作用是复用上一次创建的测试数据库而不是每轮测试都重新创建。本地开发时这能显著缩短测试启动时间。但有几个坑必须知道如果你改了模型的 fields测试数据库结构已经过期继续复用会引发意想不到的字段缺失错误如果你用了数据库迁移--reuse-db不会自动给你重放新的迁移。所以本地日常可以用切换分支、拉完新代码之后建议先跑一次pytest --create-db强制重建。CI 上则完全不同每次都是全新环境没有本地那种“复用上一个数据库”的语义要么确保跑一次干净的完整创建要么显式指定--no-migrations跳过迁移重放。# 本地开发常用跳迁移 复用库 按标签筛用例 pytest -m not slow -q --no-migrations --reuse-db # 换分支或改了模型之后 pytest --create-db -q--no-migrations这个参数我强烈推荐每个项目都用上。Django 测试默认会执行全部迁移构建数据库结构但迁移只是数据库结构的“变化历史”测试时我们真正需要的只是最终结构。直接以模型状态生成数据库可以节省大量时间。需要注意的前提是你的模型和迁移之间不能有手动的 RunPython 数据操作依赖如果某些表结构必须由迁移里的特殊操作生成那就不能一刀切禁用。3.3 数据准备工厂模式优于 fixture 里硬编码造测试数据的方式会直接影响用例的可读性和维护成本。最高级的写法是直接在 fixture 里硬编码比如pytest.fixture def user(): return User.objects.create_user( usernametest_user, password123456, emailtestexample.com, )这种写法的问题是一旦字段要求变了比如密码强度校验或者模型新增字段你需要在所有相关 fixture 里同步修改改到后面没人分得清哪些用户名在哪些用例里出现过。而且硬编码字段会显得每个值都“很重要”其实 99% 的字段值对测试结果毫无影响。更优的做法是引入 factory_boy。它提供模型字段的默认生成策略你只需要关心测试相关的关键字段。# tests/factories.py import factory from apps.users.models import User class UserFactory(factory.django.DjangoModelFactory): class Meta: model User username factory.Sequence(lambda n: fuser_{n}) email factory.LazyAttribute(lambda obj: f{obj.username}example.com) password passw0rd123实际使用的时候工厂后面还可以挂上SubFactory、RelatedFactory做关联对象生成一套数据链路能建得很完整。共享的工厂类放在tests/factories.py所有 conftest 都能 import保证数据构造逻辑全局统一。3.4 重要但容易被忽略的 django_db_blockerpytest-django 默认在非 django_db 标记的用例里禁止数据库访问但你有时确实需要在某些 fixture 里读数据库而那个 fixture 本身又服务于非 db 测试。这个时候直接访问会被拦截正确做法是用django_db_blockerfixture 临时解锁。pytest.fixture(scopesession) def common_config(django_db_blocker): with django_db_blocker.unblock(): from apps.system.models import Config return Config.objects.get(keycommon)这个 fixture 的场景并不算高频但一旦遇到就是那种不查文档根本不知道怎么解决的问题。低手会直接把整个 fixture 改成 db 访问导致所有依赖它的用例都必须打上 django_db 标记懂得用 django_db_blocker 的人可以在保持用例纯度的同时给极少数 fixture 开通数据库访问权限。这是水平分层的典型细节。4. CI/CD 流水线集成实操4.1 CI 任务拆分与最小化验证集设计接 CI 之前先回答一个核心问题每次提交代码到底要验证什么不是所有变更都需要跑完整全量测试集。小改动只改了一个模型方法全量测试跑 15 分钟纯粹浪费时间。我的推荐做法是把流水线拆成“门禁”级别每次推送必跑的门禁是代码格式检查、静态类型检查、快速测试集quick 标签、数据库可迁移性检查。这类任务合计应该控制在 5 分钟以内否则开发者会嫌弃反馈太慢开始绕过 CI 提交代码——一旦出现这种心态流程就开始崩坏。合并主干之前增加的门禁全量测试集含 slow、api、integration 标签、覆盖率检查低于阈值直接失败、构建产物验证。这两层门禁的区别在于执行时机和成本。日常开发追求快速反馈合并前追求全面回归。如果只有一个全量 job你会发现所有人都在等 CI等待时间越长团队对 CI 的信心越差最后 CI 变成一个“每次都要手动点 retry 才勉强过”的存在。name: CI Pipeline on: pull_request: branches: [ main, release/* ] push: branches: [ main ] jobs: quick-checks: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - name: Lint and type check run: | ruff check . mypy apps config tests - name: Quick tests run: | pytest -m not slow and not integration -q --no-migrations4.2 数据库与外部服务在 CI 里的跑法Django 项目 CI 里最常见的外部依赖是 PostgreSQL 和 Redis。PostgreSQL 在容器里起一个服务然后用环境变量让测试配置连过去。jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: django POSTGRES_PASSWORD: django POSTGRES_DB: django ports: - 5432:5432 options: - --health-cmd pg_isready -U django --health-interval 10s --health-timeout 5s --health-retries 5 env: DJANGO_SETTINGS_MODULE: config.settings.test DATABASE_URL: postgres://django:djangolocalhost:5432/django这里的核心点是services里健康检查参数的设置。不要小看这几行health-*配置如果没有健康检查容器刚启动数据库还没就绪测试 job 连不上数据库直接失败而且报错特别难排查。加了健康检查之后actions 会自动等待服务准备好才继续执行下一步。Redis 缓存同理services: redis: image: redis:7 ports: - 6379:6379 options: - --health-cmd redis-cli ping --health-interval 5s --health-timeout 3s --health-retries 3测试配置里对应把 CACHES 的地址指向 localhost 即可。更讲究一点的做法是用 Django-environ 读取环境变量这样 CI 和本地都能用同一套代码只是环境变量不同。4.3 依赖安装缓存与层缓存加速CI 流程里最浪费时间的就是依赖安装。每次安装十几个包网络一波动就是几分钟。正确的缓存策略能把这个时间压缩到十几秒。- name: Cache pip uses: actions/cachev4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles(requirements*.txt, pyproject.toml) }} restore-keys: | ${{ runner.os }}-pip-key 的生成规则是核心包含操作系统标识和依赖文件的 hash这样只有依赖文件变化时才重新安装依赖。这比单纯缓存虚拟环境目录稳因为 pip 缓存是跨平台可用的只要依赖文件没变就能命中缓存。如果你用 Docker 跑测试还有个更常用的技巧是调整 Dockerfile 里的指令顺序。先把依赖锁文件复制进镜像安装依赖再复制项目代码。这样只要 requirements 没变中间那层就能命中 Docker 缓存构建速度会快很多。FROM python:3.12-slim WORKDIR /app COPY requirements-dev.txt . RUN pip install --no-cache-dir -r requirements-dev.txt COPY . .4.4 覆盖率的门槛策略与报告上传覆盖率不是越高越好但低于某个阈值就说明测试网络漏洞太大可以接受合并。我在实际项目里习惯设定两个指标总覆盖率不低于 80%关键模块覆盖率不低于 90%。关键模块按实际业务价值手动指定配合 pytest-cov 的--cov参数和--cov-fail-under参数一起用。pytest --covapps --covconfig --cov-reportxml:coverage.xml \ --cov-reportterm-missing \ --cov-fail-under80 -q--cov-reportterm-missing会显示每个未覆盖行号这个输出对开发时定位漏测点很有价值。XML 格式的报告可以传给 CI 平台做增量对比比如报告里能自动展示“本次改动新代码覆盖率”。虽然 CI 平台自带的这个功能只对 diff 覆盖敏感但配合阈值拦截足够形成质量闭环了。注意覆盖率配置里默认不会把 tests 包自己算进去因为--covapps只统计指定目录。如果你统计全项目又没排除迁移文件覆盖率会因为大量自动生成的 migration 文件被拉低所以建议在.coveragerc里显式排除。[run] omit */migrations/* */tests/* */asgi.py */wsgi.py manage.py config/settings/*4.5 多层 job 与失败及时止损CI 流水线失败时最好的情况是在最便宜的环节就把问题拦住。因此在全量测试之前应该先跑格式检查、类型检查、quic 测试这相当于给程序员一个快速反馈通道避免全量测试跑了 15 分钟后才报出某个 import 路径写反了。jobs: code-quality: # ... test-suite: needs: code-quality # ... build-image: needs: test-suite # ...通过needs声明依赖关系workflow 会自动在依赖 job 失败后跳过后续 job不再浪费时间。这在实际执行时能省下的不只是时间还有 CI 费用的配额更重要的是团队对“红线”的敬畏感——失败了就别指望后面有环节帮你兜底。5. 测试提速实践与覆盖率深入优化5.1 依赖注入改造与数据库访问懒加载测试提速的关键点在于减少不必要的数据库访问。pytest-django 默认行为已经是每个测试一个事务但如果你在一个用例里反复查询同一条数据仍然会消耗大量时间。这个问题的解决方案是依赖注入把耗时的数据准备放到 fixture 里让用例只关注断言。改造前def test_order_summary(): user User.objects.create_user(usernamedummy, ...) order Order.objects.create(useruser, ...) item OrderItem.objects.create(orderorder, ...) # 断言逻辑...改造后def test_order_summary(user, order_with_item): result calculate_order_summary(order_with_item.id) assert result[total] Decimal(100.00)看起来只是把对象创建搬进了 fixture但实际上你获得了两个额外的好处第一fixture 可以组合复用多个用例共享同一套数据准备逻辑第二当你需要换数据范围时只需要改 fixture 定义不用每个用例都改。5.2 并行测试的正确打开方式pytest-xdist 可以多进程并行执行测试它默认按照 CPU 核心数分配 worker。在 CI 环境里用--numprocessesauto能自动检测可用核心数但要注意容器环境里看到的 CPU 数可能和你预期不一致。pytest -n auto -q并行测试的坑主要是数据库隔离。每个 worker 都需要自己的测试数据库pytest-django 在-n auto模式下的处理实际是主进程创建测试数据库然后复制给每个 worker。因此并行的情况下不要再叠--reuse-db否则会撞库。# 危险写法 pytest -n auto --reuse-db -q # 安全写法 pytest -n auto --create-db -q顺带说一个 xdist 的内在问题测试执行顺序是动态分配的理论上不会影响结果但如果你有全局状态依赖比如环境变量被某个用例改了并行模式下这种 bug 会被放大。建议在测试环境里杜绝修改全局状态的代码如果不能杜绝就给相关用例打一个serial标签并排除出并行范围。5.3 经典提速三板斧除了并行还有三个几乎零成本的提速手段值得养成习惯。第一禁用迁移创建数据库。CI 上跑全量测试时可以加--no-migrations然后额外用一个专门的重型 job 验证迁移本身可以正常执行到最新版本。这样既保证迁移正确性又不让每轮测试都白白重放迁移历史。第二使用--lf只跑上次失败的用例。本地开发时如果上一次测试有失败用例pytest --lf会先重跑失败的部分。配合--ff先跑上次失败的再跑剩余可以在改代码时少等很多时间。第三合理使用pytest.mark.skipif跳过特定环境下的用例比如只在本机有的敏感环境依赖CI 上可以直接跳过而不是报错。import os import pytest pytest.mark.skipif( os.getenv(CI) true, reasonCI environment does not have local fixture data ) def test_local_only_behavior(): ...5.4 覆盖率背后的真实信号覆盖率数字本身没有太大意义真正有用的是它揭示的盲区。我用--cov-reportterm-missing看到过很多次某个服务的复杂分支逻辑完全没被测试覆盖而那个分支里恰好藏着一年前修过的高危 bug。覆盖率阈值的作用不是让大家去拼数字而是强制代码审查者去看这些盲区。有一个反直觉的经验盲目冲高覆盖率会让用例质量下降。为了覆盖一个三元表达式的两个分支写出一堆断言毫无业务意义的用例只会拖慢测试让维护成本升高。所以我在团队里约定的原则是核心业务路径覆盖率必须高边缘异常路径可以接受低覆盖但任何一段代码如果覆盖不到要有明确理由记录在旁边。6. 常见问题与排查技巧快查表6.1 本地绿 CI 红的典型原因这类问题我见得太多了。常见的几个隐藏原因第一测试依赖了本地环境的数据库内容比如某个字段的默认值来自开发库的历史数据这在 CI 全新建库时就不存在。解决办法是把数据显式写在 fixture 里不要依赖环境初始数据。第二文件路径硬编码。测试里如果用了/tmp/xxx这类路径在 macOS 上确实存在但在 Linux 容器里不一定可写或者路径不同。统一改用 pytest 的tmp_pathfixture 来传递临时目录是最省心的做法。第三时区相关的断言。本地机器是东八区CI 容器跑在 UTC如果测试直接比较时间字符串两边很容易差出 8 小时。解决办法是统一使用 UTC 比较或者用freezegun冻结时间。6.2 pytest-django 运行时报错排查报错信息原因处理方式ImproperlyConfigured: settings is not configured没有设置 DJANGO_SETTINGS_MODULE 或未安装 pytest-django检查 pytest.ini 里的配置确认插件已安装到当前环境DatabaseAccessDenied: Database access not allowed未声明 django_db 标记就访问数据库给用例加pytest.mark.django_dbFailed: Database access to default is not allowedconftest 里全局 fixture 触发了数据库访问用 django_db_blocker 临时解锁或拆分 fixturePermissionError: [Errno 13] Permission denied: .pytest_cache容器内运行用户无权限写缓存目录设置PYTEST_ADDOPTS-p no:cacheprovider或配置目录可写TestDataError: Migrations are present模型与迁移文件不一致重新生成迁移或修正模型定义6.3 覆盖率不达标的排查技巧覆盖率不达标时不要盲目补用例。先用coverage report -m看看哪些模块的 missing 最多再按优先级一个一个处理。优先级判断标准这个模块是否包含核心业务逻辑是否有历史 bug 在这块代码附近是否频繁被修改还有一个技巧是分支覆盖率--cov-branch。行覆盖率只能告诉你代码是否被执行分支覆盖率能告诉你有哪个 if 分支没走到。这对路由权限这种多分支场景特别有用。pytest --covapps --cov-branch --cov-reportxml:coverage.xml --cov-fail-under756.4 流水线失败的超时与重试策略CI 跑挂很少是因为用例本身有错更多是外部服务不稳定。测试 job 里要显式设置超时时间并在允许的范围内做有限重试。这里要区分两种情况一种是任务本身运行时间不可控比如全量测试 20 分钟数据库容器中途崩了这种属于环境问题重试是合理的另一种是测试代码本身的 bug 导致的失败重试一万次也没有意义。- name: Run full test suite timeout-minutes: 20 run: | pytest -m not integration --no-migrations --covapps -q --cov-fail-under80如果全量测试总是超过 20 分钟优先考虑拆 job 或者优化测试集而不是简单调大 timeout。调大 timeout 只会让失败暴露得更晚等于把问题往后推。7. 分层流水线的完整参考示例前面讲了不少零散技巧现在把这些串成一个可以直接抄作业的完整 CI 工作流。以一个典型的 Django DRF PostgreSQL 项目为例我现在的团队用的是两 job 结构全量测试 job 内部分步构建镜像 job 作为收尾。name: Django CI Pipeline on: pull_request: push: branches: [ main ] jobs: backend-tests: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: django POSTGRES_PASSWORD: django POSTGRES_DB: django ports: - 5432:5432 options: - --health-cmd pg_isready -U django --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7 ports: - 6379:6379 options: - --health-cmd redis-cli ping --health-interval 5s --health-timeout 3s --health-retries 3 env: DJANGO_SETTINGS_MODULE: config.settings.test DATABASE_URL: postgres://django:djangolocalhost:5432/django REDIS_URL: redis://localhost:6379/0 steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt - name: Cache pip uses: actions/cachev4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles(requirements*.txt) }} restore-keys: | ${{ runner.os }}-pip- - name: Lint and type checks run: | ruff check . mypy apps config tests - name: Run test suite with coverage run: | pytest -n auto -q \ --no-migrations \ --covapps --covconfig \ --cov-reportxml:coverage.xml \ --cov-reportterm-missing \ --cov-fail-under80 - name: Upload coverage report uses: actions/upload-artifactv4 with: name: coverage-report path: coverage.xml docker-build: needs: backend-tests runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Build Docker image run: docker build -t myapp:${{ github.sha }} .这个配置有几个刻意为之的地方缓存放置顺序在依赖安装之前利用 cache 命中的 pip 安装会非常快-n auto配合--no-migrations让全量测试跑在 10 分钟内上传 coverage.xml 给后续人工审查留了素材docker build 只有在所有测试通过后才执行保证构建产物一定来自健康代码。提示如果你用的 CI 平台没有内置 services 容器支持本地先把依赖服务用 docker compose 起好再跑 pytest逻辑是一样的。关键是外部服务地址通过环境变量注入测试代码只认环境变量不认硬编码的 localhost。8. 实操经验总结与避坑清单最后把我在多个项目里反复踩过的坑总结成一份清单。这些经验大多数不在官方文档里是实打实跑过线上流水线才知道的。依赖版本锁死这件事值得单独强调。requirements-dev.txt 里的 pytest、pytest-django、pytest-cov 版本必须锁死不要用或者放任 pip 装最新版。CI 环境的可重复性建立在依赖可复现上如果哪天的 CI 失败是因为 pytest 插件升级导致的行为变化排查成本会非常高。外部服务地址永远走环境变量。Django settings 里直接写死数据库地址的坏习惯在本地开发时没感觉换到 CI 就变成一场灾难。建议项目里引入环境变量读取组件比如 django-environ所有数据库、缓存、队列连接地址全部从环境里取。测试不能依赖执行顺序。pytest 默认收集顺序是按文件路径排序看起来是稳定的但 pytest-xdist 并行时顺序就完全不可控了。如果你的用例之间存在隐式依赖比如 A 用例给 B 用例创建了数据并行环境下会随机失败。这类问题的排查极其痛苦最好的办法是用pytest-randomly插件主动随机打乱执行顺序让你的隐式依赖尽早暴露。不要把 CI 配置写得太复杂。我见过一些团队的 workflow 文件接近 300 行每个 job 里套着五六层 shell 脚本出了问题根本不知道哪一步失败。好的 CI 配置应该是平庸的、简单的、一眼能看懂的。一个 job 干一件事步骤用简单的 shell 命令表达这样任何团队新人接手都能快速定位问题。如果时间预算充足建议在流水线里加一个迁移一致性验证 job。这个 job 不跑测试只执行python manage.py makemigrations --check --dry-run检查模型定义和迁移文件是否一致。这个检查能避免一种特别隐蔽的 CI 失败开发者改了模型但忘了生成迁移本地测试用 no-migrations 跑全绿CI 上完整执行迁移时才发现字段对不上。python manage.py makemigrations --check --dry-run如果这条命令返回非零说明存在未生成的迁移CI 直接失败。能让 team 在提交阶段就发现问题而不是等发布时才暴露。我的实际体会是pytest-django 和 CI/CD 的集成并不需要什么高级魔法真正决定流程好坏的是那些细节决策测试分层怎么设计、数据库要不要跑迁移、覆盖率阈值定多少、并行怎么开、缓存怎么命中。把这些细节一个一个扣清楚了自动化测试就不再是 CI 上那个“偶尔跑跑、红了就 rerun”的摆设而是真正能拦住回归风险的守门员。希望这篇总结能帮你的 Django 项目少走几步弯路。