← 返回题目列表

Django 怎么写测试?TestCase 和 TransactionTestCase 有什么区别?

中等 第 19 / 27 题 更新于 2026/08/01
Django测试TestCasepytest-django测试数据库

简化版

Django 测试的基础设施做了三件事:创建一个独立的测试数据库(名字是 test_<原库名>)、跑完所有测试后销毁它、并让每个测试之间互相隔离。隔离的实现方式决定了三个测试基类的区别:SimpleTestCase——不碰数据库(默认禁止数据库访问,访问就报错),适合测纯函数、表单校验、模板渲染;TestCase(最常用)——把每个测试方法包在一个事务里,测完直接 ROLLBACK,所以极快TransactionTestCase——真正提交事务,测完用 TRUNCATE 清表慢得多,但它是唯一能测「事务行为本身」的基类select_for_updateon_commit 回调、多线程/多连接的并发场景)。选择原则:默认用 TestCase,只有真的要测事务语义时才用 TransactionTestCase测试客户端 self.client 模拟完整的请求-响应流程(client.get/postclient.force_login(user) 跳过密码校验),配合 assertContainsassertRedirectsassertTemplateUsedassertNumQueries 等断言。准备测试数据推荐 setUpTestData(类级别,只建一次,配合事务回滚极快)+ factory_boy,而不是 JSON fixtures(难维护、模型一改就全废)。最容易踩的坑是「TestCasetransaction.on_commit 的回调不会执行」——因为外层事务永远不提交,需要用 captureOnCommitCallbacks(execute=True) 或换 TransactionTestCase。核心记忆:TestCase 用事务回滚所以快、TransactionTestCase 真提交所以能测事务但慢on_commitTestCase 里不触发setUpTestData + factory_boy

详细版

三个测试基类对比

基类数据库隔离方式速度适用
SimpleTestCase禁止访问——最快纯函数、表单、模板、工具方法
TestCase事务 + ROLLBACK绝大多数场景(默认选它)
TransactionTestCase真提交 + TRUNCATE事务行为、on_commit、并发
LiveServerTestCase同上 + 起真实服务器最慢Selenium/端到端
from django.test import TestCase, TransactionTestCase, SimpleTestCase, Client
from django.urls import reverse

# ① ★TestCase:最常用★
class ArticleTests(TestCase):
    @classmethod
    def setUpTestData(cls):              # ★类级别,整个类只执行一次★
        cls.author = User.objects.create_user("u1", password="x")
        cls.article = Article.objects.create(title="t", author=cls.author)

    def setUp(self):                      # ★每个测试方法前执行★
        self.client = Client()            # (TestCase 已自动提供 self.client)

    def test_detail(self):
        url = reverse("article-detail", args=[self.article.pk])
        resp = self.client.get(url)
        self.assertEqual(resp.status_code, 200)
        self.assertContains(resp, "t")            # ★状态码 + 内容一起断言★
        self.assertTemplateUsed(resp, "article/detail.html")

    def test_create_requires_login(self):
        resp = self.client.post(reverse("article-create"), {"title": "n"})
        self.assertRedirects(resp, "/login/?next=/articles/create/")

    def test_create_ok(self):
        self.client.force_login(self.author)      # ★跳过密码校验,比 login 快★
        resp = self.client.post(reverse("article-create"), {"title": "n"})
        self.assertEqual(Article.objects.count(), 2)

    def test_query_count(self):                    # ★防 N+1 的利器★
        with self.assertNumQueries(2):
            list(Article.objects.select_related("author").all())

# ② ★TransactionTestCase:测事务行为★
class TransactionTests(TransactionTestCase):
    def test_on_commit_fires(self):
        with transaction.atomic():
            Article.objects.create(title="x")
        # ★真提交了 → on_commit 回调会执行★

    def test_select_for_update(self):
        # ★TestCase 里测不了行锁(因为整个测试在一个事务里)★
        ...

# ③ ★TestCase 里测 on_commit 的正确方式★
class OnCommitTests(TestCase):
    def test_callback(self):
        with self.captureOnCommitCallbacks(execute=True) as callbacks:   # ★3.2+★
            create_order()                # 内部有 transaction.on_commit(send_email)
        self.assertEqual(len(callbacks), 1)
        # ★execute=True 会真的执行回调★

# ④ SimpleTestCase:不碰数据库
class FormTests(SimpleTestCase):
    def test_invalid_email(self):
        form = SignupForm({"email": "bad"})
        self.assertFalse(form.is_valid())
        self.assertIn("email", form.errors)
    # ★这里访问数据库会抛 DatabaseOperationForbidden★

# ⑤ 常用断言
self.assertContains(resp, "text", status_code=200)   # ★同时检查状态码★
self.assertNotContains(resp, "secret")
self.assertRedirects(resp, "/target/", target_status_code=200)
self.assertTemplateUsed(resp, "app/page.html")
self.assertFormError(resp, "form", "email", "错误信息")   # 4.1+ 改为 assertFormError(form, ...)
self.assertQuerySetEqual(qs, [...], transform=str)        # 4.2+ 更名(原 assertQuerysetEqual)
self.assertNumQueries(3)
with self.assertRaises(ValidationError): ...

