多數測試教學用一個 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 — 抽出純邏輯

最容易測的程式碼是沒有副作用的程式碼。純函數接收輸入、回傳輸出,不碰 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 用 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 不會發生。這是你的安全網。
分層策略
觸發時機 | 測試層級 | 目標 | 速度 | 工具 |
|---|---|---|---|---|
| Unit | 純函數 | < 5s | Vitest |
| Component | 使用者互動 | < 30s | Vitest + RTL |
| E2E | 關鍵路徑 | < 2min | Playwright |
Pre-commit:快速回饋
用 lint-staged 搭配 pre-commit hook,只跑與變更檔案相關的 Unit Test:
{
"lint-staged": {
"*.{ts,tsx}": [
"vitest related --run"
]
}
}
5 秒內完成。開發者不會跳過它,因為它快到不會打斷流程。
GitHub Actions:完整的 Gate

以下是如何在既有的 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 測的方式 |
|---|---|
|
|
|
|
Edge Function 的 |
|
如果 Supabase 改了 API,你的 mock 照樣通過 — 但生產環境會壞。這是預期的行為。E2E smoke test(一兩個測試打 staging 環境的真實 Supabase endpoint)可以抓到這種問題,但那是監控的職責,不是測試的職責。
真實成效:訂閱者分析


這些儀表板展示了被測元件所支撐的生產系統。NewsletterSubscribe 的反垃圾層保護訂閱者名單品質,直接影響這些指標 — 43% 開信率不是靠垃圾名單撐出來的。測試不是抽象的事 — 它直接保護一個服務 2,000+ 訂閱者、橫跨 38 場活動的系統的可靠性。
測試就是文件
新成員加入團隊,想了解 NewsletterSubscribe 做了什麼事?讀測試檔案。測試名稱就是規格:
「honeypot 被填寫時假裝成功」
「拒絕拋棄式信箱域名」
「重複訂閱顯示已訂閱訊息」
這比註解可靠。註解會腐爛,測試會在不準確的時候大聲失敗。
在 ISO 62304 的脈絡下,這個特性特別有價值。測試套件同時是驗證報告和活的規格書。稽核員問「你怎麼驗證拋棄式信箱會被拒絕?」時,你指著測試,不是指著一份可能已經過時的 Word 文件。
結語
測試策略是工程決策,不是信仰問題。你不需要 100% 覆蓋率。你需要的是信心 — 確信剛寫的程式碼能運作、確信重構沒有破壞任何東西、確信新同事能理解預期行為。
本文描述的方法 — 純函數的 Unit Test、互動行為的 Component Test、關鍵路徑的 E2E Test — 用最少的維護成本換到最大的信心。
每一個測試都來自真實元件解決的真實問題:擋垃圾訂閱、防抖點擊、偵測 Markdown。測試不是開發之外的額外工作。它是精準度的體現 — 你對每一條邏輯分支的預期行為都足夠清楚,清楚到可以寫成可執行的斷言。
從你最沒把握的那個函數開始。寫一個測試。讓它通過。然後決定下一個要測什麼。