Back to Blog
前端測試策略實戰:從反垃圾驗證到 E2E 覆蓋
📝 Dev Notes

前端測試策略實戰:從反垃圾驗證到 E2E 覆蓋

B
Blake
Mar 7, 2026 By Blake 39 min read
以自己網站的真實元件為範例,分享前端測試的分層策略 — 涵蓋 NewsletterSubscribe 反垃圾驗證、ClapButton 防抖與樂觀更新、Markdown 偵測函數的測試實作,使用 Vitest、React Testing Library 與 Playwright。

多數測試教學用一個 Counter 元件示範。學 API 沒問題,但實際開發面對的抉擇完全不同:該測什麼、在哪一層測、怎麼用最少的測試程式碼換到最大的信心。

這篇文章用的範例全部來自我自己網站正在運行的元件。沒有玩具範例,每一個函數都是生產環境的程式碼。

Part 1:Testing Trophy,不是 Testing Pyramid

傳統 Testing Pyramid 建議:大量 Unit Test、少量 Integration Test、極少 E2E Test。邏輯上說得通 — Unit Test 快又便宜。但在現代 React SPA 中,這個策略容易引導你去測試實作細節(state 更新、內部方法呼叫),每次重構都會壞一片,卻抓不到真正的 bug。

Kent C. Dodds 提出的 Testing Trophy 模型把重心移到 Integration Test — 渲染元件、模擬使用者互動,不 mock 內部 state。理由是:Integration Test 的投資報酬率最高,因為它走的是使用者實際觸發的程式碼路徑。

我大致同意,但有一個自己的觀察:Component Test(渲染單一元件,mock 外部依賴)是 Unit Test 和 E2E 之間最有效的橋樑。它夠快,可以在每次 commit 時跑;夠真實,能抓到互動 bug;夠隔離,出錯時能精準定位。

我的分層模型:

  • Unit Test:從元件中抽出的純函數。不碰 DOM、不碰 React。

  • Component Test:渲染單一元件,mock API 和外部服務,模擬使用者事件。

  • E2E Test:Playwright 驅動真實瀏覽器,對執行中的應用程式操作。只用在關鍵使用者流程。

以下用真實程式碼示範每一層。

Part 2:Unit Test — 抽出純邏輯

生產環境中的 NewsletterSubscribe 元件 — 「訂閱 Blake Lab 電子報」,含暱稱與 Email 欄位,背後跑著 honeypot 隱藏欄位與提交時間驗證

最容易測的程式碼是沒有副作用的程式碼。純函數接收輸入、回傳輸出,不碰 DOM、不發網路請求、不改 state。訣竅是把邏輯從元件中抽出來,讓它可以獨立測試。

我天生對精準度有偏執 — 這讓我在寫驗證邏輯時,會把每一條規則都拆成可獨立驗證的函數。事後證明,這種拆法剛好是最適合測試的結構。

Email 驗證

NewsletterSubscribe 元件有一組嚴格的 Email 驗證:

const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

另外還有拋棄式信箱黑名單和可疑格式偵測:

const DISPOSABLE_EMAIL_DOMAINS = [
  'mailinator.com', 'guerrillamail.com', 'tempmail.com', 'temp-mail.org',
  '10minutemail.com', 'throwaway.email', 'fakeinbox.com', 'trashmail.com',
  // ... 20+ 個域名
];

const isDisposableEmail = (email: string): boolean => {
  const domain = email.split('@')[1]?.toLowerCase();
  return DISPOSABLE_EMAIL_DOMAINS.includes(domain);
};

const isSuspiciousEmail = (email: string): boolean => {
  const localPart = email.split('@')[0];
  if (/^\d+$/.test(localPart)) return true;          // 全數字
  if (localPart.length < 3) return true;              // 太短
  if (/(.)\1{4,}/.test(localPart)) return true;       // 連續重複字元 (aaaaa@)
  if (localPart.length > 8 && !/[aeiouAEIOU]/.test(localPart)) return true; // 無母音
  return false;
};

這些都是純函數。沒有 React、沒有 hooks、沒有 DOM。測起來很直覺:

import { describe, it, expect } from 'vitest';

