Python 单元测试与集成测试的边界划分

1次阅读

单元测试不该访问数据库,必须隔离外部依赖;应通过依赖注入和 mock 替换 db 调用;集成测试才覆盖 orm 行为、约束、事务等真实交互场景。

Python 单元测试与集成测试的边界划分

单元测试该不该访问数据库

不该。只要涉及数据库读写,就不再是单元测试,而是集成测试——这是最常被混淆的边界点。

单元测试的核心是隔离:用 unittest.mock.patchpytest-mock 替换掉所有外部依赖(DB、http、文件系统)。一旦你看到 setUpTestData 里调用了 MyModel.objects.create(),或者测试里出现了 assert obj in MyModel.objects.all(),那它已经越界了。

  • 真实场景中,djangoTestCase 默认开启事务并回滚,看起来“快”,但本质仍是集成测试——它依赖数据库 schema、迁移状态、甚至触发器逻辑
  • postgresql 的序列值、mysql 的 AUTO_INCREMENT 行为,在不同测试顺序下可能不一致,导致偶发失败
  • CI 环境若未预装对应数据库或权限不足,TestCase 会直接报 OperationalError: could not connect to server

怎么给带数据库操作的函数写真正的单元测试

把数据访问逻辑抽成可注入的依赖,再在测试中传入模拟实现。

比如一个统计用户活跃度的函数:

立即学习Python免费学习笔记(深入)”;

def calculate_user_score(user_id: int, db_client: DatabaseClient) -> float:     data = db_client.fetch_raw_metrics(user_id)  # ← 这个调用要能被替换     return sum(data) / len(data) if data else 0.0

测试时传入一个返回固定列表的 mock 对象

  • unittest.mock.Mock(spec=DatabaseClient),避免拼写错误导致静默失败
  • 显式设置 mock_db.fetch_raw_metrics.return_value = [10, 20, 15],而不是靠 side_effect 随机返回
  • 不要 mock DatabaseClient.__init__——它通常不做实际连接,mock 它反而掩盖构造逻辑问题
  • 如果函数内部硬编码了 django.db.connection,说明它没做依赖解耦,得先重构,再测

集成测试该覆盖哪些东西

只覆盖那些“必须连起来跑才说得清对错”的路径:ORM 查询链、事务边界、外键约束、数据库函数(如 COALESCEjsonb_extract_path)的实际行为。

典型例子:

  • Django 的 select_related + prefetch_related 组合是否真生成了预期 SQL?用 assertNumQueries 检查,不是靠 print
  • PostgreSQL 的 EXCLUDE using gist 约束是否阻止了时间重叠插入?得真正执行 save() 并捕获 IntegrityError
  • 使用 django.contrib.postgres.fields.JSONField 时,Filter(data__tags__contains=["a"]) 是否返回正确结果?JSON 查询行为高度依赖后端驱动和版本
  • 避免在集成测试里验证业务规则——比如“VIP 用户折扣不能超过 50%”,这属于单元测试范畴;集成测试只管“这个 discount 字段确实存进了 DB,并能被 WHERE 条件查出来”

为什么 pytest-django 的 –reuse-db 不稳定

因为它复用的是整个数据库实例,而多个测试模块可能并发修改同一张表的结构或数据,尤其当有人误用 makemigrations --empty 或手动执行 ALTER table 时。

常见现象包括:

  • django.core.exceptions.FieldError: Cannot resolve keyword 'xxx' into field —— 某个测试偷偷改了 model,但 migration 没提交,其他测试加载旧模型缓存
  • PostgreSQL 报 relation "auth_user" does not exist —— --reuse-db 假设 DB 是干净的,但 CI 中前序 job 可能残留临时表
  • sqlite 下看似正常,但切换到 PostgreSQL 后测试失败——因为 SQLite 忽略很多约束检查(如 CHECK、DEFERRABLE),掩盖了真实问题
  • 更稳妥的做法是:本地开发用 --reuse-db 加速,CI 中强制用 --create-db,哪怕慢一点,也确保每次都是干净起点

边界从来不是靠命名区分的,而是看它是否引入了不可控变量。数据库、网络、时钟、随机数——只要有一个在动,你就已经不在单元测试的地盘上了。

text=ZqhQzanResources