Test Data Management — Fixture / Factory / Seed / Cleanup 完整策略

「自動化測試跑兩個禮拜後 staging DB 變垃圾場」是 99% 團隊的痛。Test data 管理沒做好、自動化壽命 = 半年。這篇給你 6 種策略 + 完整實作。

為什麼 Test Data 是頭號殺手

flowchart TD
    Auto[寫了自動化] --> P1[第 1 週<br>全綠]
    P1 --> P2[第 4 週<br>10% flaky]
    P2 --> P3[第 12 週<br>30% flaky + 重複帳號]
    P3 --> P4[第 24 週<br>沒人信任、棄置]

    P3 --> Why{為什麼?}
    Why --> R1[Test data 殘留]
    Why --> R2[Test 互打]
    Why --> R3[依賴順序]
    Why --> R4[資料污染]

    style Auto fill:#06b6d4,color:#fff
    style P4 fill:#ef4444,color:#fff
    style Why fill:#f59e0b,color:#fff

Test data 不管理 = flaky test → 不信任 → 棄置

6 種 Test Data 策略

mindmap
  root((Test Data<br>Strategies))
    Fixture
      靜態 yaml/json
      適合穩定資料
      Setup 用
    Factory
      動態產生
      適合多變
      每 test 新建
    Seed Script
      DB 初始
      Migration 後跑
      適合 dev 環境
    Snapshot
      Prod 抽樣
      去識別化
      適合整合測試
    On-the-fly via API
      用真實 API 建
      最真實
      最慢
    Shared / Singleton
      只建一次
      慎用
      適合 read-only

每種有適合的場景。

策略 1: Fixture — 靜態檔案

適合:固定的 reference data(國家清單、產品分類)。

# tests/fixtures/users.yml
users:
  - id: 1
    email: [email protected]
    role: admin
  - id: 2
    email: [email protected]
    role: user
# pytest
@pytest.fixture
def users(scope="session"):
    with open('tests/fixtures/users.yml') as f:
        return yaml.safe_load(f)['users']

def test_admin_can_see_panel(users):
    admin = users[0]
    # ...

問題:多 test 共用 → 改一個壞一票。

策略 2: Factory — 動態產生(最強)

適合:每 test 新建獨立資料、避免共用衝突。

Python 用 factory_boy

# tests/factories.py
import factory
from app.models import User, Order

class UserFactory(factory.Factory):
    class Meta:
        model = User

    email = factory.Sequence(lambda n: f"qa-user-{n}@test.com")
    name = factory.Faker('name')
    role = "user"
    created_at = factory.LazyFunction(datetime.now)

class OrderFactory(factory.Factory):
    class Meta:
        model = Order

    user = factory.SubFactory(UserFactory)
    total = factory.Faker('pydecimal', positive=True, max_value=1000)
    status = "pending"
# test
def test_user_can_view_own_order():
    user = UserFactory()
    order = OrderFactory(user=user)
    # ...

def test_admin_view_all_orders():
    # 一行建 10 個 user 各 3 個 order
    users = UserFactory.create_batch(10)
    for u in users:
        OrderFactory.create_batch(3, user=u)

Playwright 也可以

// fixtures/factories.ts
import { faker } from '@faker-js/faker';

let userIdSeq = 0;
export function makeUser(overrides = {}) {
  userIdSeq++;
  return {
    email: `qa-user-${userIdSeq}-${Date.now()}@test.com`,
    name: faker.person.fullName(),
    role: 'user',
    ...overrides,
  };
}

export function makeOrder(user, overrides = {}) {
  return {
    user_id: user.id,
    total: faker.number.float({ min: 10, max: 1000, precision: 0.01 }),
    status: 'pending',
    ...overrides,
  };
}
test('user views own order', async ({ api, page }) => {
  const user = await api.post('/users', makeUser()).json();
  const order = await api.post('/orders', makeOrder(user)).json();
  // ...
});

關鍵:每個 test 自己的 user/data、不互打。

策略 3: Seed Script — DB 初始

適合:dev / staging 環境的 reference data + 少量 sample data。

# 跑一次塞滿基本資料
npm run db:seed
# 或
python manage.py seed_test_data
# seeds/dev_seed.py
def seed():
    # Reference data
    Country.objects.bulk_create([
        Country(code='TW', name='台灣'),
        Country(code='JP', name='日本'),
    ])

    # Sample users
    for i in range(20):
        UserFactory(email=f'demo-{i}@example.com')

    # Sample orders
    OrderFactory.create_batch(100)