⚠️ 三个必须记住的点:① TestCase 把每个测试方法包在一个事务里、结束时 ROLLBACK——这是它比 TransactionTestCase 快得多的原因(回滚比 TRUNCATE + 重新插入数据快一个数量级)。但代价是**「事务相关的行为在 TestCase 里测不出来」transaction.on_commit() 注册的回调永远不会触发**(外层事务不会提交)、select_for_update() 的锁行为观察不到、其他数据库连接(如另一个线程)看不到测试里创建的数据。② transaction.on_commit 的回调在 TestCase 里不执行是最常见的困惑——发邮件、发消息队列、清缓存这类「提交后才做」的逻辑在测试里静默跳过,导致「测试通过但线上不工作」或反过来「以为没测到」。正确做法是用 self.captureOnCommitCallbacks(execute=True)(Django 3.2+)显式捕获并执行回调,或者对这类测试改用 TransactionTestCase。③ setUpTestDatasetUp 的区别很关键setUpTestData类方法,整个测试类只执行一次(数据在类级别的事务里创建,每个测试方法结束只回滚方法级的事务,所以数据还在)——这是 Django 测试提速的最重要手段setUp 每个方法前都执行一次。注意 setUpTestData 创建的对象在Django 3.2+ 会自动做深拷贝隔离(防止一个测试修改属性影响另一个),更早的版本需要自己注意。

完整版教学

一、Django 测试的基础设施

★ 运行测试时 Django 做了什么:
  python manage.py test
  ① ★创建测试数据库★:test_<你的库名>
     - 跑完所有 migrations(★这是最慢的一步★)
     - 或用 --keepdb 复用上次的测试库
  ② 逐个运行测试(★按测试类分组、按基类排序★)
  ③ ★销毁测试数据库★

★ 关键设置:
  DATABASES["default"]["TEST"] = {
      "NAME": "test_mydb",              # 自定义测试库名
      "SERIALIZE": False,               # ★跳过序列化,加速★
  }
  # 命令行
  python manage.py test --keepdb          # ★复用测试库(省去建库和迁移)★
  python manage.py test --parallel        # ★多进程并行(每进程一个库)★
  python manage.py test --failfast        # 第一个失败就停
  python manage.py test app.tests.TestX.test_y   # 只跑一个

★ ★测试专用 settings(★强烈建议★)★:
  # settings/test.py
  from .base import *
  DEBUG = False                          # ★别开 DEBUG(行为不同)★
  PASSWORD_HASHERS = [                   # ★★最有效的提速手段★★
      "django.contrib.auth.hashers.MD5PasswordHasher",
  ]
  # → 默认的 PBKDF2 故意很慢(安全需要),★测试里创建用户会慢几十倍★
  EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"  # ★内存邮件★
  CACHES = {"default": {"BACKEND": "...locmem.LocMemCache"}}
  CELERY_TASK_ALWAYS_EAGER = True        # ★Celery 同步执行★
  MIGRATION_MODULES = {...}              # 或用 --no-migrations 跳过迁移

★ 提速手段(★大项目的测试可能跑几十分钟★):
  ┌────────────────────────────┬────────────────────────┐
  │ 手段                        │ 效果                    │
  ├────────────────────────────┼────────────────────────┤
  │ ★MD5PasswordHasher★        │ ★创建用户快几十倍★      │
  │ ★--keepdb★                 │ 省去每次建库 + 迁移      │
  │ ★--parallel★               │ 按 CPU 核数并行          │
  │ ★setUpTestData 替代 setUp★ │ 数据只建一次             │
  │ ★TestCase 替代 Transaction★│ 回滚比 TRUNCATE 快       │
  │ 跳过迁移(--no-migrations)  │ 直接建表(★需谨慎★)    │
  │ 内存数据库(SQLite :memory:)│ 快但★与生产不一致★      │
  └────────────────────────────┴────────────────────────┘

★ 测试的数据隔离原理(★理解它才知道各基类的差别★):
  TestCase:
    setUpClass  → ★开启类级 atomic★ → setUpTestData 创建数据
      每个 test →   ★开启方法级 atomic★ → 测试 → ★ROLLBACK★
    tearDownClass → ★回滚类级 atomic★
    → ★数据库从未真正写入★,所以快
  TransactionTestCase:
    每个 test → 真实执行(★真提交★)→ ★TRUNCATE 所有表★ → 重新加载 fixtures
    → 慢,但行为与生产一致

Django 测试的基础设施会创建一个 test_ 前缀的独立数据库、跑完所有迁移、测试结束后销毁测试专用 settings 是必须做的,其中最有效的提速手段是把 PASSWORD_HASHERS 换成 MD5——默认的 PBKDF2 为了安全故意设计得很慢,测试里每创建一个用户都要付出这个代价,换成 MD5 能快几十倍;其他还有内存邮件后端、本地缓存、CELERY_TASK_ALWAYS_EAGER。命令行层面 --keepdb(复用测试库,省去建库和迁移)和 --parallel(多进程并行) 是大项目的标配。理解数据隔离原理才能明白各基类的差别:TestCase 是「类级 atomic + 方法级 atomic,测完 ROLLBACK」——数据库从未真正写入所以快TransactionTestCase 是「真提交 + TRUNCATE 所有表」——慢但行为与生产一致

