返回博客

先写测试,再写代码——Django TDD 的工程实践

TDD 这个词被说烂了。理论上每个人都知道「先写测试」,但真正在 Django 项目里坚持跑下来的,往往没几个。我自己也经历过一段时间的「补测试」阶段——功能写完了,再反向补一批断言,心里知道这不对,但就是提不起劲来先写。

转变发生在一次重构:有个核心的库存扣减逻辑,没有测试,我改完之后上线,当天凌晨就出了数据不一致的问题。从那之后,我彻底认可了 TDD 的价值——不是教条,而是一道防护网。

Red-Green-Refactor 不是仪式,是节奏

TDD 的核心循环是三步:先写一个必定失败的测试(Red),再写刚好够让它通过的代码(Green),最后在测试守护下做结构优化(Refactor)。

听起来简单,做起来有两个坑:

第一,Green 阶段不要过度设计。很多人一进入 Green 就开始写「优雅的代码」,结果越写越偏,测试也越来越难 pass。正确的做法是写最笨的实现——哪怕是硬编码返回值——先让测试绿了再说。

第二,Refactor 阶段不要加新功能。这一步只做结构整理,不扩展行为。如果想到了新需求,记下来,下一轮 Red 再开始。

# Step 1: RED — 先写测试,此时 reduce_stock 还不存在
def test_reduce_stock_raises_when_insufficient(db):
    product = ProductFactory(stock=5)
    with pytest.raises(ValueError):
        product.reduce_stock(10)
 
# Step 2: GREEN — 写最小实现让测试通过
def reduce_stock(self, quantity):
    if quantity > self.stock:
        raise ValueError("库存不足")
    self.stock -= quantity
    self.save()
 
# Step 3: REFACTOR — 加上原子操作、日志等,测试继续绿

测试基础设施:一次配好,终身受益

Django TDD 真正的成本不在写测试本身,而在基础设施搭建。把这几件事做好,后续的测试会轻松很多。

隔离的测试设置。测试用 SQLite 内存数据库,禁用 migration(用 DisableMigrations),密码哈希改用 MD5(这不影响安全性,只是让测试快 10 倍)。Celery 设置 TASK_ALWAYS_EAGER,异步任务同步执行,不需要跑 worker。

pytest-django + --reuse-db。首次跑会建库,之后直接复用,只有 schema 变了才重建。单个测试从 30 秒加速到 3 秒以内。

conftest.py 统一管理 fixture。把 useradmin_userauthenticated_clientapi_client 这些高频对象放在项目级 conftest,避免在每个测试文件里重复造轮子。

# pytest.ini 关键配置
addopts =
    --reuse-db
    --nomigrations
    --cov=apps
    --strict-markers
factory_boy 是 TDD 的乘法器

factory_boy 声明式定义测试数据工厂,比手写 Model.objects.create(...) 灵活得多。ProductFactory(price=50) 覆盖单个字段,其余自动填充;ProductFactory.create_batch(10) 批量造数据。工厂集中管理,字段变了只改一处,不用满文件搜索手写创建代码。

分层测试:模型、序列化器、视图各司其职

Django REST Framework 的测试分三层,各层关注点不同:

Model 层:测业务规则和约束。价格不能为负、库存扣减的边界条件、__str__ 方法、自定义 Manager 的过滤逻辑。这层测试最纯粹,不涉及 HTTP,运行最快。

Serializer 层:测序列化/反序列化的字段映射和校验逻辑。负价格能不能过、必填字段缺失的报错路径。很多人跳过这层,把校验逻辑放在 View 里测——代价是测试难以复用,且报错信息不精确。

API/View 层:测 HTTP 语义。401/403/201/204 这些状态码,权限控制(未登录能不能 POST),以及过滤、搜索参数是否生效。用 force_authenticate 绕过认证细节,专注业务逻辑。

外部依赖(支付网关、邮件、第三方 API)一律用 unittest.mock.patch 隔离。不要让测试依赖真实的外部服务——那叫集成测试,要单独标记、单独运行。

取舍与边界

TDD 不是万能药。以下几个场景我会主动降低测试优先级:

  • 纯粹的数据迁移脚本:一次性的,验证结果比写测试更直接。
  • Django admin 配置:list_display、搜索字段这类,改起来频率高,测试维护成本高于收益。
  • 第三方库的内部行为:不要测 Django ORM 的 SQL 生成,那是 Django 的责任,不是你的。

覆盖率目标我的经验值:Model 和 Service 层 90%+,View 层 80% 就够。不要为了凑覆盖率写无意义的测试——一个好的测试胜过十个断言 assert True 的凑数测试。

一句话心法

测试是代码行为的活文档;先写测试,是强迫自己在动手之前想清楚「这段代码到底该做什么」。