結論先講
Unit Test 用 Factory,Integration Test 用 Factory + Test DB,開發環境用 Seeder,Fixture 只用在靜態參考資料。 這三個不是互斥的,而是在不同層次解決不同問題。但如果只能選一個先導入,選 Factory——它最靈活,踩坑最少。
真實場景:我們的 EC 專案有個 seeder 會隨機生成黑名單帳號,結果測試環境裡有幾個 test account 被加入黑名單,所有角色權限測試直接爆掉。花了整整一天 debug,最後發現問題不在 code 裡,在 seeder 裡。
測試資料的三個痛點
寫測試最煩的不是寫 assertion,是準備資料:
- 資料太少——測試跑不起來,缺少必要的關聯資料
- 資料太多——測試之間互相干擾,A 測試的資料影響 B 測試的結果
- 資料不一致——開發環境的資料跟 CI 環境不同,本地過但 CI 掛
這三個問題分別對應三種解法:Fixture(靜態資料)、Factory(動態生成)、Seeder(環境初始化)。
Fixture:靜態資料檔
Fixture 就是一份寫死的資料檔(JSON、YAML、SQL),測試前載入、測試後清除。
範例
// fixtures/products.json
[
{
"id": 1,
"name": "測試商品 A",
"price": 100,
"stock": 50,
"category_id": 1
},
{
"id": 2,
"name": "測試商品 B",
"price": 200,
"stock": 0,
"category_id": 2
}
]# Django 的 fixture 用法
class ProductTestCase(TestCase):
fixtures = ['products.json', 'categories.json']
def test_in_stock_products(self):
products = Product.objects.filter(stock__gt=0)
self.assertEqual(products.count(), 1)Fixture 的問題
| 優點 | 缺點 |
|---|---|
| 簡單直觀,看得到資料 | 維護成本高,schema 一改就要更新 |
| 可版本控制 | 關聯資料要手動對(category_id 對不對?) |
| 載入快 | ID 硬編碼,容易衝突 |
| 適合靜態參考資料 | 資料量一大就失控 |
適合場景: 國家列表、幣別設定、權限定義等很少變動的參考資料。
Factory Pattern:動態生成資料
Factory 的核心概念:用程式碼定義「一個標準的 X 長什麼樣」,測試時再根據需要覆寫特定欄位。
Python — Factory Boy
# factories.py
import factory
from myapp.models import User, Product, Order
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f'user_{n}')
email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')
is_active = True
class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
model = Product
name = factory.Faker('product_name', locale='zh_TW')
price = factory.Faker('random_int', min=100, max=10000)
stock = 50
category = factory.SubFactory(CategoryFactory)
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
status = 'pending'
@factory.post_generation
def items(self, create, extracted, **kwargs):
if extracted:
for product in extracted:
OrderItemFactory(order=self, product=product)# 測試中使用
def test_order_total():
product = ProductFactory(price=500)
order = OrderFactory(items=[product])
assert order.calculate_total() == 500
def test_inactive_user_cannot_order():
user = UserFactory(is_active=False)
with pytest.raises(PermissionError):
OrderFactory(user=user)JavaScript — Fishery
// factories.ts
import { Factory } from 'fishery';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
const userFactory = Factory.define<User>(({ sequence }) => ({
id: sequence,
name: `User ${sequence}`,
email: `user-${sequence}@example.com`,
role: 'user',
}));
// 使用
const admin = userFactory.build({ role: 'admin' });
const users = userFactory.buildList(5);
const specificUser = userFactory.build({
name: '測試管理員',
email: 'admin@test.com',
role: 'admin',
});Ruby — FactoryBot
# factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
password { 'password123' }
role { :user }
trait :admin do
role { :admin }
end
trait :with_orders do
after(:create) do |user|
create_list(:order, 3, user: user)
end
end
end
end
# 使用
let(:admin) { create(:user, :admin) }
let(:user_with_orders) { create(:user, :with_orders) }Seeder:環境初始化
Seeder 的用途是初始化開發或 staging 環境的資料,讓開發者不用從空資料庫開始。
Django Seeder
# management/commands/seed.py
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def handle(self, *args, **options):
# 基礎資料(冪等)
for category_name in ['電子產品', '服飾', '食品']:
Category.objects.get_or_create(name=category_name)
# 測試帳號
User.objects.get_or_create(
email='admin@dev.local',
defaults={
'username': 'dev_admin',
'is_staff': True,
'is_superuser': True,
}
)
self.stdout.write(self.style.SUCCESS('Seed complete'))Seeder 的致命陷阱
陷阱 1:Seeder 汙染測試帳號
# 這是我們 EC 專案踩過的坑
def seed_blacklist():
"""隨機生成黑名單測試資料"""
users = User.objects.all()
# 問題:隨機選 5 個帳號加入黑名單
# 如果選到 smoke test 帳號,所有權限測試就壞了
random_users = random.sample(list(users), 5)
for user in random_users:
Blacklist.objects.create(user=user, reason='seed data')修正: Seeder 產生的帳號應該有明確的命名規則,永遠不要隨機選取既有帳號。
陷阱 2:get_or_create 不會更新
# 第一次跑:建立 admin,密碼是 oldpassword
User.objects.get_or_create(
email='admin@dev.local',
defaults={'password': 'oldpassword'}
)
# 改了 seeder,密碼改成 newpassword
# 但是跑第二次時:get 到了既有的,不會 create,密碼還是 oldpassword
User.objects.get_or_create(
email='admin@dev.local',
defaults={'password': 'newpassword'} # 這行不會執行!
)修正: 用 update_or_create 取代 get_or_create。
測試資料庫策略
策略比較
| 策略 | 速度 | 隔離性 | 設定複雜度 | 適用場景 |
|---|---|---|---|---|
| SQLite in-memory | 極快 | 高 | 低 | Unit Test(但 SQL 方言可能不同) |
| Transaction rollback | 快 | 高 | 低 | 大部分 Integration Test |
| Docker Testcontainers | 中 | 極高 | 中 | 需要真實 DB 行為的 Integration Test |
| 共用測試 DB | 慢 | 低 | 低 | 不推薦(測試會互相干擾) |
Testcontainers 範例
# 用 Testcontainers 啟動真實 PostgreSQL
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope='session')
def postgres():
with PostgresContainer('postgres:16') as pg:
yield pg
@pytest.fixture
def db_connection(postgres):
conn = postgres.get_connection_url()
engine = create_engine(conn)
# 每個測試用 transaction rollback 隔離
with engine.begin() as connection:
yield connection
connection.rollback()Transaction Rollback
大部分框架都有內建支援:
# Django — TestCase 自動 rollback
class OrderTest(TestCase): # 不是 SimpleTestCase
def test_create_order(self):
# 這個 order 在測試結束後會自動 rollback
order = OrderFactory()
self.assertEqual(Order.objects.count(), 1)
# 測試結束,order 消失了Fixture vs Factory vs Seeder 完整比較
| 維度 | Fixture | Factory | Seeder |
|---|---|---|---|
| 資料定義 | 靜態檔案 | 程式碼 | 程式碼 |
| 適用測試類型 | 參考資料 | Unit / Integration | 開發環境 |
| 靈活性 | 低 | 高 | 中 |
| 維護成本 | Schema 改就要改 | 低 | 中 |
| 關聯資料 | 手動管理 | 自動(SubFactory) | 手動管理 |
| 資料量 | 固定 | 動態 | 固定 |
| 隔離性 | 全域載入 | 每個測試獨立 | 全域 |
| 學習成本 | 零 | 中 | 低 |
| 典型工具 | JSON/YAML | Factory Boy / Fishery | Django seed / Rails seed |
什麼時候用什麼
需要靜態參考資料? → Fixture
例:國家代碼、幣別、權限定義
需要測試特定情境? → Factory
例:「一個有 3 筆訂單的 VIP 使用者」
需要初始化開發環境? → Seeder
例:開發用帳號、範例商品、測試分類
實務建議
Factory 設計原則
- 每個 Factory 預設就能獨立建立——不需要先建其他資料
- 用 Trait / Transient 處理變化——不要建一堆 Factory
- 預設值要合理——不要讓預設值觸發特殊邏輯
- 避免 Factory 裡做太多事——Factory 是資料工廠,不是業務邏輯
Seeder 設計原則
- 冪等性——跑幾次結果都一樣(用
update_or_create) - 明確的命名規則——seeder 帳號用
seed_開頭 - 不要隨機選取既有資料——只操作 seeder 自己建立的資料
- 分離基礎資料和測試資料——
seed:basevsseed:dev
常見問題
Factory 和 Fixture 可以混用嗎?
可以,而且很常見。Fixture 載入靜態參考資料(Category、Permission),Factory 建立測試專用的動態資料(User、Order)。這是最實務的做法。
測試資料庫應該用跟 production 一樣的 DB 嗎?
看情況。Unit Test 用 SQLite 沒問題(快)。但如果你的查詢用了 PostgreSQL 特有功能(JSON 操作、CTE),Integration Test 應該用真實的 PostgreSQL(Testcontainers)。
Seeder 的資料可以拿來跑測試嗎?
強烈不建議。Seeder 是給開發環境用的,資料可能隨時改變。測試應該用 Factory 自己建資料,確保每次跑都是一致的。
Factory 建資料太慢怎麼辦?
- 能用
build(不存 DB)就不用create(存 DB) - 用
build_batch批次建立 - Unit Test 根本不需要真的存 DB——用
build加 mock 就好
關聯資料很深(A→B→C→D)怎麼辦?
Factory 的 SubFactory 會自動處理。定義好 OrderFactory → UserFactory → CompanyFactory 的關聯,你只要 OrderFactory() 就會自動建立整條鏈。