二、三个测试基类的选择

★ SimpleTestCase:不碰数据库
  class FormTest(SimpleTestCase):
      databases = []                      # ★默认就是禁止★
      def test_x(self):
          Article.objects.count()         # ✗ ★抛 DatabaseOperationForbidden★
  ✓ 适合:表单校验、模板渲染、工具函数、URL 解析、纯逻辑
  ✓ 它仍然提供:self.client、assertContains 等断言、settings 覆盖
  ★ 需要访问数据库时可以显式声明:databases = "__all__"(★但那就该用 TestCase★)

★ ★TestCase(默认选择)★:
  - 每个测试方法包在 ★atomic 事务★里,结束 ROLLBACK
  - ★setUpTestData 的数据在类级事务里,所有方法共享★
  - ★快★:不需要 TRUNCATE 和重新插入
  ✗ ★测不了的东西★:
    ① ★transaction.on_commit 的回调★(外层事务不提交)
    ② ★select_for_update 的锁行为★(整个测试在一个事务里)
    ③ ★其他数据库连接看不到测试数据★(线程、子进程、真实的 HTTP 请求)
    ④ 事务的 ★savepoint 嵌套行为★(部分能测,但与生产不完全一致)

★ ★TransactionTestCase:测事务行为★
  - ★真正提交★,测完 TRUNCATE 所有表
  - ★慢★:TRUNCATE + 重新加载 fixtures(每个方法一次)
  - ★没有 setUpTestData 的加速★(数据每个方法都要重建)
  ✓ 必须用它的场景:
    ① ★测 on_commit 回调★(虽然 TestCase 有 captureOnCommitCallbacks)
    ② ★测 select_for_update / 行锁 / 死锁★
    ③ ★多线程/多连接并发测试★
    ④ ★测试真实的事务回滚行为★(IntegrityError 后的状态)
    ⑤ LiveServerTestCase 的场景(Selenium 需要真实数据)
  ★ 属性:
    available_apps = [...]     # ★只 TRUNCATE 这些 app 的表(加速)★
    reset_sequences = True     # ★重置自增 ID(默认不重置!)★
    serialized_rollback = True # 回滚后恢复初始数据

★ ★运行顺序(★容易被问★)★:
  Django 会★重排测试顺序★:
    ① 所有 TestCase 子类(★先跑,因为快且隔离好★)
    ② 所有 TransactionTestCase 子类(★后跑★)
    ③ 其他(SimpleTestCase 等)
  ★ 原因:TransactionTestCase 会 TRUNCATE 表,
    如果在 TestCase 之前跑会破坏它们依赖的初始数据

★ 选择决策:
  ┌──────────────────────────────────┬──────────────────────┐
  │ 不需要数据库                       │ ★SimpleTestCase★     │
  │ 需要数据库(★绝大多数★)           │ ★TestCase★           │
  │ 要测 on_commit                    │ TestCase +           │
  │                                   │ ★captureOnCommitCallbacks★│
  │ 要测行锁/并发/多连接               │ ★TransactionTestCase★│
  │ Selenium 端到端                    │ LiveServerTestCase   │
  └──────────────────────────────────┴──────────────────────┘

三个基类的选择很清晰。SimpleTestCase 默认禁止数据库访问(访问会抛 DatabaseOperationForbidden),适合表单校验、模板渲染、工具函数这类纯逻辑测试。TestCase 是默认选择——每个测试方法包在事务里、结束回滚,快且隔离好;但它测不了四类东西on_commit 回调、select_for_update 的锁行为、其他数据库连接(线程/子进程)看不到测试数据、以及完整的事务回滚语义。TransactionTestCase 真提交、测完 TRUNCATE,慢且没有 setUpTestData 的加速,但它是测事务行为的唯一选择(它还有两个实用属性:available_apps 只清理指定 app 的表来加速reset_sequences 重置自增 ID——默认是不重置的)。还有个容易被问的细节:Django 会重排测试顺序,先跑所有 TestCase 再跑 TransactionTestCase——因为后者会 TRUNCATE 表,先跑会破坏前者依赖的初始数据。

三、测试客户端与断言

★ Client 模拟完整的请求-响应流程:
  resp = self.client.get("/articles/", {"page": 2})
  resp = self.client.post("/articles/", {"title": "x"}, follow=True)   # ★跟随重定向★
  resp = self.client.post("/api/", data=json.dumps(d),
                          content_type="application/json")
  resp = self.client.put/patch/delete/head/options(...)
  ★ 它★不发真实的 HTTP 请求★,而是直接调用 Django 的请求处理链
  → ★中间件、URL 解析、视图、模板渲染都会执行★
  → ★但不经过 WSGI 服务器和网络★

★ 登录:
  self.client.login(username="u", password="p")   # ★走完整认证流程(慢)★
  self.client.force_login(user)                   # ★★直接设 session(快,推荐)★★
  self.client.logout()
  ★ force_login 跳过密码验证 → ★配合 MD5 hasher 能省大量时间★

★ 响应对象上能拿到什么:
  resp.status_code
  resp.content            # bytes
  resp.json()             # ★JsonResponse 的解析(3.0+)★
  resp.context            # ★模板上下文(只有渲染了模板才有)★
  resp.templates          # 用到的模板列表
  resp.redirect_chain     # follow=True 时的重定向链
  resp.wsgi_request       # ★请求对象(可以看 request.user)★
  resp["Content-Type"]    # 响应头