問題:Seed 只能跑一次、跑多次會重複。要設計成 idempotent。

策略 4: Snapshot — Prod 抽樣(謹慎用)

適合:整合測試需要真實 data 結構與分佈。

flowchart LR
    Prod[Production DB] --> Snap[Snapshot]
    Snap --> Anon[去識別化<br>name → fake<br>email → masked]
    Anon --> Staging[Staging DB]

    style Prod fill:#ef4444,color:#fff
    style Anon fill:#f59e0b,color:#fff
    style Staging fill:#10b981,color:#fff

必須去識別化的欄位

  • ✅ 姓名 → faker
  • ✅ Email → user_{id}@masked.com
  • ✅ 手機 / 身分證 → fake
  • ✅ 信用卡 → mask 中間 8 位
  • ✅ 地址 → fake
  • ✅ IP → 隨機

工具

  • pgreplay (PostgreSQL)
  • AWS DMS(雲端)
  • 自建 script + Faker library

策略 5: On-the-fly via API — 最真實

適合:E2E test、要走真實 flow。

test('register → verify email → login', async ({ page, api }) => {
  // 1. API 建 user (跟前端 register flow 一樣)
  const email = `qa-${Date.now()}@test.com`;
  await api.post('/auth/register', { email, password: 'Pass@123' });

  // 2. 從 mail server 拿 verification link
  const link = await getVerificationLink(email);

  // 3. 走 verify
  await page.goto(link);

  // 4. Login
  await page.goto('/login');
  await page.fill('[name=email]', email);
  await page.fill('[name=password]', 'Pass@123');
  await page.click('[type=submit]');

  // 5. Assert
  await expect(page).toHaveURL('/dashboard');
});

最真實、最慢。適合 critical flow。

策略 6: Shared / Singleton — 共用一份(慎用)

適合:read-only 的 fixture(產品目錄)。

@pytest.fixture(scope='session')
def all_countries(db):
    # 整 session 只建一次
    return CountryFactory.create_batch(50)

禁忌:可改寫的資料絕對不要 session scope。

命名規範(避免 prod data 跟 test data 混)

# ✅ 一眼看出是測試
email = f'qa-test-{uuid.uuid4()}@example.com'
name = f'QA_Test_User_{uuid.uuid4()}'

# ❌ 看起來像真用戶
email = '[email protected]'
name = 'Alice Chen'

好處

  1. 後台搜 qa-test- 一次 cleanup
  2. Customer support 不會誤打給測試 email
  3. Analytics 可以排除測試流量

Cleanup 策略

flowchart TD
    Strategy{Cleanup<br>策略} --> S1[Test 內 cleanup<br>finally / afterEach]
    Strategy --> S2[Fixture teardown<br>pytest fixture]
    Strategy --> S3[每天 cron job<br>清過期測試資料]
    Strategy --> S4[整 DB reset<br>每次 CI 跑]
    Strategy --> S5[Transactional<br>每 test 一個 transaction、跑完 rollback]

    style S1 fill:#06b6d4,color:#fff
    style S2 fill:#10b981,color:#fff
    style S3 fill:#a855f7,color:#fff
    style S4 fill:#f59e0b,color:#fff
    style S5 fill:#ef4444,color:#fff

Strategy A: 每 test cleanup(pytest fixture)

@pytest.fixture
def fresh_user(api):
    user = api.post('/users', json=make_user()).json()
    yield user
    # Test 跑完自動清
    api.delete(f'/users/{user["id"]}')

問題:test fail 時 cleanup 也跑 → 看不到失敗瞬間的 data。

# 改成失敗時保留
@pytest.fixture
def fresh_user(api, request):
    user = api.post('/users', json=make_user()).json()
    yield user
    if request.node.rep_call.passed:
        api.delete(f'/users/{user["id"]}')
    else:
        print(f"⚠️ Keeping user {user['id']} for debugging")

Strategy B: Transactional(最乾淨)

@pytest.fixture
def db_session():
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()  # 一切都不留
    connection.close()

所有改動都 rollback。最乾淨。但 API 測試 / E2E 用不了(API 走 HTTP、不在同 transaction)。

Strategy C: 整 DB reset(CI 友善)