describe('EMAIL_REGEX', () => {
  const valid = ['[email protected]', '[email protected]', '[email protected]'];
  const invalid = ['', '@domain.com', 'user@', '[email protected]', '[email protected]', 'user @domain.com'];

  valid.forEach(email => {
    it(`接受 ${email}`, () => expect(EMAIL_REGEX.test(email)).toBe(true));
  });

  invalid.forEach(email => {
    it(`拒絕 "${email}"`, () => expect(EMAIL_REGEX.test(email)).toBe(false));
  });
});

describe('isDisposableEmail', () => {
  it('拒絕 mailinator.com', () => {
    expect(isDisposableEmail('[email protected]')).toBe(true);
  });

  it('接受 gmail.com', () => {
    expect(isDisposableEmail('[email protected]')).toBe(false);
  });

  it('域名比對不區分大小寫', () => {
    expect(isDisposableEmail('[email protected]')).toBe(true);
  });
});

describe('isSuspiciousEmail', () => {
  it('標記全數字 local part', () => {
    expect(isSuspiciousEmail('[email protected]')).toBe(true);
  });

  it('標記過短的 local part', () => {
    expect(isSuspiciousEmail('[email protected]')).toBe(true);
  });

  it('標記連續重複字元', () => {
    expect(isSuspiciousEmail('[email protected]')).toBe(true);
  });

  it('標記長字串無母音', () => {
    expect(isSuspiciousEmail('[email protected]')).toBe(true);
  });

  it('通過正常 email', () => {
    expect(isSuspiciousEmail('[email protected]')).toBe(false);
  });
});

關於 TypeScript:型別系統已經幫你擋掉一整類 bug(把 number 傳進需要 string 的地方、遺漏必填欄位)。不需要為型別寫測試。把 Unit Test 的精力留給執行期邏輯 — regex pattern、條件分支、邊界值。

加碼:Markdown 偵測

TipTapEditor 元件有一個 isMarkdown() 函數,用來判斷使用者貼上的文字是否為 Markdown。它檢查 13 種 regex pattern,需要匹配 2 個以上才判定(特殊情況:單獨出現 fenced code block 就直接判定)。