★ Django 特有的断言:
  assertContains(resp, "text", count=2, status_code=200)  # ★同时校验状态码★
  assertNotContains(resp, "secret")
  assertRedirects(resp, "/target/")                       # ★检查重定向目标★
  assertTemplateUsed(resp, "app/x.html") / assertTemplateNotUsed
  assertFormError(form, "field", "错误")                  # ★4.1+ 签名变了★
  assertQuerySetEqual(qs, expected, transform=repr, ordered=False)  # ★4.2 更名★
  ★assertNumQueries(n)★                                   # ★防 N+1 的核心工具★
  assertJSONEqual(resp.content, {...})
  assertInHTML(needle, haystack)                          # ★HTML 语义比较★

★ ★assertNumQueries:最有价值的断言★
  def test_list_no_nplus1(self):
      ArticleFactory.create_batch(10)
      with self.assertNumQueries(2):          # ★1 次文章 + 1 次作者★
          resp = self.client.get("/articles/")
  → ★把"查询次数"变成回归测试★
  → 有人不小心删了 select_related → ★测试立刻失败★
  ★ 这是防止 N+1 回归的最有效手段

★ 测试 API(DRF):
  from rest_framework.test import APIClient, APITestCase
  class ApiTests(APITestCase):
      def test_create(self):
          self.client.force_authenticate(user=self.user)   # ★DRF 的认证★
          resp = self.client.post("/api/articles/", {"title": "x"}, format="json")
          self.assertEqual(resp.status_code, 201)

★ 其他实用工具:
  from django.test import override_settings
  @override_settings(DEBUG=True, CACHES={...})    # ★类或方法级覆盖设置★
  def test_x(self): ...

  from django.core import mail
  self.assertEqual(len(mail.outbox), 1)           # ★locmem 邮件后端的收件箱★
  self.assertIn("欢迎", mail.outbox[0].subject)

  from django.test import tag
  @tag("slow")                                    # ★python manage.py test --tag=slow★
  def test_heavy(self): ...

测试客户端不发真实的 HTTP 请求,而是直接调用 Django 的请求处理链——中间件、URL 解析、视图、模板渲染都会执行,但不经过 WSGI 服务器和网络,所以又快又完整。登录推荐用 force_login(user)(直接设 session、跳过密码验证),比 login() 快得多。响应对象上能拿到 resp.context(模板上下文)、resp.templatesresp.redirect_chainresp.wsgi_request 等丰富信息。Django 特有的断言里,assertNumQueries(n) 是最有价值的——它把「查询次数」变成了回归测试:有人不小心删了 select_related,测试会立刻失败,这是防止 N+1 回归的最有效手段。其他常用的还有 assertContains(同时校验状态码)、assertRedirectsassertTemplateUsed、以及 override_settings 装饰器和 mail.outbox(locmem 后端的收件箱)。

四、测试数据的准备

★ 三种方式对比:
  ┌──────────────────┬──────────────────────────────────────┐
  │ ★setUpTestData★  │ ★类级别只建一次,最快★;配合事务回滚    │
  │ JSON fixtures    │ ★难维护★,模型一改就全废;加载慢       │
  │ ★factory_boy★    │ ★灵活、只声明关心的字段、可组合★       │
  └──────────────────┴──────────────────────────────────────┘

★ setUpTestData(★Django 的提速利器★):
  class MyTests(TestCase):
      @classmethod
      def setUpTestData(cls):
          cls.user = User.objects.create_user("u")     # ★整个类只执行一次★
          cls.articles = [Article.objects.create(...) for _ in range(10)]
      def test_a(self): ...    # 用 self.user(★共享★)
      def test_b(self): ...    # 数据仍在(★方法级事务回滚不影响类级数据★)

  ★ 3.2+ 的改进:★类属性会被自动深拷贝隔离★
    → 一个测试里 self.user.name = "x" 不会影响另一个测试
    → 但★数据库里的修改仍会被方法级事务回滚★
  ★ 3.2 之前要小心:修改类属性会影响后续测试

★ ★factory_boy(推荐)★:
  import factory
  class UserFactory(factory.django.DjangoModelFactory):
      class Meta:
          model = User
      username = factory.Sequence(lambda n: f"user{n}")   # ★自动唯一★
      email = factory.LazyAttribute(lambda o: f"{o.username}@x.com")

  class ArticleFactory(factory.django.DjangoModelFactory):
      class Meta:
          model = Article
      title = factory.Faker("sentence")                    # ★假数据★
      author = factory.SubFactory(UserFactory)             # ★自动创建关联★
      status = "draft"

  # 用法
  a = ArticleFactory()                          # 全默认
  a = ArticleFactory(title="指定", status="published")   # ★只写关心的字段★
  articles = ArticleFactory.create_batch(10)
  a = ArticleFactory.build()                    # ★不存数据库★

  ★ 优点:
    ① ★测试里只声明"这个测试关心的字段"★ → 意图清晰
    ② 模型加字段时★不用改所有测试★(工厂里给默认值)
    ③ ★关联对象自动创建★(SubFactory)
    ④ 可以定义 Trait/派生工厂表达不同状态