# CI 起 docker-compose 含 fresh DB
docker compose up -d db
python manage.py migrate
python manage.py seed_dev_data
npm run test:e2e

每次 CI 全新 DB → 0 污染。慢一點但乾淨

Strategy D: Nightly cron

-- 每晚 3am 清測試 user
DELETE FROM users WHERE email LIKE 'qa-test-%' AND created_at < NOW() - INTERVAL '24 hours';

備胎策略。

跨環境的 Test Data 策略

flowchart LR
    Env{環境} --> Local[Local Dev]
    Env --> CI[CI / Sandbox]
    Env --> Staging[Staging]
    Env --> Prod[Production]

    Local --> L1[Seed + Factory<br>盡量真實]
    CI --> C1[Fresh DB<br>每次 reset]
    Staging --> S1[Snapshot from prod<br>去識別化]
    Prod --> P1[❌ 不寫測試資料]

    style Prod fill:#ef4444,color:#fff

規則

  1. Local 用 seed + factory
  2. CI 用 fresh container
  3. Staging 用 anonymized prod snapshot
  4. Prod 絕對不寫測試資料

敏感資料處理

mindmap
  root((敏感資料))
    必去識別化
      姓名
      Email
      手機
      地址
      身分證
      信用卡
      DOB
    可用 fake
      Faker library
      Mockaroo
      自建 generator
    法規
      GDPR
      個資法
      PCI-DSS
      HIPAA

Faker 範例

from faker import Faker
fake = Faker('zh_TW')

email = fake.email()        # [email protected]
name = fake.name()          # 王小明
address = fake.address()    # 台北市信義區...
phone = fake.phone_number() # 0912345678
ssn = fake.ssn()           # 假身分證
cc = fake.credit_card_number()  # 4242 4242 4242 4242

測試卡號(信用卡測試專用)

Visa: 4111 1111 1111 1111
Visa (Stripe): 4242 4242 4242 4242
Mastercard: 5555 5555 5555 4444
Amex: 3782 822463 10005
過期卡: 4000 0000 0000 0069
扣款失敗: 4000 0000 0000 0002
3DS 必驗: 4000 0000 0000 3220

反模式

flowchart TD
    Anti[Test Data 反模式] --> A1["共用同一個 test user"]
    Anti --> A2["把真實 email 寫進 test code"]
    Anti --> A3["在 prod 跑 test"]
    Anti --> A4["從不 cleanup"]
    Anti --> A5["每 test 都 reset 整 DB"]
    Anti --> A6["test data 寫死、不能改"]
    Anti --> A7["敏感資料用真實的"]
    Anti --> A8["順序依賴:必須先跑 test A 才能跑 B"]

    style A1 fill:#ef4444,color:#fff
    style A2 fill:#ef4444,color:#fff
    style A3 fill:#ef4444,color:#fff
    style A4 fill:#ef4444,color:#fff
    style A5 fill:#ef4444,color:#fff
    style A6 fill:#ef4444,color:#fff
    style A7 fill:#ef4444,color:#fff
    style A8 fill:#ef4444,color:#fff

工具地圖

工具 用途 語言
factory_boy Python factory Python
Faker 假資料 generator Multi
Mockaroo 線上產假資料 Web
fishery TS factory TypeScript
factories.ts 手寫 TS 手刻 TypeScript
Test Containers Docker 起測試 DB Multi
db-fixtures Django fixture Python
factory-girl (TS) TS factory TypeScript

QA Lead 該推的 3 件事

  1. Test data 規範寫進 onboarding(每個新 QA 進來都讀)
  2. Cleanup strategy 跟 dev 對齊(DB schema 改了 fixture 也要改)
  3. Test data dashboard — 多少測試 user / 還在 DB 多少(看健康)

給 QA 的 5 句

  1. 第一週做 test data 規範、後面省 6 個月維護
  2. 用 factory > fixture > snapshot > shared
  3. 命名加 prefix(qa-test-)救你的人生
  4. 失敗時別 cleanup、好用
  5. 永遠不要在 prod 寫 test data

最後

Test data 是自動化的隱形主角。寫得好沒人看見、寫不好整個 team 都受害。從今天起把所有 test data 加 qa-test- prefix、每個 test 自己的 factory、cleanup 寫進 fixture — 三個月後你的自動化會從「2 週後不能跑」變「3 年後還在跑」。