結論先講

Unit Test 用 Factory,Integration Test 用 Factory + Test DB,開發環境用 Seeder,Fixture 只用在靜態參考資料。 這三個不是互斥的,而是在不同層次解決不同問題。但如果只能選一個先導入,選 Factory——它最靈活,踩坑最少。

真實場景:我們的 EC 專案有個 seeder 會隨機生成黑名單帳號,結果測試環境裡有幾個 test account 被加入黑名單,所有角色權限測試直接爆掉。花了整整一天 debug,最後發現問題不在 code 裡,在 seeder 裡。


測試資料的三個痛點

寫測試最煩的不是寫 assertion,是準備資料:

  1. 資料太少——測試跑不起來,缺少必要的關聯資料
  2. 資料太多——測試之間互相干擾,A 測試的資料影響 B 測試的結果
  3. 資料不一致——開發環境的資料跟 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 完整比較

維度FixtureFactorySeeder
資料定義靜態檔案程式碼程式碼
適用測試類型參考資料Unit / Integration開發環境
靈活性
維護成本Schema 改就要改
關聯資料手動管理自動(SubFactory)手動管理
資料量固定動態固定
隔離性全域載入每個測試獨立全域
學習成本
典型工具JSON/YAMLFactory Boy / FisheryDjango seed / Rails seed

什麼時候用什麼

需要靜態參考資料? → Fixture
  例:國家代碼、幣別、權限定義

需要測試特定情境? → Factory
  例:「一個有 3 筆訂單的 VIP 使用者」

需要初始化開發環境? → Seeder
  例:開發用帳號、範例商品、測試分類

實務建議

Factory 設計原則

  1. 每個 Factory 預設就能獨立建立——不需要先建其他資料
  2. 用 Trait / Transient 處理變化——不要建一堆 Factory
  3. 預設值要合理——不要讓預設值觸發特殊邏輯
  4. 避免 Factory 裡做太多事——Factory 是資料工廠,不是業務邏輯

Seeder 設計原則

  1. 冪等性——跑幾次結果都一樣(用 update_or_create
  2. 明確的命名規則——seeder 帳號用 seed_ 開頭
  3. 不要隨機選取既有資料——只操作 seeder 自己建立的資料
  4. 分離基礎資料和測試資料——seed:base vs seed: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 建資料太慢怎麼辦?

  1. 能用 build(不存 DB)就不用 create(存 DB)
  2. build_batch 批次建立
  3. Unit Test 根本不需要真的存 DB——用 build 加 mock 就好

關聯資料很深(A→B→C→D)怎麼辦?

Factory 的 SubFactory 會自動處理。定義好 OrderFactory → UserFactory → CompanyFactory 的關聯,你只要 OrderFactory() 就會自動建立整條鏈。

本系列文章

  1. 測試策略(一):測試金字塔
  2. 測試策略(二):Unit vs Integration
  3. 測試策略(三):E2E + 壓力測試
  4. CD 整合
  5. API 契約測試
  6. 安全測試
  7. Smoke + 回歸測試
  8. 測試資料管理(本篇)
  9. 視覺回歸測試
  10. AI 輔助測試
  11. ISO 29119)
  12. 測試相關憑證與學習路徑