★ ★为什么不推荐 JSON fixtures★:
  python manage.py dumpdata > fixtures/data.json
  class MyTests(TestCase):
      fixtures = ["data.json"]
  ✗ ★模型字段一改,整个 fixture 就失效★
  ✗ ★看不出"这个测试依赖哪些数据"★(一整个大文件)
  ✗ ★加载慢★(TransactionTestCase 下每个方法都要重新加载)
  ✓ 仍然适合:★不变的基础数据★(省份表、权限初始化)

★ 一个常见的组织方式:
  tests/
    factories.py        # ★所有工厂集中定义★
    test_models.py
    test_views.py
    test_api.py
  → 工厂复用,测试文件只写"这个场景需要什么"

★ 时间与随机的处理(★保证可重复★):
  from unittest.mock import patch
  with patch("django.utils.timezone.now", return_value=fixed_dt): ...
  # 或 freezegun / time-machine
  from freezegun import freeze_time
  @freeze_time("2026-01-01 12:00:00")
  def test_expiry(self): ...
  ★ 随机:固定 seed 或 mock 掉随机源

准备测试数据的三种方式里,setUpTestData 是 Django 的提速利器——类级别只执行一次,数据在类级事务里创建,每个测试方法结束只回滚方法级事务,所以数据还在(3.2+ 还会自动深拷贝类属性做隔离)。推荐配合 factory_boy:它的核心价值是测试里只声明「这个测试关心的字段」(意图清晰),模型加字段时不用改所有测试,而且 SubFactory 能自动创建关联对象、Sequence 保证唯一。不推荐 JSON fixtures 的理由很实际:模型字段一改整个 fixture 就失效看不出某个测试依赖哪些数据、加载慢——它只适合「不变的基础数据」(省份表、权限初始化)。另外别忘了时间和随机的可重复性:用 freezegun/time-machine 冻结时间、固定随机种子,否则测试会在特定日期或特定运行下随机失败。

五、常见的坑

★ 坑一:★on_commit 回调在 TestCase 里不执行★(最常见)
  def create_order():
      with transaction.atomic():
          order = Order.objects.create(...)
          transaction.on_commit(lambda: send_email(order))   # ★提交后才执行★
      return order

  class Test(TestCase):
      def test_email(self):
          create_order()
          self.assertEqual(len(mail.outbox), 1)    # ✗ ★失败:回调没执行★
  ★ 原因:TestCase 的外层事务★永远不会提交★
  ✓ 解法一(★推荐★):
    with self.captureOnCommitCallbacks(execute=True):
        create_order()
    self.assertEqual(len(mail.outbox), 1)          # ✓
  ✓ 解法二:改用 TransactionTestCase(慢)

★ 坑二:★缓存跨测试污染★
  测试 A 写了缓存 → 测试 B 读到脏数据
  ✓ settings 里用 LocMemCache + 每个测试 cache.clear()
  ✓ 或 @override_settings(CACHES={"default": {"BACKEND": "...DummyCache"}})
  ★ Redis 等外部缓存★不会被事务回滚★ → 必须手动清理

★ 坑三:★信号导致的意外行为★
  post_save 信号里发邮件/调外部 API → ★每个测试都会触发★
  ✓ mock 掉:@patch("app.signals.send_notification")
  ✓ 或临时断开:signals.post_save.disconnect(handler, sender=Model)
  ★ 记得测完连回去(用 setUp/tearDown 或上下文管理器)

★ 坑四:★文件上传残留★
  测试里上传的文件会真的写到 MEDIA_ROOT
  ✓ @override_settings(MEDIA_ROOT=tempfile.mkdtemp())
  ✓ 或用 SimpleUploadedFile + InMemoryStorage(4.2+ 内置)

★ 坑五:★迁移让测试变慢★
  大项目有几百个迁移文件 → ★每次建测试库都要跑一遍★
  ✓ --keepdb(★最有效★)
  ✓ 定期 squashmigrations
  ✓ 或用 pytest-django 的 --no-migrations(★直接从模型建表★)
    ★ 风险:跳过了迁移里的数据操作和自定义 SQL

★ 坑六:★测试之间的顺序依赖★
  测试 A 依赖测试 B 先跑 → ★这是设计问题★
  ✓ Django 默认会打乱顺序(--shuffle,4.0+)帮你发现
  ✓ 每个测试自己准备需要的数据

★ 坑七:★自增 ID 不重置★
  TransactionTestCase 默认 ★不重置序列★
  → 断言 pk == 1 会失败
  ✓ reset_sequences = True(★但更慢★)
  ✓ ★更好:别断言具体的 ID★

★ 坑八:★DEBUG=True 下行为不同★
  - connection.queries 只在 DEBUG 下记录
  - ALLOWED_HOSTS 检查被跳过
  - 错误页面不同
  ★ Django 测试时会★强制 DEBUG=False★(除非你覆盖)
  → ★所以 assertNumQueries 能工作(它内部临时开启记录)★

