测试数据怎么管理?factory 和 fixture 文件该怎么选?
简化版
测试数据管理的核心矛盾是「每个测试需要不同的数据,但准备数据的代码不该淹没测试意图」。三种主流方案:① JSON/YAML fixture 文件(Django 的 loaddata、手写的 JSON)——看起来整洁但维护性最差:模型加一个非空字段,所有 fixture 文件立刻全部失效;而且从测试里完全看不出它依赖了哪几条数据,久而久之没人敢删。② 工厂(factory_boy / polyfactory)——当前的主流做法:定义一个「合法默认值」的工厂,测试里只写自己关心的字段(UserFactory(is_admin=True)),其余自动填充;模型加字段时只改工厂一处。③ 直接在测试里构造——最直白,数据量小时反而最清晰。实践中的推荐是「工厂为主 + 少量真正静态的种子数据用文件」(省份表、币种、权限初始化这类「配置性质」的数据)。除了「怎么造」,还有三个同等重要的问题:数据隔离(每个测试自己准备、用事务回滚清理,绝不能依赖前一个测试留下的数据);唯一性冲突(工厂要用 Sequence 生成唯一的 email、用户名,否则并行或多次创建时撞唯一约束);以及「测试意图的可读性」——UserFactory(age=17) 配上变量名 underage_user 比一行 15 个字段的构造清晰得多。一条重要原则:测试数据应该「最小且相关」——只创建这个测试真正需要的数据,多余的字段和关联对象不但拖慢测试,还会掩盖真正的依赖关系。核心记忆:工厂为主、文件只用于静态种子;只写关心的字段,其余用默认值;用 Sequence 防唯一冲突;数据最小且相关。
详细版
三种方案对比:
| fixture 文件 | 工厂(factory) | 测试内构造 | |
|---|---|---|---|
| 模型改字段 | 全部失效 | 只改工厂一处 | 改所有用到的地方 |
| 看出依赖 | ❌ 看不出 | ✅ 只写关心的 | ✅ 最直白 |
| 关联对象 | 手写外键 id | ✅ SubFactory 自动 | 手动创建 |
| 唯一性 | 手动保证 | ✅ Sequence | 手动 |
| 适合 | 静态种子数据 | ✅ 绝大多数场景 | 数据极简时 |
# ① ★★factory_boy:主流方案★★
import factory
from factory.django import DjangoModelFactory # 或 factory.alchemy.SQLAlchemyModelFactory
class UserFactory(DjangoModelFactory):
class Meta:
model = User
★django_get_or_create = ("email",)★ # ★避免重复创建★
# ★Sequence:保证唯一★
★email = factory.Sequence(lambda n: f"user{n}@example.com")★
★username = factory.Sequence(lambda n: f"user{n}")★
# ★Faker:真实感的假数据★
★name = factory.Faker("name", locale="zh_CN")★
age = factory.Faker("pyint", min_value=18, max_value=80)
# ★LazyAttribute:依赖其他字段★
★display = factory.LazyAttribute(lambda o: f"{o.name}({o.username})")★
# ★LazyFunction:每次调用★
★created_at = factory.LazyFunction(timezone.now)★
is_active = True
class OrderFactory(DjangoModelFactory):
class Meta:
model = Order
★user = factory.SubFactory(UserFactory)★ # ★★自动创建关联对象★★
total = factory.Faker("pydecimal", left_digits=4, right_digits=2,
positive=True)
status = "pending"
# ★使用:只写关心的字段★
def test_admin_can_delete():
admin = ★UserFactory(is_admin=True)★ # ★其余字段自动填充★
normal = UserFactory()
assert can_delete(admin, normal) is True
# ★批量★
users = ★UserFactory.create_batch(10)★
# ★不落库(只构造对象)★
user = ★UserFactory.build()★
users = UserFactory.build_batch(5)
# ★只要参数字典★
data = ★factory.build(dict, FACTORY_CLASS=UserFactory)★
# ② ★★Trait:表达"状态变体"★★
class OrderFactory(DjangoModelFactory):
class Meta:
model = Order
status = "pending"
paid_at = None
class Params:
★paid = factory.Trait(status="paid", paid_at=factory.LazyFunction(now))★
★cancelled = factory.Trait(status="cancelled",
cancel_reason="用户取消")★
order = ★OrderFactory(paid=True)★ # ★★语义清晰★★
# ③ ★★PostGeneration:创建后的额外操作★★
class UserFactory(DjangoModelFactory):
class Meta:
model = User
★@factory.post_generation★
def groups(self, create, extracted, **kwargs):
if not create: return
if extracted:
for g in extracted: self.groups.add(g) # ★多对多★
user = UserFactory(groups=[admin_group, editor_group])
# ★密码要 hash★
@factory.post_generation
def password(self, create, extracted, **kwargs):
self.set_password(extracted or "test1234")
if create: self.save()
# ④ ★polyfactory:从 Pydantic/dataclass 自动生成★
from polyfactory.factories.pydantic_factory import ModelFactory
class UserCreateFactory(ModelFactory[UserCreate]):
★__model__ = UserCreate★ # ★★字段全自动推导★★
data = UserCreateFactory.build() # ★合法的随机数据★
data = UserCreateFactory.build(email="fixed@x.com")
# ⑤ ★★工厂 fixture(pytest 集成)★★
@pytest.fixture
def make_user(db):
created = []
def _make(**kwargs):
u = UserFactory(**kwargs)
created.append(u)
return u
yield _make
# ★事务回滚的话不需要手动清理★
def test_x(make_user):
alice = make_user(name="alice")
admin = make_user(is_admin=True)
# ⑥ ★静态种子数据(少数场景)★
@pytest.fixture(scope="session", autouse=True)
def seed_static_data(django_db_setup, django_db_blocker):
with django_db_blocker.unblock():
★Currency.objects.bulk_create([...])★ # ★★不变的配置数据★★
Province.objects.bulk_create([...])
⚠️ 三个必须记住的点:① 工厂的核心价值是「测试里只写关心的字段」。
UserFactory(is_admin=True)这一行清楚地表达了「这个测试只关心用户是不是管理员」,其余的 email、name、created_at 由工厂填充合法默认值。这带来两个收益:测试的意图一目了然(读的人立刻知道哪个字段是关键),以及模型加字段时只需要改工厂一处——而如果你在 50 个测试里手写User(email=..., name=..., age=..., is_active=...),加一个非空字段就要改 50 处。②Sequence是防唯一约束冲突的关键。email = factory.Sequence(lambda n: f"user{n}@example.com")保证每次创建的邮箱都不同——如果写成固定值"test@example.com",第二次调用工厂就会撞唯一约束,而且这类错误在并行执行(pytest -n auto)下更容易出现。同理适用于用户名、订单号、身份证号等所有唯一字段。③ JSON/YAML fixture 文件的最大问题不是「加载慢」而是「不可维护」。它是数据库某一刻的完整快照,模型加一个非空字段,所有 fixture 文件立刻全部失效(要么报错、要么静默填 null 导致后续断言诡异);更麻烦的是从测试里完全看不出它依赖了文件里的哪几条记录——fixtures = ["all_data.json"]加载了 200 条数据,某个测试到底依赖哪一条?久而久之没人敢删任何一条,文件越滚越大。它唯一合适的场景是**「真正静态的配置数据」**(省份、币种、权限定义)。
完整版教学
一、三种方案的取舍
★ ★★方案一:fixture 文件(JSON/YAML/SQL)★★
# Django
★python manage.py dumpdata > tests/fixtures/data.json★
class MyTest(TestCase):
★fixtures = ["data.json"]★
★ ✓ 优点:
- ★一次准备,多处复用★
- ★非开发者也能编辑★
- 适合"就是这批数据"的场景
★ ✗ ★致命缺点★:
① ★★模型加非空字段 → 所有文件失效★★
② ★★看不出测试依赖哪条数据★★
fixtures = ["all.json"] # ★200 条记录,这个测试用哪条?★
→ ★没人敢删,文件只增不减★
③ ★加载慢★(每个测试都要 load)
④ ★数据和测试分离,改数据要跳文件★
⑤ ★重构时 IDE 找不到引用★
★ ★★方案二:工厂(推荐)★★
★ ✓ ★只声明关心的字段★
★ ✓ ★模型改动只改工厂一处★
★ ✓ ★SubFactory 自动处理关联★
★ ✓ ★Sequence 保证唯一★
★ ✓ ★IDE 能补全和跳转★
★ ✗ 要学一点 API
★ ✗ ★工厂本身也会腐化★(默认值越加越多)
★ ★方案三:测试内直接构造★
def test_discount():
★items = [Item(price=Decimal("100"), qty=2)]★ # ★极简、极清晰★
assert calculate_total(items, rate=0) == Decimal("200")
★ ✓ ★纯函数测试的最佳选择★(不涉及数据库)
★ ✓ 零依赖、零抽象
★ ✗ 对象复杂时代码膨胀
★ ★★选择标准★★:
┌────────────────────────────────┬──────────────────┐
│ ★纯函数、值对象、2~3 个字段★ │ ★直接构造★ │
│ ★需要落库的领域对象★ │ ★★工厂★★ │
│ ★真正静态的配置数据★ │ ★种子文件/脚本★ │
│ (省份、币种、权限) │ │
│ ★大量真实数据的性能测试★ │ ★脚本生成★ │
└────────────────────────────────┴──────────────────┘
★ ★"最小且相关"原则(★最重要的原则★)★:
✗ def test_user_can_login():
user = ★UserFactory(★
name="张三", age=30, address="...", phone="...",
company="...", department="...", ...) # ★★噪音★★
assert login(user.email, "pw")
✓ def test_user_can_login():
user = ★UserFactory()★ # ★只要一个合法用户★
assert login(user.email, "pw")
✓ def test_underage_cannot_purchase():
★underage = UserFactory(age=17)★ # ★★只写关键字段★★
assert not can_purchase(underage)
★ ★读的人立刻知道:age 是这个测试的关键★
★ ★多余的字段会掩盖真正的依赖关系★
★ ★数据量的克制★:
✗ ★每个测试都 create_batch(100)★
→ 慢 + 掩盖问题(100 条里哪条触发了 bug?)
✓ ★用最少的数据表达场景★
- 测"列表分页":★创建 3 条,per_page=2★(够验证边界了)
- 测"排序":★2~3 条足以★
✓ ★真的需要大数据量 → 单独的性能测试★
三种方案的选择标准很清晰:纯函数和值对象直接构造(最清晰)、需要落库的领域对象用工厂、真正静态的配置数据用种子文件。fixture 文件的致命缺点不是慢而是不可维护——模型加字段全部失效、看不出测试依赖哪条数据导致没人敢删、重构时 IDE 找不到引用。「最小且相关」是最重要的原则:UserFactory(age=17) 让读的人立刻知道「age 是这个测试的关键」,而写满 10 个字段会掩盖真正的依赖关系。数据量也要克制——测分页创建 3 条配 per_page=2 就够了,create_batch(100) 既慢又掩盖问题(100 条里哪条触发了 bug?)。
二、工厂的进阶用法
★ ★★声明式字段的几种类型★★:
★Sequence★ 每次递增(★保证唯一★)
email = factory.Sequence(lambda n: f"u{n}@x.com")
★LazyFunction★ 每次调用一个函数(★不依赖其他字段★)
created = factory.LazyFunction(timezone.now)
★LazyAttribute★ ★依赖同一对象的其他字段★
slug = factory.LazyAttribute(lambda o: slugify(o.title))
email = factory.LazyAttribute(lambda o: f"{o.username}@x.com")
★Faker★ 真实感的假数据
name = factory.Faker("name", locale="zh_CN")
address = factory.Faker("address")
★SubFactory★ ★关联对象(自动创建)★
author = factory.SubFactory(UserFactory)
★SelfAttribute★ 引用父工厂的字段
user = factory.SubFactory(UserFactory,
name=factory.SelfAttribute("..order.customer_name"))
★Iterator★ 循环取值
status = factory.Iterator(["pending", "paid", "shipped"])
★Maybe★ 条件字段
paid_at = factory.Maybe("is_paid",
yes_declaration=factory.LazyFunction(now),
no_declaration=None)
★ ★★build vs create(★重要区别★)★★:
★UserFactory.build()★ # ★★只构造对象,不落库★★
★UserFactory.create()★ # ★构造 + 保存(★UserFactory() 默认就是 create★)★
★UserFactory.stub()★ # ★只有属性的简单对象★
★ ✓ ★纯逻辑测试用 build(快得多,不碰数据库)★
★ ✗ 注意:★build 时 SubFactory 也不会落库★
→ ★关联对象没有 id,存库时会出错★
✓ 用 ★factory.SubFactory(..., strategy=factory.CREATE_STRATEGY)★
★ ★★Trait:表达业务状态(★可读性神器★)★★:
class Params:
★paid = factory.Trait(status="paid", paid_at=LazyFunction(now))★
★refunded = factory.Trait(status="refunded",
refund_amount=SelfAttribute("total"))★
★vip_customer = factory.Trait(
user=factory.SubFactory(UserFactory, level="vip"))★
# 使用
★OrderFactory(paid=True)★
★OrderFactory(paid=True, vip_customer=True)★ # ★可组合★
★ ✓ ★比 OrderFactory(status="paid", paid_at=..., ...) 清晰得多★
★ ★工厂继承(表达子类型)★:
class UserFactory(DjangoModelFactory):
class Meta: model = User
is_admin = False
★class AdminFactory(UserFactory):★
is_admin = True
★permissions = ["all"]★
★ ✓ ★比每次传参数清晰★
★ ★post_generation:创建后的操作★:
@factory.post_generation
def tags(self, create, extracted, **kwargs):
if not create: return # ★build 时跳过★
if extracted:
self.tags.set(extracted) # ★多对多★
else:
self.tags.add(TagFactory()) # ★默认给一个★
# ★密码(必须 hash)★
@factory.post_generation
def password(self, create, extracted, **kwargs):
self.set_password(extracted or "test1234")
if create: self.save()
★ ✗ ★注意 post_generation 会触发额外的 save()★
★ ★SQLAlchemy 版本★:
class UserFactory(★factory.alchemy.SQLAlchemyModelFactory★):
class Meta:
model = User
★sqlalchemy_session = Session★ # ★要注入 session★
★sqlalchemy_session_persistence = "flush"★ # ★或 "commit"★
# ★配合 pytest fixture 动态注入 session★
@pytest.fixture(autouse=True)
def _set_factory_session(db_session):
UserFactory._meta.sqlalchemy_session = db_session
OrderFactory._meta.sqlalchemy_session = db_session
★ ★polyfactory(Pydantic/dataclass 首选)★:
from polyfactory.factories.pydantic_factory import ModelFactory
class UserFactory(ModelFactory[User]):
__model__ = User
# ★不写的字段全部按类型自动生成★
★email = lambda: f"{uuid4().hex}@example.com"★ # 覆盖某个字段
★ ✓ ★字段全自动推导(连嵌套模型都能生成)★
★ ✓ ★适合 FastAPI 项目的 schema★
工厂的声明式字段里几个高频的:Sequence 保证唯一、LazyAttribute 依赖同一对象的其他字段(slug 从 title 生成)、SubFactory 自动创建关联对象、Trait 表达业务状态(OrderFactory(paid=True) 比传三个字段清晰得多,而且可组合)。build 和 create 的区别很重要——build 只构造对象不落库,纯逻辑测试用它快得多,但要注意 SubFactory 也不会落库导致关联对象没有 id。SQLAlchemy 版本要注入 session(通常用 autouse fixture 动态设置)。Pydantic 和 dataclass 首选 polyfactory——字段全自动推导,连嵌套模型都能生成。
三、数据隔离
★ ★★核心原则:每个测试自己准备、自己不留痕迹★★
✗ ★依赖前一个测试留下的数据★
def test_create_user(): User.objects.create(name="alice")
def test_list_users(): ★assert User.objects.count() == 1★ # ★依赖顺序★
✓ 每个测试独立准备
★ ★三种隔离机制(速度对比)★:
┌────────────────────┬────────────┬──────────────────────┐
│ ★事务回滚★ │ ★★~1ms★★ │ ★★推荐★★ │
│ ★TRUNCATE 表★ │ ~10~50ms │ 被测代码有 commit 时 │
│ ★重建数据库★ │ ~1~5s │ ★太慢,仅特殊场景★ │
└────────────────────┴────────────┴──────────────────────┘
★ ★★事务回滚的标准实现★★:
@pytest.fixture
def db_session(engine):
conn = engine.connect()
★trans = conn.begin()★ # ★外层事务★
session = Session(bind=conn)
yield session
session.close()
★trans.rollback()★ # ★★丢弃所有改动★★
conn.close()
# ★Django:pytest-django 的 db fixture 默认就是事务回滚★
# ★FastAPI/SQLAlchemy:如上★
★ ★★被测代码 commit 时的破功问题★★:
★ 如果 service 里有 session.commit(),外层事务就提交了 → 回滚失效
✓ ★SAVEPOINT 嵌套事务★:
@pytest.fixture
def db_session(engine):
conn = engine.connect(); trans = conn.begin()
session = Session(bind=conn)
★session.begin_nested()★ # ★SAVEPOINT★
@event.listens_for(session, "after_transaction_end")
def restart(sess, t):
if t.nested and not t._parent.nested:
★sess.begin_nested()★ # ★★重新开 SAVEPOINT★★
yield session
session.close(); trans.rollback(); conn.close()
★ ✓ ★应用的 commit 只提交 SAVEPOINT,外层仍可整体回滚★
★ ★★并行执行下的隔离(xdist)★★:
@pytest.fixture(scope="session")
def db_url(★worker_id★):
if worker_id == "master":
return BASE_URL
return f"{BASE_URL}_{worker_id}" # ★★每 worker 一个库★★
★ ✓ Django:pytest-django 会自动加 _gw0 后缀
★ ✓ Redis:用不同的 db 号
★ ✓ 文件:tmp_path 自动隔离
★ ★★全局状态也要隔离(★常被忽略★)★★:
@pytest.fixture(autouse=True)
def _reset():
yield
★cache.clear()★ # 缓存
★app.dependency_overrides.clear()★
★SomeSingleton._instance = None★
★search_index.clear()★ # ★搜索索引★
★celery_app.conf.task_always_eager = True★
★ ★数据库回滚了,但缓存里的旧数据还在 → 下个测试读到脏数据★
★ ★静态种子数据的处理★:
★ 问题:币种、省份这类"每个测试都需要但从不改变"的数据
✓ ★session 级创建一次,且不在事务里★:
@pytest.fixture(scope="session", autouse=True)
def seed(django_db_setup, django_db_blocker):
with django_db_blocker.unblock():
Currency.objects.bulk_create([...])
✓ ★或者放进迁移(data migration)★
★ ✗ 不要在每个测试的 fixture 里重复创建
★ ★测试之间的"时间"隔离★:
✗ 某个测试用 freeze_time 改了时间但没还原
✓ ★用装饰器或上下文管理器(自动还原)★
✓ ★autouse fixture 兜底★
数据隔离的核心原则是「每个测试自己准备、自己不留痕迹」。三种机制里事务回滚最快(约 1ms)也最推荐,但被测代码里有 commit() 时会破功——解法是 SAVEPOINT 嵌套事务(监听 after_transaction_end 重新开 SAVEPOINT,让应用的 commit 只提交 SAVEPOINT)。并行执行下要按 worker_id 给每个 worker 独立的数据库。最容易被忽略的是「全局状态也要隔离」——数据库回滚了,但缓存里的旧数据还在,下一个测试就读到脏数据;同样需要清理的还有 dependency_overrides、单例、搜索索引。静态种子数据要 session 级创建一次且不在事务里,或者干脆放进 data migration。
四、可读性与维护性
★ ★★测试数据应该"讲故事"★★:
✗ def test_x():
u = UserFactory(age=17, is_verified=False, balance=0)
o = OrderFactory(user=u, total=100)
assert process(o).status == "rejected"
# ★★读的人:到底哪个字段导致了 rejected?★★
✓ def test_underage_user_order_rejected():
★underage_user = UserFactory(age=17)★ # ★★变量名 + 关键字段★★
order = OrderFactory(user=underage_user)
assert process(order).status == "rejected"
# ★一眼看出:因为未成年★
★ ★三条可读性规则★:
① ★★变量名说明"这是什么角色"★★
admin / underage_user / expired_coupon / oversized_file
② ★★只写与断言相关的字段★★
③ ★★用 Trait 或子工厂表达状态★★
OrderFactory(paid=True) > OrderFactory(status="paid", paid_at=...)
★ ★★Arrange-Act-Assert 的结构★★:
def test_discount_applied():
# ★Arrange:准备(★最小化★)★
user = UserFactory(level="vip")
cart = CartFactory(user=user, items=[ItemFactory(price=100)])
# ★Act:执行(★一行★)★
★result = checkout(cart)★
# ★Assert:断言(★聚焦★)★
assert result.total == Decimal("90")
★ ✓ ★空行分隔三段,结构一目了然★
★ ★★工厂的腐化(★要警惕★)★★:
# ★半年后的工厂★
class UserFactory(DjangoModelFactory):
name = ...
email = ...
★# 下面 30 个字段是各种测试陆续加的★
preference_json = ...
last_login_ip = ...
★avatar = factory.django.ImageField()★ # ★★每次都生成图片!★★
★subscription = factory.SubFactory(SubFactory)★ # ★★每次都建关联★★
★ ✗ 后果:★创建一个用户要几百毫秒 + 建 5 个关联对象★
✓ ★对策★:
- ★默认值保持最小★(只填必填字段)
- ★可选的关联用 Trait 或 post_generation 按需创建★
- ★定期审查工厂的成本★(--durations 里 setup 慢就看它)
★ ★★避免"共享的可变测试数据"★★:
✗ # conftest.py
★SHARED_USER = UserFactory()★ # ★★模块级★★
def test_a(): SHARED_USER.name = "changed" # ★污染★
def test_b(): assert SHARED_USER.name == "..." # ★★依赖顺序★★
✓ ★用 fixture(每个测试新建)★
✓ ★或者用不可变的常量★
★ ★★工厂 vs fixture:怎么配合★★:
# ★fixture 负责"这个测试用的具体对象"★
@pytest.fixture
def admin_user(db):
return ★UserFactory(is_admin=True)★
# ★工厂负责"怎么造一个合法对象"★
# ★工厂 fixture 负责"让测试能自己造"★
@pytest.fixture
def make_order(db):
def _make(**kw): return OrderFactory(**kw)
return _make
★ ★三者配合:★
- ★多个测试共用同一个对象 → fixture★
- ★每个测试要造不同的 → 工厂 fixture★
- ★纯逻辑测试 → 直接调工厂的 build()★
★ ★数据构造的性能★:
★ 每个 SubFactory 都是一次额外的 INSERT
OrderFactory() → ★创建 Order + User + Address + ...★
✓ ★复用已有对象★:
user = UserFactory()
orders = ★OrderFactory.create_batch(5, user=user)★ # ★★只建一个用户★★
✓ ★用 build() 避免落库★
✓ ★批量创建用 create_batch(内部可能有优化)★
测试数据应该「讲故事」——underage_user = UserFactory(age=17) 让读的人一眼看出「因为未成年所以被拒绝」,而写满字段会让人猜不出哪个是关键。三条可读性规则:变量名说明角色、只写与断言相关的字段、用 Trait 表达状态。要警惕「工厂的腐化」——半年后各种测试陆续往工厂里加了 30 个字段,其中 ImageField 每次生成图片、SubFactory 每次建关联对象,创建一个用户要几百毫秒;对策是默认值保持最小、可选关联用 Trait 按需创建、定期审查工厂成本。工厂和 fixture 的配合:多个测试共用同一个对象用 fixture、每个测试要造不同的用工厂 fixture、纯逻辑测试直接用 build()。
五、特殊场景
★ ★① 真实感数据(Faker)★:
from faker import Faker
fake = Faker("zh_CN")
fake.name() / fake.address() / fake.company()
fake.phone_number() / fake.email() / fake.ssn()
★ ✓ ★演示、截图、性能测试时有用★
★ ✗ ★单元测试里"真实感"没有价值★——反而降低可读性
✗ user = UserFactory() # name="张伟" —— ★这个名字重要吗?★
✓ 断言相关的字段用明确的值
★ ★固定种子保证可复现★:Faker.seed(0)
★ ★★② 敏感数据(★绝不能用真实数据★)★★:
✗ ★从生产库导数据到测试环境★
→ ★★违反隐私法规(GDPR/个保法)+ 泄露风险★★
✓ ★用工厂生成★
✓ ★必须用生产数据形态时 → 脱敏★:
- ★姓名/手机/邮箱/身份证 → 替换★
- ★保留统计特征(长度分布、格式)★
- ★不可逆(不能只是简单替换)★
✓ ★用专门的脱敏工具或生成合成数据★
★ ★③ 大数据量(性能测试)★:
✗ 用工厂逐个创建 10 万条(★几分钟★)
✓ ★bulk_create / COPY★:
Model.objects.bulk_create(
[Model(**d) for d in data], ★batch_size=1000★)
✓ ★PostgreSQL 的 COPY★(最快)
✓ ★或者预先准备好的数据库快照/dump★
★ ★性能测试的数据准备应该和功能测试分开★
★ ★④ 时间相关的数据★:
✗ ★created_at = datetime(2024, 1, 1)★ # ★★硬编码,会过期★★
assert obj.is_recent() # ★两年后就挂了★
✓ ★相对时间★:
created_at = factory.LazyFunction(
lambda: timezone.now() - timedelta(days=1))
✓ ★或冻结时间★:
@freeze_time("2026-01-15")
def test_x(): ...
★ ★原则:要么全部相对"现在",要么全部冻结★
★ ★⑤ 依赖外部 ID 的数据★:
✗ ★user_id = 1★ # ★假设数据库是空的★
✓ ★user = UserFactory(); order.user_id = user.id★
★ ★永远不要假设自增 ID 的具体值★
★ ★⑥ 多租户/多语言数据★:
class TenantFactory(DjangoModelFactory):
name = factory.Sequence(lambda n: f"tenant{n}")
class UserFactory(DjangoModelFactory):
★tenant = factory.SubFactory(TenantFactory)★
# ★测试租户隔离★
def test_tenant_isolation():
t1, t2 = TenantFactory(), TenantFactory()
★u1 = UserFactory(tenant=t1)★
★u2 = UserFactory(tenant=t2)★
assert list(users_of(t1)) == [u1] # ★★关键断言★★
★ ★⑦ 状态机数据(各种状态的对象)★:
# ★用 Trait 组合出各状态★
class Params:
draft = factory.Trait(status="draft")
submitted = factory.Trait(status="submitted",
submitted_at=LazyFunction(now))
approved = factory.Trait(status="approved",
approver=factory.SubFactory(UserFactory))
# ★参数化测试各状态的行为★
@pytest.mark.parametrize("state,can_edit", [
("draft", True), ("submitted", False), ("approved", False)])
def test_edit_permission(state, can_edit):
doc = DocFactory(★**{state: True}★)
assert doc.can_edit() is can_edit
特殊场景里几个关键点:Faker 的「真实感」在单元测试里没有价值(name="张伟" 对测试逻辑毫无意义,反而降低可读性),它适合演示和性能测试。敏感数据绝不能从生产库导——违反隐私法规且有泄露风险,必须用工厂生成或做不可逆的脱敏。大数据量用 bulk_create 或 COPY,而且性能测试的数据准备应该和功能测试分开。时间相关的数据不要硬编码(datetime(2024,1,1) 两年后测试就挂了)——要么全部相对「现在」、要么全部冻结。永远不要假设自增 ID 的具体值。
六、实践清单
★ ★推荐的组织结构★:
tests/
├── conftest.py # ★全局 fixture★
├── ★factories.py★ # ★★所有工厂集中定义★★
├── helpers.py # 断言辅助、工具函数
├── fixtures/ # ★仅静态种子数据★
│ └── currencies.json
└── unit/ integration/ ...
# factories.py
class UserFactory(DjangoModelFactory): ...
class OrderFactory(DjangoModelFactory): ...
# conftest.py
@pytest.fixture
def admin_user(db): return UserFactory(is_admin=True)
★ 检查清单:
【方案选择】
□ ★工厂为主,文件只用于静态种子数据★
□ ★纯函数测试直接构造对象(不用工厂)★
□ ★工厂集中在 factories.py★
【工厂设计】
□ ★唯一字段用 Sequence★
□ ★默认值保持最小(只填必填)★
□ ★可选关联用 Trait/post_generation 按需创建★
□ ★状态变体用 Trait 而不是传一堆字段★
□ ★纯逻辑测试用 build() 不落库★
【隔离】
□ ★每个测试自己准备数据★
□ ★事务回滚做隔离(有 commit 时用 SAVEPOINT)★
□ ★缓存/单例/overrides 也要清理★
□ ★并行下按 worker 隔离数据库★
【可读性】
□ ★变量名表达角色(underage_user)★
□ ★只写与断言相关的字段★
□ ★Arrange-Act-Assert 结构清晰★
□ ★数据量最小(测分页 3 条就够)★
【安全】
□ ★绝不使用生产数据★
□ ★工厂不生成真实的手机号/身份证(用假格式)★
★ ★★常见问题速查★★:
┌────────────────────────────────────┬──────────────────┐
│ UNIQUE constraint failed │ ★没用 Sequence★ │
│ 测试单跑过全跑挂 │ ★数据没隔离★ │
│ setup 很慢 │ ★工厂腐化/关联太多★│
│ 改模型后一堆测试挂 │ ★用了 fixture 文件★│
│ 并行时数据串 │ ★没按 worker 隔离★│
│ 两年后测试突然挂 │ ★硬编码了日期★ │
│ 数据库回滚了但结果还是脏的 │ ★缓存没清★ │
└────────────────────────────────────┴──────────────────┘
★ 一句话总结:
★"测试数据用工厂为主(只写关心的字段、其余自动填充,
模型改动只改一处),JSON fixture 文件只留给真正静态的种子数据;
唯一字段一定要用 Sequence 防冲突,状态变体用 Trait 表达;
隔离靠事务回滚(有 commit 就用 SAVEPOINT),
别忘了缓存和单例也要清;
最重要的原则是『最小且相关』——只造这个测试真正需要的数据。"★
推荐的组织结构是工厂集中在 factories.py、fixture 在 conftest.py、静态种子数据单独放目录。问题速查表里几条高频的:UNIQUE constraint failed 是没用 Sequence、setup 很慢是工厂腐化或关联太多、改模型后一堆测试挂是用了 fixture 文件、数据库回滚了但结果还是脏的是缓存没清。
记忆钩子:「测试数据管理的核心矛盾是★『每个测试需要不同的数据,但准备数据的代码不该淹没测试意图』★。三种方案:★① JSON/YAML fixture 文件——看起来整洁但维护性最差★:★模型加一个非空字段所有文件立刻失效★、★从测试里完全看不出它依赖哪几条数据导致没人敢删、文件只增不减★、加载慢、IDE 找不到引用 → ★只适合真正静态的配置数据(省份、币种、权限)★;★② 工厂(factory_boy / polyfactory)——当前主流★:★测试里只写自己关心的字段(UserFactory(is_admin=True)),其余自动填充合法默认值★,★模型加字段只改工厂一处★(对比手写要改 50 处);★③ 纯函数和值对象直接构造最清晰★。★工厂的关键 API★:★Sequence 保证唯一(写成固定值第二次创建就撞唯一约束,并行下更容易暴露)★、★LazyAttribute 依赖同对象的其他字段★、★SubFactory 自动创建关联★、★Trait 表达业务状态且可组合(OrderFactory(paid=True) 远好过传三个字段)★、★build() 只构造不落库(纯逻辑测试快得多,但 SubFactory 也不落库导致关联对象没 id)★。★要警惕工厂腐化★——半年后各种测试往里加了 30 个字段,★ImageField 每次生成图片、SubFactory 每次建关联,创建一个用户几百毫秒★ → ★默认值保持最小、可选关联用 Trait 按需创建★。★数据隔离★:★事务回滚约 1ms 最推荐,但被测代码有 commit 会破功 → 用 SAVEPOINT 嵌套事务★(监听 after_transaction_end 重新开);★并行下按 worker_id 给每个 worker 独立数据库★;★最容易忽略的是『数据库回滚了但缓存里的旧数据还在』——缓存/单例/dependency_overrides 都要清★;★静态种子数据 session 级创建一次且不在事务里★。★最重要的原则是『最小且相关』★:★UserFactory(age=17) 配变量名 underage_user 让人一眼看出关键★,★多余字段会掩盖真正的依赖关系★;★数据量也要克制——测分页 3 条配 per_page=2 就够,create_batch(100) 既慢又掩盖问题★。其他要点:★时间数据别硬编码(datetime(2024,1,1) 两年后会挂),要么全部相对『现在』要么全部冻结★、★永远不要假设自增 ID 的具体值★、★绝不能从生产库导数据(违反隐私法规)★、★Faker 的『真实感』在单元测试里没价值反而降低可读性★。」
七、常见误区与追问
- 误区:用
dumpdata导出一份完整的测试数据 JSON,所有测试共用,最省事。 短期最省事,长期是维护灾难。三个问题会逐渐浮现:① 模型演进即失效——给模型加一个非空字段(没有默认值),所有 fixture 文件立刻全部报错;就算加了默认值,那些文件里的数据也永远停留在旧的形态。② 依赖关系不可见——fixtures = ["all_data.json"]加载了 200 条记录,但某个具体的测试到底依赖哪一条?没人说得清,于是没人敢删任何一条数据,文件只增不减,最后变成一个几千行、包含大量无用数据的巨型文件,每个测试都要加载它。③ 重构不友好——改字段名时 IDE 的重命名功能不会碰 JSON 文件,只能全文搜索。工厂方案则把「数据的形状」和「数据的值」分开了:形状由工厂统一维护(改一处),值由每个测试按需指定(意图清晰)。JSON fixture 唯一合适的场景是「真正静态的配置数据」——币种、省份、权限定义这类几年不变、且本质上是「配置」而不是「测试场景」的数据。 - 误区:工厂的默认值写得越全越好,这样测试里就什么都不用写了。 默认值越多,工厂越慢、测试越不透明。常见的腐化路径:某个测试需要用户有头像,于是往工厂里加了
avatar = factory.django.ImageField()——从此每次创建用户都会生成一张图片;另一个测试需要订阅信息,加了subscription = factory.SubFactory(SubscriptionFactory)——从此每次创建用户都会多插一条订阅记录(而那条记录又可能带出更多关联)。半年后,UserFactory()一次调用要几百毫秒、插入五张表。正确的做法是「默认值只填必填字段」(数据库层面 NOT NULL 且没有默认值的),可选的关联和重资源用Trait或post_generation按需创建——UserFactory(with_avatar=True)。判断信号:pytest --durations里如果大量测试的 setup 时间接近,去看工厂就对了。 - 误区:所有唯一字段随便写个固定值就行,反正每个测试都会回滚。 同一个测试里创建两个对象就会撞唯一约束。
email = "test@example.com"这样的写法,UserFactory()调用第一次没问题,第二次就报UNIQUE constraint failed——而「一个测试里创建两个用户」是极常见的场景(测试权限、测试列表、测试关联)。正确做法是用factory.Sequence(lambda n: f"user{n}@example.com"),factory_boy 会维护一个自增计数器保证每次不同。需要注意几点:① Sequence 的计数器在整个进程内递增,所以并行(xdist)时不同 worker 各有各的计数器——如果它们共用一个数据库就还是会冲突,所以并行时数据库也要按 worker 隔离;② 如果测试之间不回滚(比如用了 TRUNCATE 方案),计数器和实际数据可能不同步;③ 复合唯一约束(unique_together)需要保证组合唯一,可能要用LazyAttribute结合多个字段。 - 误区:数据库用了事务回滚,测试之间就完全隔离了。 只隔离了数据库,其他状态照样会串。最常见的漏网之鱼是缓存:测试 A 查询了某个用户,结果被缓存进 Redis 或进程内存;测试 A 的数据库改动回滚了,但缓存里那条记录还在——测试 B 读到的是一条「数据库里已经不存在的数据」,表现为莫名其妙的断言失败,而且单跑测试 B 时完全正常。同类的还有:搜索索引(Elasticsearch 的写入不会跟着数据库事务回滚)、
app.dependency_overrides(FastAPI)、单例对象的内部状态、模块级的缓存字典、freeze_time没还原、环境变量被改了。解法是一个autouse=True的清理 fixture,在 teardown 里把这些统统重置。还有一个更隐蔽的:被测代码里的commit()会让外层事务提交,导致回滚失效——需要用 SAVEPOINT 嵌套事务方案。 - 误区:测试数据用 Faker 生成真实感的姓名和地址,测试更接近生产。 在单元测试里,「真实感」不但没有价值,还会降低可读性。
UserFactory()生成一个叫「张伟」、住在「上海市浦东新区xx路」的用户——这些信息对测试逻辑毫无意义,读测试的人还要花一秒钟判断「这个名字重要吗」。更糟的是,如果断言依赖了随机生成的值,测试就变得不可预测(Faker 每次生成不同,偶尔会生成包含特殊字符的名字导致失败——这时它其实变成了低配版的属性测试,但没有收缩能力,只会给你一个 flaky test)。正确的用法是:与断言相关的字段用明确的字面量(UserFactory(age=17)),不相关的字段用什么都行(Faker 或简单的 Sequence 都可以)。Faker 真正有价值的场景是演示环境的数据填充、性能测试的大批量数据、以及需要「多样化输入」的探索性测试(那种场景要固定Faker.seed()保证可复现)。 - 追问:factory_boy 和 polyfactory 该怎么选? 看你的数据模型是什么。factory_boy 更适合 ORM 模型——它对 Django 的
DjangoModelFactory和 SQLAlchemy 的SQLAlchemyModelFactory有专门支持,处理了「创建时落库」「关联对象」「多对多」「post_save 钩子」这些 ORM 特有的复杂性,而且Trait、SubFactory、post_generation这套 API 很成熟,社区积累多。polyfactory 更适合 Pydantic 模型和 dataclass——它的最大优势是从类型注解自动推导所有字段,你只需要写__model__ = User就能生成完全合法的实例(包括嵌套模型、Literal、Enum、约束条件如Field(ge=0, le=100)都能正确处理),几乎不用写字段定义。所以在 FastAPI 项目里,测试请求体和响应模型用 polyfactory、测试数据库模型用 factory_boy 是很自然的组合。两者也可以共存。如果项目里既有 Pydantic schema 又有 SQLAlchemy model,通常的做法是给 model 写 factory_boy 工厂(因为要落库),给 schema 用 polyfactory(因为只是构造对象)。 - 追问:
build()和create()具体差别在哪,什么时候用 build?create()(也就是直接调用UserFactory())会构造对象并保存到数据库;build()只构造内存中的对象,不发任何 SQL。所以build()快得多——纯粹的对象构造是微秒级,而落库要走数据库往返。适用场景是测试不碰数据库的纯逻辑:比如验证一个序列化器的输出格式、测试一个计算函数、检查模型的__str__方法。但有两个坑要注意:①SubFactory也不会落库——OrderFactory.build()里的user是一个未保存的User实例,它的id是None,如果你之后想保存这个 order 就会失败(外键约束);需要「对象不落库但关联落库」时要显式指定SubFactory(..., strategy=factory.CREATE_STRATEGY)。②post_generation钩子在 build 时通常被跳过(多对多关系没法在未保存的对象上设置),所以工厂里的post_generation要写if not create: return。还有第三个变体stub()——它返回一个只有属性的简单对象(不是模型实例),适合完全不需要模型行为的场景。 - 追问:能不能把生产数据脱敏后拿来测试? 技术上可以,但要非常谨慎,而且通常不是好主意。风险有三:① 合规风险——GDPR、个人信息保护法对个人数据的处理有严格要求,「导到测试环境」通常需要合法性基础和相应的安全措施;一旦测试环境泄露(而测试环境的防护通常弱得多),后果很严重。② 脱敏难以彻底——简单替换姓名手机号是不够的:准标识符的组合仍可能重识别(出生日期 + 邮编 + 性别常能唯一定位一个人)、自由文本字段(备注、地址、工单内容)里可能有身份信息、关联表里的数据可能反推出原始信息。③ 数据形态会漂移——今天导的数据到明年就和线上不一致了,还要重新导。更好的方案是:用工厂生成合成数据(可控、可复现、无合规风险),如果需要「生产数据的统计特征」(比如测试查询性能),就生成符合同样分布的合成数据而不是搬运真实数据。确实需要用真实数据排查特定问题时,应该走受控的、有审批和审计的流程,用完即删。
八、加强记忆
测试数据管理的核心矛盾是**「每个测试需要不同的数据,但准备数据的代码不该淹没测试意图」。三种方案:① JSON/YAML fixture 文件——看起来整洁但维护性最差:模型加一个非空字段所有文件立刻失效、从测试里完全看不出它依赖哪几条数据,导致没人敢删、文件只增不减、加载慢、IDE 找不到引用——它只适合真正静态的配置数据(省份、币种、权限定义);② 工厂(factory_boy / polyfactory)——当前的主流做法:测试里只写自己关心的字段(UserFactory(is_admin=True)),其余自动填充合法默认值,模型加字段时只改工厂一处(对比手写要改 50 处);③ 纯函数和值对象直接构造最清晰。工厂的关键 API:Sequence 保证唯一**(写成固定值时第二次创建就撞唯一约束,并行下更容易暴露)、LazyAttribute 依赖同一对象的其他字段、SubFactory 自动创建关联对象、Trait 表达业务状态且可组合(OrderFactory(paid=True) 远好过传三个字段)、build() 只构造不落库(纯逻辑测试快得多,但要注意 SubFactory 也不落库导致关联对象没有 id)。要警惕「工厂腐化」——半年后各种测试往工厂里加了 30 个字段,ImageField 每次生成图片、SubFactory 每次建关联,创建一个用户要几百毫秒 → 对策是默认值保持最小、可选关联用 Trait 按需创建。数据隔离:事务回滚约 1ms 最推荐,但被测代码里有 commit() 时会破功 → 要用 SAVEPOINT 嵌套事务;并行时按 worker_id 给每个 worker 独立的数据库;最容易被忽略的是「数据库回滚了但缓存里的旧数据还在」——缓存、单例、dependency_overrides、搜索索引都要清理;静态种子数据要 session 级创建一次且不在事务里。最重要的原则是「最小且相关」:UserFactory(age=17) 配上变量名 underage_user 让人一眼看出关键,多余的字段会掩盖真正的依赖关系;数据量也要克制——测分页创建 3 条配 per_page=2 就够了,create_batch(100) 既慢又掩盖问题。其他要点:时间数据别硬编码(datetime(2024,1,1) 两年后测试就会挂),要么全部相对「现在」、要么全部冻结;永远不要假设自增 ID 的具体值;绝不能从生产库导数据(违反隐私法规且脱敏难以彻底);Faker 的「真实感」在单元测试里没有价值,反而降低可读性。