const isMarkdown = (text: string): boolean => {
  const markdownPatterns = [
    /^#{1,6}\s+.+$/m,           // 標題
    /^```[\w]*\n[\s\S]*?```$/m, // 程式碼區塊
    /^\s*[-*+]\s+.+$/m,         // 無序列表
    /^\s*\d+\.\s+.+$/m,         // 有序列表
    /\[.+\]\(.+\)/,             // 連結
    /!\[.*\]\(.+\)/,            // 圖片
    /\*\*.+\*\*/,               // 粗體
    /\*.+\*/,                   // 斜體
    /^>\s+.+$/m,                // 引用
    /`[^`]+`/,                  // 行內程式碼
    /^-{3,}$/m,                 // 分隔線
    /^\|.+\|$/m,                // 表格
    /^- \[[ x]\] .+$/m,         // 任務列表
  ];

  let matchCount = 0;
  for (const pattern of markdownPatterns) {
    if (pattern.test(text)) {
      matchCount++;
      if (matchCount >= 2) return true;
    }
  }

  if (/^```[\w]*\n[\s\S]*?```$/m.test(text)) return true;
  return false;
};

測試這種函數的重點在邊界 — 一個 pattern 匹配 vs 兩個,以及 code block 的特殊路徑:

describe('isMarkdown', () => {
  it('純文字回傳 false', () => {
    expect(isMarkdown('Just a normal sentence.')).toBe(false);
  });

  it('只有一個 markdown 特徵回傳 false', () => {
    expect(isMarkdown('> just a blockquote')).toBe(false); // 只匹配 1 個 pattern
  });

  it('標題 + 列表回傳 true', () => {
    expect(isMarkdown('# Title\n- item one')).toBe(true);
  });

  it('單獨出現 fenced code block 回傳 true', () => {
    expect(isMarkdown('```js\nconsole.log("hi")\n```')).toBe(true);
  });

  it('表格 + 粗體回傳 true', () => {
    expect(isMarkdown('| col |\n**bold**')).toBe(true);
  });
});

Part 3:Component Test — 使用者互動

純函數是簡單的部分。真正的複雜度在元件互動:使用者填表單、按按鈕、看到回饋。React Testing Library 鼓勵你測使用者看到的東西,而不是 React 內部做了什麼。

NewsletterSubscribe:四層反垃圾驗證

訂閱表單有四層反垃圾機制,每一層都可以獨立測試。這種分層防禦的思路 — 先在設計階段把每一道防線拆清楚 — 讓測試自然就有了結構。

第一層:Honeypot 欄位

一個隱藏的表單欄位,真實使用者看不到。機器人會自動填滿所有欄位。如果 honeypot 有值,表單假裝成功但不實際處理。

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, it, expect } from 'vitest';
import NewsletterSubscribe from './NewsletterSubscribe';

// Mock Supabase client
vi.mock('@/lib/supabase', () => ({
  supabase: {
    from: vi.fn(() => ({
      select: vi.fn().mockReturnThis(),
      eq: vi.fn().mockReturnThis(),
      single: vi.fn().mockResolvedValue({ data: null, error: { code: 'PGRST116' } }),
      insert: vi.fn().mockResolvedValue({ error: null }),
    })),
  },
}));

describe('NewsletterSubscribe', () => {
  it('honeypot 被填寫時假裝成功(偵測到機器人)', async () => {
    render(<NewsletterSubscribe />);

    // 模擬機器人填寫隱藏的 honeypot 欄位
    const honeypotField = document.querySelector('input[name="website_url"]') as HTMLInputElement;
    fireEvent.change(honeypotField, { target: { value: 'http://spam.com' } });

    // 用 fireEvent 填入 email(不依賴 timer)
    const emailInput = screen.getByPlaceholderText(/email/i);
    fireEvent.change(emailInput, { target: { value: '[email protected]' } });

    fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

    // 顯示「成功」— 但沒有發出任何 API 呼叫
    await waitFor(() => {
      expect(screen.getByText(/訂閱成功/)).toBeInTheDocument();
    });
  });
});

第二層:提交時間驗證

表單載入後 2 秒內提交,幾乎可以確定是機器人。元件在掛載時記錄 Date.now(),提交時檢查時間差。

注意:userEvent.type() 內部使用 delay,和 vi.useFakeTimers() 衝突會造成 hang。兩種解法:用 userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) 設定,或改用 fireEvent.change() 繞過。這裡為了清楚起見用 fireEvent.change()

it('提交太快時假裝成功(偵測到機器人)', async () => {
  vi.useFakeTimers();
  render(<NewsletterSubscribe />);

  // 用 fireEvent.change — userEvent.type() 搭配 fake timers 會 hang
  const emailInput = screen.getByPlaceholderText(/email/i);
  fireEvent.change(emailInput, { target: { value: '[email protected]' } });

  // 立即提交 — 在 2 秒門檻內
  fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

  await waitFor(() => {
    expect(screen.getByText(/訂閱成功/)).toBeInTheDocument();
  });

  vi.useRealTimers();
});

第三層:拋棄式信箱黑名單

it('拒絕拋棄式信箱域名', async () => {
  vi.useFakeTimers();
  render(<NewsletterSubscribe />);
  vi.advanceTimersByTime(3000); // 跳過 2 秒反機器人門檻

  fireEvent.change(screen.getByPlaceholderText(/email/i), {
    target: { value: '[email protected]' },
  });

  fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

  await waitFor(() => {
    expect(screen.getByText(/不接受臨時信箱/)).toBeInTheDocument();
  });

  vi.useRealTimers();
});

第四層:可疑格式偵測

it('拒絕可疑格式(全數字)', async () => {
  vi.useFakeTimers();
  render(<NewsletterSubscribe />);
  vi.advanceTimersByTime(3000);

  fireEvent.change(screen.getByPlaceholderText(/email/i), {
    target: { value: '[email protected]' },
  });

  fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

  await waitFor(() => {
    expect(screen.getByText(/格式不正確/)).toBeInTheDocument();
  });

  vi.useRealTimers();
});

Happy Path:成功訂閱

it('正常 email 成功訂閱', async () => {
  vi.useFakeTimers();
  render(<NewsletterSubscribe />);
  vi.advanceTimersByTime(3000);

  fireEvent.change(screen.getByPlaceholderText(/email/i), {
    target: { value: '[email protected]' },
  });

  fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

  await waitFor(() => {
    expect(screen.getByText(/訂閱成功/)).toBeInTheDocument();
  });

  vi.useRealTimers();
});

Error Path:重複訂閱

it('重複訂閱顯示已訂閱訊息', async () => {
  // 覆寫 mock,回傳已存在的 active 訂閱者
  const { supabase } = await import('@/lib/supabase');
  vi.mocked(supabase.from).mockReturnValue({
    select: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnThis(),
    single: vi.fn().mockResolvedValue({
      data: { id: '123', status: 'active' },
      error: null,
    }),
  } as any);

  vi.useFakeTimers();
  render(<NewsletterSubscribe />);
  vi.advanceTimersByTime(3000);

  fireEvent.change(screen.getByPlaceholderText(/email/i), {
    target: { value: '[email protected]' },
  });
  fireEvent.click(screen.getByRole('button', { name: /訂閱/i }));

  await waitFor(() => {
    expect(screen.getByText(/已訂閱/)).toBeInTheDocument();
  });

  vi.useRealTimers();
});

ClapButton:防抖、樂觀更新與上限

生產環境中的 ClapButton 元件 —「喜歡這篇文章嗎?給作者鼓勵吧!」含即時計數顯示、500ms 防抖邏輯、每人 50 次上限

ClapButton 用 debounce 處理快速點擊 — 使用者停止點擊 500ms 後才合併發送一次 API 呼叫。UI 上採用樂觀更新(點擊立刻 +1,不等 API 回應),並且有 50 次鼓掌上限。

防抖:多次點擊,一次 API 呼叫

import ClapButton from './ClapButton';

// Mock supabase.rpc — fetchStats (get_clap_stats) 和 submitClaps (add_clap) 都用它
const mockRpc = vi.fn();
vi.mock('@/lib/supabase', () => ({
  supabase: { rpc: mockRpc },
}));

describe('ClapButton', () => {
  beforeEach(() => {
    mockRpc.mockReset();
    // 預設:初始 fetchStats 回傳 0
    mockRpc.mockResolvedValue({
      data: [{ total_claps: 0, user_claps: 0 }],
      error: null,
    });
  });

  it('快速點擊 debounce 成一次 API 呼叫', async () => {
    vi.useFakeTimers();
    render(<ClapButton postId="test-post" />);
    const button = screen.getByRole('button');

    // 模擬 3 次快速點擊
    fireEvent.mouseDown(button);
    fireEvent.mouseUp(button);
    fireEvent.mouseDown(button);
    fireEvent.mouseUp(button);
    fireEvent.mouseDown(button);
    fireEvent.mouseUp(button);

    // debounce timer 觸發前 — add_clap 還沒被呼叫
    expect(mockRpc).not.toHaveBeenCalledWith('add_clap', expect.anything());

    // 跳過 500ms debounce 窗口
    vi.advanceTimersByTime(600);

    // 現在應該只有一次合併的 API 呼叫
    await waitFor(() => {
      expect(mockRpc).toHaveBeenCalledWith('add_clap', expect.objectContaining({
        p_post_id: 'test-post',
      }));
    });

    vi.useRealTimers();
  });
});

樂觀更新:UI 不等 API 回應

it('點擊後立即更新計數(樂觀更新)', async () => {
  render(<ClapButton postId="test-post" />);

  // 等初始 fetchStats 解析完成(total_claps: 0)
  await waitFor(() => {
    expect(screen.getByText('0')).toBeInTheDocument();
  });

  const button = screen.getByRole('button');
  fireEvent.mouseDown(button);
  fireEvent.mouseUp(button);

  // 計數應該已經顯示 1,即使 add_clap API 還沒回應
  expect(screen.getByText('1')).toBeInTheDocument();
});

上限:到達 50 後按鈕 disabled

it('到達上限後按鈕 disabled', async () => {
  // 覆寫 mock:使用者已經有 50 次鼓掌
  mockRpc.mockResolvedValue({
    data: [{ total_claps: 200, user_claps: 50 }],
    error: null,
  });

  render(<ClapButton postId="test-post" />);

  await waitFor(() => {
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

指紋快取

it('生成並快取瀏覽器指紋', () => {
  localStorage.clear();

  render(<ClapButton postId="test-post" />);
  const stored = localStorage.getItem('clap_fingerprint');
  expect(stored).toBeTruthy();
  expect(stored).toMatch(/^fp_/);

  // 第二次渲染重用快取的指紋
  render(<ClapButton postId="test-post-2" />);
  expect(localStorage.getItem('clap_fingerprint')).toBe(stored);
});

Part 4:E2E Test — 關鍵使用者流程

Unit Test 和 Component Test 跑在模擬的 DOM(jsdom)上。它們很快,但抓不到:CSS 把元素隱藏了、慢網路下的 timeout、第三方 script 破壞版面。這是 E2E Test 的領域。

先釐清架構:Static Export + BaaS

這個網站是 Next.js static export,部署到 Apache 伺服器(透過 FTP)。Build 產出的是純 HTML/JS/CSS 檔案。部落格內容由 PHP handler 在 runtime 向 Supabase 查詢。互動式 React 元件(訂閱表單、鼓掌按鈕)在 client-side 直接呼叫 Supabase。

這對測試策略的影響:

  • 生產環境沒有 Next.js server。 你不能對 next dev 跑 E2E。E2E 測試對象是 static build 的本地靜態伺服器。

  • Supabase 是第三方服務。 你永遠不測 Supabase 本身 — 你測的是你的程式碼在 Supabase 回傳特定結果時的行為(成功、錯誤、重複)。

  • PHP 層是薄的 pass-through。 它不包含值得 unit test 的業務邏輯。邏輯住在 React 元件裡。

選 Playwright 而不是 Cypress

原因很務實:

  • 多瀏覽器:Chromium、Firefox、WebKit 一次跑完。

  • Auto-wait:不用寫 cy.wait(1000) 這種 hack。Playwright 自動等元素可操作。

  • 平行執行:測試在隔離的 browser context 中平行跑,預設就是平行。

  • 網路攔截:內建 page.route() — 對 mock Supabase 呼叫至關重要,不需要測試資料庫。

關鍵路徑:電子報訂閱

這是網站上轉換價值最高的使用者流程。因為是 static export,我們用本地靜態伺服器提供 out/ 目錄,並 mock 所有 Supabase API 呼叫:

import { test, expect } from '@playwright/test';

test('電子報訂閱流程', async ({ page }) => {
  // Mock Supabase REST API — 不需要真實資料庫
  await page.route('**/rest/v1/subscribers*', async route => {
    const method = route.request().method();
    if (method === 'GET') {
      // 模擬「不存在的訂閱者」— Supabase .single() 找不到時
      // 回傳 error code PGRST116
      await route.fulfill({
        status: 406,
        contentType: 'application/json',
        body: JSON.stringify({
          code: 'PGRST116',
          message: 'The result contains 0 rows',
        }),
      });
    } else if (method === 'POST') {
      // 模擬成功寫入
      await route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify([{ id: 'new-id' }]),
      });
    }
  });

  // Mock Edge Function(歡迎信)— 直接回傳成功
  await page.route('**/functions/v1/process-scheduled-campaigns', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ success: true }),
    });
  });

  await page.goto('/');
  await page.locator('#subscribe').scrollIntoViewIfNeeded();
  await page.getByPlaceholder(/email/i).fill('[email protected]');
  await page.getByRole('button', { name: /訂閱/ }).click();

  await expect(page.getByText(/訂閱成功/)).toBeVisible({ timeout: 5000 });
});

注意:每一個外部呼叫都被攔截。GET mock 回傳 PGRST116(Supabase 的 .single() 找不到列時的錯誤碼),元件因此進入 insert 流程。測試不需要 Supabase 專案、API key、或網路連線。它測的是你的元件行為,不是 Supabase 的可用性。

處理 Flaky Test

三個有效的策略:

1. Mock 所有外部服務

page.route() 攔截 Supabase REST、Edge Functions、和任何 analytics 呼叫。對於依賴 BaaS 的網站,這不是可選的 — 你無法在 CI 中控制 Supabase 的回應時間。

2. 確定性資料

永遠不要依賴生產資料庫的狀態。每個測試設定自己的 mock response。要測「重複訂閱」?mock response 回傳 { status: 'active' },不要寫入真實資料庫。

3. 重試策略

Playwright 有內建的 retries 設定。CI 上設為 1 — 如果失敗一次但重試通過,代表它是 flaky 的、需要修,但至少不會擋住 deploy。

連結合規需求

如果你在受監管的產業工作(我曾在 ISO 62304 醫療器材軟體規範下出貨),E2E 測試報告可以一物兩用。Playwright 的 HTML 報告包含截圖和 trace,正是稽核員想看的「驗證證據」。把測試名稱寫成需求追溯格式:test('REQ-042: 使用者可以訂閱電子報'),你的測試套件就同時是合規文件。

Part 5:CI/CD 整合

測試只在自動執行時才有用。關鍵問題是:測試在 static export + FTP 部署的 pipeline 裡放在哪裡?

答案:測試跑在 Node.js 建構環境(GitHub Actions),在 static files 產生和上傳之前。你的 React 元件、驗證函數、互動邏輯都是 TypeScript/JavaScript — 不管最終產出怎麼被 serve,它們都可以用 Node.js 工具測試。

Pipeline 全貌

git push (main) → GitHub Actions
  ├── Step 1: npm ci
  ├── Step 2: npx vitest run              ← Unit + Component 測試
  ├── Step 3: npm run build               ← Static export (out/)
  ├── Step 4: npx playwright test         ← E2E 對本地靜態伺服器
  ├── Step 5: 複製 PHP, .htaccess, GA4    ← 準備部署檔案
  └── Step 6: FTP 部署到 Apache           ← SamKirkland/FTP-Deploy-Action

如果 Step 2 或 4 失敗,deploy 不會發生。這是你的安全網。

分層策略

觸發時機

測試層級

目標

速度

工具

git commit

Unit

純函數

< 5s

Vitest

git push (CI)

Component

使用者互動

< 30s

Vitest + RTL

git push (CI)

E2E

關鍵路徑

< 2min

Playwright

Pre-commit:快速回饋

lint-staged 搭配 pre-commit hook,只跑與變更檔案相關的 Unit Test:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "vitest related --run"
    ]
  }
}

5 秒內完成。開發者不會跳過它,因為它快到不會打斷流程。

GitHub Actions:完整的 Gate

GitHub Actions workflow 列表 — Scheduled Email Sender、Calculate Company Analytics、部署到 FTP 伺服器,三條自動化 pipeline 支撐整個網站運作

以下是如何在既有的 FTP 部署 workflow 中加入測試。關鍵思路:測試跑在 build 之前,壞掉的元件永遠不會被部署。

# .github/workflows/deploy-ftp.yml(加入測試)
name: 部署到 FTP 伺服器

on:
  push:
    branches: [ main ]
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
    - name: 檢出程式碼
      uses: actions/checkout@v4

    - name: 設定 Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '18'
        cache: 'npm'

    - name: 安裝依賴
      run: npm ci

    # ---- 測試在 build 之前跑 ----
    - name: 執行 Unit & Component 測試
      run: npx vitest run --reporter=verbose

    - name: 建置靜態網站
      run: npm run build
      env:
        NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
        NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
        NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }}

    # ---- E2E 測試對建構完成的靜態檔案 ----
    - name: 安裝 Playwright 瀏覽器
      run: npx playwright install --with-deps chromium

    - name: 執行 E2E 測試
      run: npx playwright test

    # ---- 所有測試通過後才部署 ----
    - name: 準備部署檔案
      run: |
        cp public/*.php out/ || echo "No PHP files to copy"
        cp public/.htaccess out/ || echo "No .htaccess to copy"
        cp public/error404.html out/ || echo "No error404.html to copy"
        if [ -d "public/images" ]; then
          cp -r public/images out/ || echo "Failed to copy images directory"
        fi
        cp ga4-php-helper.php out/ 2>/dev/null || echo "No GA4 PHP helper to copy"

    - name: Deploy to FTP
      uses: SamKirkland/[email protected]
      with:
        server: ${{ secrets.FTP_HOST }}
        username: ${{ secrets.FTP_USERNAME }}
        password: ${{ secrets.FTP_PASSWORD }}
        local-dir: ./out/
        server-dir: /
        exclude: |
          **/.git*
          **/node_modules/**
          **/.next/**
          .env*
          *.md

Playwright 設定用本地靜態伺服器提供 out/ 目錄:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  webServer: {
    command: 'npx serve out -l 3000',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:3000',
  },
  retries: process.env.CI ? 1 : 0,
});

這個設定測試的是實際的 static build — 跟將要被 FTP 上傳到生產環境的 HTML/JS 檔案完全一樣。如果 Supabase client 初始化失敗、元件沒有正確 hydrate、static export 破壞了某個路由 — 你會在這裡抓到它,在它到達 Apache 伺服器之前。

那 Supabase 的邏輯怎麼測?

你不測 Supabase。你測的是你的程式碼對 Supabase 的假設

你的程式碼做的事

你用 mock 測的方式

supabase.from('subscribers').insert(...)

vi.mock() 回傳 { error: null }{ error: { code: '23505' } }

supabase.rpc('get_clap_stats', ...)

vi.mock() 回傳 { data: [{ total_claps: 10 }] }

Edge Function 的 fetch() 呼叫

vi.fn()page.route() 回傳 { success: true }

如果 Supabase 改了 API,你的 mock 照樣通過 — 但生產環境會壞。這是預期的行為。E2E smoke test(一兩個測試打 staging 環境的真實 Supabase endpoint)可以抓到這種問題,但那是監控的職責,不是測試的職責。

真實成效:訂閱者分析

訂閱者分析儀表板 — 2,082 總訂閱者、43% 開信率、29.6% 點擊率,含從發送到點擊的轉換漏斗EDM 活動管理後台 — 38 場總活動、36 場已完成,依 Tier 分級的受眾分群(Tier1/2/3),含每場活動的送達追蹤

這些儀表板展示了被測元件所支撐的生產系統。NewsletterSubscribe 的反垃圾層保護訂閱者名單品質,直接影響這些指標 — 43% 開信率不是靠垃圾名單撐出來的。測試不是抽象的事 — 它直接保護一個服務 2,000+ 訂閱者、橫跨 38 場活動的系統的可靠性。

測試就是文件

新成員加入團隊,想了解 NewsletterSubscribe 做了什麼事?讀測試檔案。測試名稱就是規格:

  • 「honeypot 被填寫時假裝成功」

  • 「拒絕拋棄式信箱域名」

  • 「重複訂閱顯示已訂閱訊息」

這比註解可靠。註解會腐爛,測試會在不準確的時候大聲失敗。

在 ISO 62304 的脈絡下,這個特性特別有價值。測試套件同時是驗證報告和活的規格書。稽核員問「你怎麼驗證拋棄式信箱會被拒絕?」時,你指著測試,不是指著一份可能已經過時的 Word 文件。

結語

測試策略是工程決策,不是信仰問題。你不需要 100% 覆蓋率。你需要的是信心 — 確信剛寫的程式碼能運作、確信重構沒有破壞任何東西、確信新同事能理解預期行為。

本文描述的方法 — 純函數的 Unit Test、互動行為的 Component Test、關鍵路徑的 E2E Test — 用最少的維護成本換到最大的信心。

每一個測試都來自真實元件解決的真實問題:擋垃圾訂閱、防抖點擊、偵測 Markdown。測試不是開發之外的額外工作。它是精準度的體現 — 你對每一條邏輯分支的預期行為都足夠清楚,清楚到可以寫成可執行的斷言。

從你最沒把握的那個函數開始。寫一個測試。讓它通過。然後決定下一個要測什麼。

Enjoyed this article? Show some love!

0
Clap

Enjoyed this article?

Subscribe for engineering notes and AI development insights

We respect your privacy. No spam, unsubscribe anytime.

Share this article

Comments