八个常见的坑里,最容易困惑的是 on_commit 回调在 TestCase 里不执行——因为外层事务永远不提交,导致「发邮件、发消息队列、清缓存」这类提交后逻辑在测试里静默跳过,解法是 captureOnCommitCallbacks(execute=True)。其他几个:缓存跨测试污染(外部 Redis 不会被事务回滚,必须手动清理)、信号导致的意外行为post_save 里发邮件会在每个测试触发,要 mock 掉)、文件上传残留(用 override_settings(MEDIA_ROOT=临时目录))、迁移让测试变慢--keepdb 最有效)、测试之间的顺序依赖(Django 4.0+ 的 --shuffle 能帮你发现)、TransactionTestCase 默认不重置自增 ID(所以别断言具体的 pk 值)。还有个有意思的细节:Django 测试时会强制 DEBUG=False,而 assertNumQueries 之所以能工作是因为它内部临时开启了查询记录。

六、pytest-django 与实践建议

★ pytest-django:更简洁的写法
  # pytest.ini / pyproject.toml
  [tool.pytest.ini_options]
  DJANGO_SETTINGS_MODULE = "myproject.settings.test"
  python_files = "test_*.py"
  addopts = "--reuse-db --nomigrations"      # ★相当于 --keepdb + 跳过迁移★

  # 测试写法
  import pytest
  @pytest.mark.django_db                      # ★声明需要数据库(等价 TestCase)★
  def test_article(client):                   # ★client 是内置 fixture★
      a = ArticleFactory()
      resp = client.get(f"/articles/{a.pk}/")
      assert resp.status_code == 200

  @pytest.mark.django_db(transaction=True)    # ★等价 TransactionTestCase★
  def test_on_commit(): ...

  # 内置 fixture
  client / admin_client / rf(RequestFactory)/ django_user_model
  settings(★可直接改:settings.DEBUG = True★)/ mailoutbox / django_assert_num_queries

  def test_queries(django_assert_num_queries):
      with django_assert_num_queries(2):
          list(Article.objects.select_related("author"))

★ Django TestCase vs pytest-django:
  ┌────────────────┬────────────────────────────────────┐
  │ Django TestCase │ 标准库风格、无额外依赖、官方文档全   │
  │ ★pytest-django★ │ ★fixture 更灵活、参数化、插件生态★  │
  └────────────────┴────────────────────────────────────┘
  → 两者可以共存(pytest 能运行 Django 的 TestCase)
  → ★新项目推荐 pytest-django★(parametrize 和 fixture 组合能力强)

★ 测试分层建议:
  ① ★模型/工具函数★ → SimpleTestCase 或纯 pytest(★快、多写★)
  ② ★业务逻辑(service 层)★ → TestCase + factory
  ③ ★视图/API★ → TestCase + Client(★覆盖权限、状态码、序列化★)
  ④ ★集成★ → 少量端到端(LiveServerTestCase / Selenium)
  ★ 金字塔:底层多、顶层少

★ 该测什么(★Django 项目的重点★):
  □ ★权限★:未登录/无权限用户访问受限资源 → 403/302
  □ ★表单和序列化器的校验★(边界值、必填、格式)
  □ ★业务规则★(状态流转、金额计算、库存扣减)
  □ ★查询数量★(assertNumQueries 防 N+1)
  □ ★信号和 on_commit 的副作用★
  □ ★迁移能否正常执行★(makemigrations --check --dry-run)
  ✗ 不用测:Django 框架本身、ORM 的基本功能、第三方库

★ CI 中的实践:
  python manage.py makemigrations --check --dry-run   # ★检查有没有漏生成迁移★
  python manage.py test --parallel --keepdb
  coverage run --source=. manage.py test && coverage report --fail-under=80

★ 一句话总结:
  ★"默认用 TestCase(事务回滚,快),只有测事务行为时才用 TransactionTestCase;
    数据用 setUpTestData + factory_boy;
    别忘了 assertNumQueries 和 captureOnCommitCallbacks 这两个 Django 特有的利器。"★

pytest-django 提供了更简洁的写法:@pytest.mark.django_db 等价于 TestCase、加 transaction=True 等价于 TransactionTestCase,内置的 clientsettingsmailoutboxdjango_assert_num_queries 等 fixture 很好用,配置里加 --reuse-db --nomigrations 相当于 --keepdb 加跳过迁移。两者可以共存(pytest 能运行 Django 的 TestCase),新项目推荐 pytest-django(参数化和 fixture 组合能力强)。测试分层遵循金字塔:底层的模型和工具函数多写、顶层的端到端少写。Django 项目该重点测的是:权限(未登录/无权限访问受限资源)、表单和序列化器校验、业务规则、查询数量(assertNumQueries 防 N+1)、信号和 on_commit 的副作用、以及 CI 里用 makemigrations --check --dry-run 检查有没有漏生成迁移不用测 Django 框架本身和 ORM 的基本功能

记忆钩子:「Django 测试的基础设施=★建 test_ 前缀的独立库 + 跑迁移 + 测完销毁★。★三个基类的区别全在『隔离方式』★:★SimpleTestCase 默认禁止数据库访问★(访问抛 DatabaseOperationForbidden,适合表单/模板/纯函数);★TestCase 把每个测试方法包在事务里、测完 ROLLBACK★(数据库从未真正写入 → ★快★,是默认选择);★TransactionTestCase 真提交 + TRUNCATE 所有表★(★慢★但行为与生产一致)。★TestCase 测不了四类东西★:①★transaction.on_commit 的回调永远不触发★(外层事务不提交)②select_for_update 的锁行为③★其他数据库连接/线程看不到测试数据★④完整的事务回滚语义 → 解法是 ★self.captureOnCommitCallbacks(execute=True)(3.2+)★ 或换 TransactionTestCase。★Django 会重排顺序:先跑所有 TestCase 再跑 TransactionTestCase★(后者会 TRUNCATE,先跑会破坏前者的数据)。提速三件套:★PASSWORD_HASHERS 换 MD5(默认 PBKDF2 故意很慢,创建用户快几十倍)★、★—keepdb★、★setUpTestData(类级只建一次,配合方法级事务回滚)★,外加 —parallel 和 force_login(跳过密码校验)。数据准备用 ★setUpTestData + factory_boy★(只声明关心的字段、SubFactory 自动建关联、模型加字段不用改所有测试),★别用 JSON fixtures★(模型一改就全废、看不出依赖、加载慢)。★assertNumQueries 是最有价值的断言★——把『查询次数』变成回归测试,有人删了 select_related 立刻失败。其他坑:★外部缓存不会被事务回滚★要手动清、★信号里的副作用每个测试都会触发★要 mock、★TransactionTestCase 默认不重置自增 ID★(所以别断言具体 pk)、文件上传要 override MEDIA_ROOT。pytest-django 用 ★@pytest.mark.django_db(加 transaction=True 等价 TransactionTestCase)★ 和 —reuse-db —nomigrations。」

七、常见误区与追问

  • 误区:TestCaseTransactionTestCase 只是名字不同,随便用哪个。 两者的隔离机制完全不同,直接决定了速度和能测什么。TestCase 把每个测试方法包在一个数据库事务里,结束时 ROLLBACK——数据从未真正提交,所以清理极快(回滚比 TRUNCATE + 重新插入快一个数量级),而且 setUpTestData 创建的类级数据可以在所有方法间共享。TransactionTestCase 真正提交事务,测完 TRUNCATE 所有表——慢得多,而且没有 setUpTestData 的加速(每个方法都要重建数据)。选择原则是默认用 TestCase,只有当你要测的东西本身就是事务行为on_commitselect_for_update、多连接并发)时才用 TransactionTestCase——一个大项目如果到处滥用后者,测试时间可能翻好几倍。
  • 误区:transaction.on_commit() 注册的回调在测试里也会执行。 TestCase 里永远不会执行——因为整个测试方法被包在一个外层事务里,而这个事务只会 ROLLBACK、永远不会 COMMIT,所以「提交后执行」的回调自然不会触发。后果是「发邮件、投递消息队列、清缓存、发 webhook」这类逻辑在测试里静默跳过,你以为测到了其实没测到(或者反过来,断言 mail.outbox 有邮件时莫名其妙失败)。Django 3.2+ 提供了 self.captureOnCommitCallbacks(execute=True) 上下文管理器:它会捕获这段代码里注册的所有回调并真正执行,还能拿到回调列表做断言。这是推荐做法;实在需要真实提交语义时才换 TransactionTestCase
  • 误区:把测试数据放在 setUp 里,每个测试都干净,最保险。 干净是干净,但慢得多——setUp每个测试方法前都执行一次,如果它创建 20 条数据、而这个类有 30 个测试,就是 600 次插入。setUpTestData 是类方法、整个测试类只执行一次,数据在类级事务里创建,而每个测试方法只回滚方法级事务,所以类级数据始终存在——这是 Django 测试最重要的提速手段之一。安全性方面:Django 3.2+ 会对 setUpTestData 赋值的类属性做自动深拷贝隔离(一个测试里 self.user.name = "x" 不会污染其他测试),而数据库层面的修改本来就会被方法级事务回滚。只有「每个测试都需要不同数据」时才用 setUp
  • 误区:测试跑得慢是因为测试用例太多,没什么办法。 大部分 Django 项目的测试慢有几个固定的元凶,改配置就能大幅提速。① 密码哈希:默认的 PBKDF2 为了安全故意设计成很慢(几十万次迭代),测试里每创建一个用户都要付出这个代价——在测试 settings 里换成 MD5PasswordHasher 通常能让整体快好几倍。② 迁移:大项目有几百个迁移文件,每次建测试库都要全部跑一遍——用 --keepdb(复用上次的测试库)或 pytest-django 的 --no-migrations(直接从模型建表,但会跳过迁移里的数据操作)。③ 没用 setUpTestData(见上)。④ 滥用 TransactionTestCase⑤ 没有并行——--parallel 会按 CPU 核数创建多个测试库并行跑。此外还有:用 force_login 代替 login、内存邮件后端、CELERY_TASK_ALWAYS_EAGER
  • 误区:测试里访问 Redis 缓存也会像数据库一样被自动回滚。 不会——TestCase 的事务回滚只作用于数据库外部服务(Redis、Memcached、Elasticsearch、消息队列、文件系统)完全不受影响。所以测试 A 写进 Redis 的数据,测试 B 还能读到,造成难以复现的相互污染(尤其是测试顺序随机时)。三种应对:① 测试 settings 里改用 LocMemCache(进程内存,隔离性好)或 DummyCache(什么都不缓存,适合不想让缓存干扰断言时);② 在 setUp/tearDown 里显式 cache.clear()③ 用 @override_settings(CACHES=...) 针对单个测试类覆盖。同样的道理适用于文件上传(会真的写进 MEDIA_ROOT,要用 override_settings(MEDIA_ROOT=tempfile.mkdtemp()))和信号触发的外部调用(要 mock 掉)。
  • 追问:assertNumQueries 为什么值得专门写测试? 因为 N+1 是 Django 项目最常见也最容易「回归」的性能问题。一个列表页今天用了 select_related("author") 只查 2 次,明天有人在模板里加了 {{ article.category.name }}、或者重构时删掉了 prefetch_related,查询数就悄悄变成了 101 次——功能测试全部通过,只有线上响应变慢assertNumQueries(2) 把「查询次数」变成了可断言的契约:一旦有人破坏了预加载,测试立刻失败并告诉你实际执行了多少条 SQL(失败信息里会打印所有查询)。实践建议:给列表页、聚合接口、后台导出这类「循环里访问关联对象」的地方加上它;数字不必精确到 1(可以留一点余量或用 pytest-django 的 django_assert_max_num_queries),关键是挡住数量级的劣化。它也是 code review 时很有说服力的证据。
  • 追问:factory_boy 相比 JSON fixtures 好在哪? 三个层面。① 可维护性:fixtures 是模型数据的完整快照,模型加一个非空字段,所有 fixture 文件立刻失效,而工厂只需要在一处补一个默认值。② 可读性/意图表达:测试里写 ArticleFactory(status="published") 一眼就能看出「这个测试只关心状态是已发布」,其余字段自动填充;而 fixtures 是一整个大 JSON 文件,你完全看不出某个测试依赖其中哪几条数据,久而久之没人敢删。③ 灵活性SubFactory 自动创建关联对象(不用手写外键 id)、Sequence 保证唯一性、Faker 生成真实感的假数据、create_batch(10) 批量生成、build() 只构造不落库、Trait 表达不同状态组合。fixtures 仍有一席之地:不变的基础数据(省份、币种、权限初始化)用它更合适,因为那些数据本来就是「配置」而不是「测试场景」。
  • 追问:Django 项目里最值得写测试的是哪些部分? 按投入产出排序。① 权限与访问控制——「未登录用户访问 → 302 到登录页」「无权限用户访问 → 403」「只能看自己的数据」,这类 bug 的后果是数据泄露,而测试极其简单(几行 client.get + 断言状态码)。② 业务规则——状态流转(订单能否从「已取消」变回「已支付」)、金额计算、库存扣减、优惠券叠加,这些是真正的业务价值所在③ 表单/序列化器的校验——边界值、必填、格式、跨字段一致性,用 SimpleTestCase 就能测且飞快。④ 查询数量assertNumQueries,防性能回归)。⑤ 信号和 on_commit 的副作用(容易被忽略的隐式逻辑)。⑥ CI 里跑 makemigrations --check --dry-run——检查有没有人改了模型却忘记生成迁移,这条几乎零成本却能挡住线上事故。不值得测的:Django 框架自身的功能(ORM 能不能保存、filter 对不对)、第三方库的行为、纯粹的 getter/setter。

八、加强记忆

Django 测试的基础设施 = 建一个 test_ 前缀的独立数据库 + 跑完所有迁移 + 测完销毁。三个基类的区别全在「隔离方式」SimpleTestCase 默认禁止数据库访问(访问会抛 DatabaseOperationForbidden,适合表单、模板、纯函数);TestCase 把每个测试方法包在事务里、测完 ROLLBACK(数据库从未真正写入,所以,是默认选择);TransactionTestCase 真提交 + TRUNCATE 所有表,但行为与生产一致)。TestCase 测不了四类东西transaction.on_commit 的回调永远不触发(外层事务不提交)、select_for_update 的锁行为、其他数据库连接/线程看不到测试数据、以及完整的事务回滚语义——解法是 self.captureOnCommitCallbacks(execute=True)(3.2+) 或改用 TransactionTestCase。还要知道 Django 会重排测试顺序:先跑所有 TestCase 再跑 TransactionTestCase(后者会 TRUNCATE 表,先跑会破坏前者依赖的数据)。提速三件套PASSWORD_HASHERS 换 MD5(默认 PBKDF2 故意很慢,换掉后创建用户快几十倍)、--keepdbsetUpTestData(类级只建一次,配合方法级事务回滚),外加 --parallelforce_login(跳过密码校验)。数据准备用 setUpTestData + factory_boy(只声明关心的字段、SubFactory 自动建关联、模型加字段时不用改所有测试),别用 JSON fixtures(模型一改就全废、看不出依赖、加载慢)。assertNumQueries 是最有价值的断言——它把「查询次数」变成回归测试,有人删了 select_related 会立刻失败。其他常见坑:外部缓存和文件系统不会被事务回滚(要手动清理或 override_settings)、信号里的副作用每个测试都会触发(要 mock)、TransactionTestCase 默认不重置自增 ID(所以别断言具体的 pk)。pytest-django 的对应写法是 @pytest.mark.django_db(加 transaction=True 等价 TransactionTestCase 配合 --reuse-db --nomigrations