跳到主要內容

FE Day 40 測試與驗收

FE Day 40 測試與驗收

執行需求:CPU 可跑。今天把 Day 38 的登入流程、Day 39 的效能調校都用 Vitest 3.x 與 Playwright 1.5x 寫成可重現的回歸測試,並補上 axe-core 的無障礙自動檢查。具體四件事:第一,把 AuthProvider 的 reducer 寫成 Vitest 單元測試(沿用 Day 38);第二,用 Playwright 寫 E2E 測試覆蓋登入、下單、後台確認三條核心路徑;第三,在每個 E2E 測試內加入 axe-core 掃描,a11y 違規就 fail;第四,把 Lighthouse 跑進 CI,效能預算超標就 fail。所有資料仍是虛構示範,沿用 Day 31-39 的共用設定;無真實後端時用 mock 模式讓測試可以完全離線跑。

引言

測試是軟體工程的「保險絲」。沒寫測試的程式碼在規模還小時看起來「沒事」,一旦擴充就會開始出現「改 A 壞 B」的鬼故事。對前端來說測試有三個層次:單元測試驗證函式邏輯、元件測試驗證渲染、E2E 測試驗證使用者流程。今天會把這三層都寫一遍,讓預約管理系統在 CI 階段就能抓到 90% 的回歸問題。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。Day 31-37 把介面建好、Day 38 把登入與角色導向完成、Day 39 把效能調到健康。今天進入「測試與驗收」:把所有流程包成 Vitest + Playwright 雙層測試,並用 axe-core 自動檢查 a11y、用 Lighthouse 守住效能預算。這層做完之後,整個專案的「可維護性」就到位了:未來任何改動都能在 5 分鐘內被驗證。

今天的內容分四段:第一段設定 Vitest 環境並寫 reducer / API client 的單元測試;第二段用 Playwright 寫 E2E 測試覆蓋三條核心路徑;第三段在 E2E 內嵌入 axe-core 做 a11y 自動檢查;第四段用 Lighthouse 與 bundle 大小設定 CI 守護。讀完這篇你會了解:Vitest 3.x 與 React Testing Library 的搭配、Playwright 1.5x 的 fixture 與平行測試、axe-core 的常見違規、Lighthouse CI 的設定方式、怎麼在 GitHub Actions 把整套串起來。

貫穿專案共用設定(Day 31-44 沿用)

今天的測試建立在 Day 31-39 已建好的 stack 上,繼續沿用:

  • 框架:Next.js 15(App Router)、React 19.x、TypeScript 5.9
  • 樣式:Tailwind CSS 4.x、CSS variables 主題、Heroicons
  • 後端:FastAPI 預約管理系統(Web 系列 Day 35-44),測試時切到 mock 模式
  • 認證:JWT access token(12 小時)、refresh token(14 天);沿用 Day 38 的 AuthProvider 與 API client
  • 測試工具:Vitest 3.x(單元 + 元件)、Playwright 1.5x(E2E)、axe-core 4.x(a11y)、Lighthouse 12.x(CI 守護)
  • Mock:NEXT_PUBLIC_API_MODE=mock 時全部走 lib/mock/*.ts fixture,測試完全不依賴真實後端

這份設定從 Day 31 到 Day 44 不變;測試只在既有設定上「加工具與案例」,不改架構。理解這點才能看懂為什麼今天的測試可以離線跑、為什麼 mock fixture 是關鍵設計。

原理解念:測試金字塔與 E2E 投資

測試金字塔是業界對測試投資分配的經驗法則:底層單元測試(70%)、中層整合測試(20%)、上層 E2E 測試(10%)。單元測試跑得快(毫秒級)、維護成本低、覆蓋率容易拉高,但只驗證「函式邏輯」無法驗證「使用者體驗」。E2E 測試跑得慢(秒級)、維護成本高、覆蓋率難拉高,但能驗證「整條使用者流程」。我們今天三層都寫,但 E2E 只覆蓋「最重要的三條路徑」而不是「所有頁面」——這是投資報酬率最高的策略。

Mock vs 假後端(fake backend)是 E2E 測試的關鍵決策。Mock(在本進程內回傳假資料)跑得快但測的是「前端邏輯」;假後端(起一個 Express/Hono 跑假資料)跑得稍慢但更貼近真實。我們選「mock + 完整 API 形狀」這個組合:mock 模組實作了所有真實 API 端點的相同介面,E2E 測試時前端完全感覺不到差別。這個設計的好處是「測試隨時能跑、不依賴網路」、壞處是「mock 跟真實後端的契約可能漂移」。對應處理:在 PR 階段額外跑一次「打真實 staging 後端」的 smoke test,但本地開發與 CI 主流程用 mock。

axe-core 是業界最廣泛使用的 a11y 自動檢查工具,由 deque 公司維護。它把 WAI-ARIA 規範轉成可執行的規則集,能在瀏覽器內掃描 DOM 並回報違規。我們在每個 E2E 測試結尾加一次 axe scan,這樣「按鈕沒有 label」或「圖片缺 alt」這類問題會在 CI 階段被擋下。axe-core 的常見違規有四類:顏色對比不足、互動元素不可鍵盤操作、ARIA 角色錯誤、表單欄位缺 label。今天的三條核心 E2E 都會用 axe 驗證。

完整實作:Vitest、Playwright、axe-core

第一步:Vitest 設定與 reducer / API client 單元測試。

# 安裝 Vitest 3.x 與相關套件(2026 年 3 月主流版本)
pnpm add -D vitest@^3.0.0 @vitest/coverage-v8 @testing-library/react@^16.0.0
pnpm add -D @testing-library/jest-dom @testing-library/user-event
pnpm add -D jsdom@^25.0.0
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      include: ['app/**/*.{ts,tsx}', 'lib/**/*.{ts,tsx}'],
      exclude: ['**/*.test.*', '**/mock/**'],
      thresholds: { statements: 80, branches: 70, functions: 80, lines: 80 },
    },
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, '.') },
  },
});

Vitest 3.x 跟 Vite 共用配置,所以 import 設定與 alias 可以直接從 next.config 借。我們設了 80% 的覆蓋率門檻,這對小型專案是合理的甜蜜點:太寬鬆會漏、太高會鼓勵為測試而測試(無意義的測試)。

// tests/setup.ts(全域設定)
import '@testing-library/jest-dom/vitest';
import { vi, beforeEach } from 'vitest';

// 把 next/navigation 的 useRouter mock 成可注入版本
vi.mock('next/navigation', () => ({
  useRouter: () => ({ replace: vi.fn(), push: vi.fn(), refresh: vi.fn() }),
  usePathname: () => '/',
  useSearchParams: () => new URLSearchParams(),
}));

// 把 document.cookie 與 matchMedia mock 起來
Object.defineProperty(window, 'matchMedia', {
  value: vi.fn().mockImplementation((query) => ({
    matches: false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(),
  })),
});

beforeEach(() => {
  // 每個測試開始前清空 cookie,避免測試互相污染
  document.cookie.split(';').forEach((c) => {
    document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/');
  });
});

第二步:reducer 與 API client 的單元測試(沿用 Day 38 的設計)。

// tests/auth.reducer.test.ts(節錄)
import { describe, it, expect } from 'vitest';
import { reducer, initialState } from '@/app/providers/AuthProvider';

describe('auth reducer', () => {
  const sampleUser = { id: 'u-1', email: 'a@b.com', displayName: 'A', role: 'admin' };

  it('BOOTSTRAP with null payload 進入 anonymous', () => {
    const next = reducer(initialState, { type: 'BOOTSTRAP', payload: null });
    expect(next.status).toBe('anonymous');
    expect(next.user).toBeNull();
  });

  it('LOGIN 後狀態為 authenticated 且 accessToken 正確', () => {
    const next = reducer(initialState, {
      type: 'LOGIN',
      payload: { user: sampleUser, accessToken: 'jwt.token.value' },
    });
    expect(next.status).toBe('authenticated');
    expect(next.accessToken).toBe('jwt.token.value');
  });

  it('EXPIRE 把狀態從 authenticated 轉為 expired 並清空 user', () => {
    const authed = { status: 'authenticated', user: sampleUser, accessToken: 't' };
    const next = reducer(authed, { type: 'EXPIRE' });
    expect(next.status).toBe('expired');
    expect(next.user).toBeNull();
    expect(next.accessToken).toBeNull();
  });

  it('未知 action type 不改變狀態', () => {
    const next = reducer(initialState, { type: 'UNKNOWN' } as any);
    expect(next).toBe(initialState);
  });
});

這組 reducer 測試覆蓋四個關鍵案例,預期 0.1 秒內跑完。reducer 是純函式,沒有副作用,測試不需要 mock 任何東西——這是 Day 38 把它設計成純函式的紅利。

第三步:API client 的單元測試(401 自動 refresh 邏輯)。

// tests/api-client.test.ts(節錄)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { apiPost } from '@/lib/api-client';

describe('api-client refresh 邏輯', () => {
  beforeEach(() => {
    document.cookie = 'bf_access=old.token; Path=/';
    vi.restoreAllMocks();
  });

  it('收到 401 時自動呼叫 refresh 並重發原請求', async () => {
    const fetchMock = vi.fn()
      .mockResolvedValueOnce({ status: 401, ok: false, text: () => Promise.resolve('unauthorized') })
      .mockResolvedValueOnce({ status: 200, ok: true, json: () => Promise.resolve({ access_token: 'new.token' }) })
      .mockResolvedValueOnce({ status: 200, ok: true, json: () => Promise.resolve({ ok: true }) });
    global.fetch = fetchMock;

    await expect(apiPost('/bookings/', { serviceId: 's1' })).resolves.toEqual({ ok: true });
    expect(fetchMock).toHaveBeenCalledTimes(3);
    expect(fetchMock.mock.calls[1][0]).toBe('/api/auth/refresh');
    expect(fetchMock.mock.calls[2][0]).toBe('/api/proxy/bookings/');
  });

  it('refresh 也失敗時拋 401 例外', async () => {
    const fetchMock = vi.fn()
      .mockResolvedValueOnce({ status: 401, ok: false })
      .mockResolvedValueOnce({ status: 401, ok: false });
    global.fetch = fetchMock;

    await expect(apiPost('/bookings/', {})).rejects.toThrow();
  });
});

這組測試用 vi.fn() mock fetch,驗證「401 → refresh → 重試」、「refresh 也失敗」兩條路徑。mock 的呼叫次數與呼叫順序都要正確,否則可能被程式碼的順序錯亂誤導。

第四步:Playwright E2E 設定與三條核心路徑。

# 安裝 Playwright 1.5x
pnpm add -D @playwright/test@^1.5.0
pnpm exec playwright install chromium
# 安裝 axe-core 與 playwright-axe
pnpm add -D axe-core@^4.10.0 @axe-core/playwright@^4.10.0
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 60_000,
    env: { NEXT_PUBLIC_API_MODE: 'mock' },
  },
});

Playwright 1.5x 的設定重點:CI 階段用 2 worker 平行跑、fail 時保留 trace;本機單執行緒;webServer 自動起 dev server 並注入 NEXT_PUBLIC_API_MODE=mock,這樣 E2E 完全不依賴真實後端。我們刻意分兩個 project:chromium-desktop 給桌機測試、mobile-safari 給手機測試(驗證響應式 layout)。

第五步:寫三條核心路徑的 E2E。

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('登入流程', () => {
  test('admin 登入後自動進後台', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('密碼').fill('admin-pass');
    await page.getByRole('button', { name: '登入' }).click();

    // 登入成功後應自動導向 /admin/bookings
    await expect(page).toHaveURL(/\/admin\/bookings/);
    await expect(page.getByRole('heading', { name: '預約清單' })).toBeVisible();

    // a11y 自動檢查
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
  });

  test('錯誤密碼顯示錯誤訊息且不跳轉', async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('密碼').fill('wrong-password');
    await page.getByRole('button', { name: '登入' }).click();
    await expect(page.getByRole('alert')).toContainText('帳號或密碼錯誤');
    await expect(page).toHaveURL(/\/login/);
  });
});

這個 E2E 跑了兩條 case:正確密碼登入後導向後台、錯誤密碼顯示錯誤訊息不跳轉。每條 case 都加了 axe scan,違規就 fail。axe 預設跑 WCAG 2.1 AA 等級,這對一般應用是合理預設。對螢幕閱讀器、鍵盤操作、色彩對比、ARIA 角色等四類常見問題都能抓到。

// tests/e2e/booking-flow.spec.ts(客戶下單路徑)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('客戶從瀏覽服務到建立預約', async ({ page }) => {
  // 1. 登入 customer
  await page.goto('/login');
  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('密碼').fill('alice-pass');
  await page.getByRole('button', { name: '登入' }).click();
  await expect(page).toHaveURL(/\/services/);

  // 2. 點選第一個服務
  await page.getByRole('article').first().getByRole('link', { name: /預約/ }).click();
  await expect(page.getByRole('heading', { name: /個人攝影|健身|語言/ })).toBeVisible();

  // 3. 選時段、送單
  await page.getByRole('button', { name: '14:00' }).click();
  await page.getByRole('button', { name: '確認送出' }).click();
  await expect(page.getByRole('status')).toContainText('預約成功');

  // 4. 在「我的預約」看到剛建立的紀錄
  await page.goto('/bookings');
  await expect(page.getByRole('table')).toContainText('個人攝影');

  // a11y
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

第三條 E2E 是後台確認流程:admin 登入、看到剛建立的 pending 預約、按下確認按鈕、預約狀態變為 confirmed。

第六步:把整套串進 GitHub Actions CI。

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  unit-and-component:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm vitest run --coverage
      - name: 覆蓋率門檻檢查
        run: |
          if [ $(jq '.total.lines.pct' coverage/coverage-summary.json) -lt 80 ]; then
            echo "覆蓋率未達 80%"; exit 1
          fi

  e2e-and-a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test
        env: { NEXT_PUBLIC_API_MODE: mock, CI: 'true' }
      - uses: actions/upload-artifact@v4
        if: failure()
        with: { name: playwright-report, path: playwright-report/ }

  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - name: Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          configPath: .lighthouserc.json
          uploadArtifacts: true

這個 workflow 分三個 job 平行跑:unit-and-component 跑 Vitest + 覆蓋率門檻、e2e-and-a11y 跑 Playwright 三條核心路徑 + axe、lighthouse 對 production build 跑效能檢查。三個 job 都設在 PR 觸發,所以「改完程式沒跑測試」的 PR 永遠進不了 main。

// .lighthouserc.json
{
  "ci": {
    "collect": {
      "url": ["http://127.0.0.1:3000/", "http://127.0.0.1:3000/services"],
      "numberOfRuns": 3
    },
    "assert": {
      "preset": "lighthouse:recommended",
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["warn", { "maxNumericValue": 300 }]
      }
    }
  }
}

Lighthouse CI 跑 3 次取中位數、效能分 ≥ 90、無障礙分 ≥ 95、LCP ≤ 2.5 秒、CLS ≤ 0.1。對小團隊這個預算合理:太寬鬆會放過回歸、太嚴格會被效能波動干擾。實務上我們把 warn 與 error 分開,warn 只警告不擋 PR。

常見錯誤與踩雷

第一個常見錯誤是「E2E 測試用真實後端」。如果 Playwright 連到 staging 或 production 預備環境,每次 CI 跑都會打真實 API 一次,遇到 staging 維護時整個 CI 就紅了。對應處理:把 E2E 完全切到 mock 模式,並在 PR 階段額外跑一支 smoke test 打 staging。我們的 NEXT_PUBLIC_API_MODE=mock 環境變數與 playwright.config.ts 的 webServer.env 就是這個機制的實作。

第二個常見踩雷是「忘記清 cookie 導致測試污染」。Vitest 的 jsdom 環境會在多個測試間共用 document.cookie,如果前一個測試寫了 bf_access=xxx、下一個測試沒清,狀態就會污染。對應處理:在我們的 tests/setup.ts 內 beforeEach 清空所有 cookie,並把 fetch mock 在每個 test 開頭 vi.restoreAllMocks() 重置。

第三個是「axe 違規種類太多讓 CI 一直紅」。axe 預設跑完整 WCAG 2.1 AA 規則集,會抓到非常細節的問題(例如「按鈕對比度差 0.1」也會列為違規)。對剛起步的專案建議先用 wcag2a 標籤過濾最基本的 A 等級規則:

// 只跑 WCAG 2.1 A 等級(更寬鬆、噪音更少)
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag21a']).analyze();

隨著專案成熟再逐步加上 AA、AAA 等級。我們的預約管理系統先用 AA,當違規降到個位數再考慮往 AAA 推進。

第四個是「Playwright 平行測試的 port 衝突」。預設每個 worker 都會用相同 port 起 dev server。我們的 reuseExistingServer: !process.env.CI 設定讓本機只起一個 server、所有 worker 共用,CI 環境則每個 worker 自己起。這是 Playwright 1.5x 的標準寫法。

第五個是「Lighthouse 跑分波動太大」。Lighthouse 跑 3 次取中位數,但仍可能因為 CI 主機當下負載而波動 ±5 分。對應處理:把 perf 預算設為「warn」而非「error」,只把 LCP / CLS 這類硬指標設為 error。我們的 .lighthouserc.json 就是這個策略:分數 warn、LCP / CLS error。

效能與實務提醒

測試金字塔的執行順序很重要。先寫單元測試、再寫元件測試、最後才寫 E2E——這是「從下往上」的建設順序。我們今天示範的順序是 Vitest(單元)→ React Testing Library(元件)→ Playwright(E2E)→ axe-core(a11y)→ Lighthouse CI(效能)。跳過任何一層都會讓回歸測試變得不完整:只寫 E2E 不寫單元,速度慢、抓 bug 範圍小;只寫單元不寫 E2E,會漏掉整合層的問題。

mock 與真實契約的同步是另一個常被忽略的成本。如果後端的 /auth/login 改了回傳欄位(例如 access_token 變成 accessToken),前端 mock 還在回舊欄位,前端程式碼就會壞但測試不會抓到。對應處理:把後端的 OpenAPI 規格(Web 系列 Day 44 的 /openapi.json)餵給 openapi-typescript 自動產生 TS 類型,再讓 mock 與真實共用同一份類型。我們的 lib/types.ts 就是這個機制,Day 36 已建立。

覆蓋率門檻也是雙面刃。設太高(90%)會鼓勵「為測試而測試」的低品質測試;設太低(50%)會讓測試失去意義。我們的 80% 是基於「使用者介面元件很難每個都測、但商業邏輯必須測」的取捨:元件檔可以寬鬆(60%),但 API client、reducer、表單驗證要嚴格(95%+)。

最後,CI 跑測試的時間成本要控制。如果 E2E 跑 10 分鐘,工程師就會放棄在本地先跑、直接 push 讓 CI 抓。對應處理:把 E2E 切成「快速(5 條核心 path,30 秒)」與「完整(所有 path,5 分鐘)」兩個 tier,PR 跑快速、合併前才跑完整。我們的 Playwright config 用 projects 切分就是這個思路的延伸。

小結

今天把預約管理系統的測試從「零」補到「三層 + 兩道守護」。我們裝了 Vitest 3.x 寫 reducer / API client 單元測試,用 React Testing Library 16.x 寫元件測試,用 Playwright 1.5x 寫三條 E2E 核心路徑(登入、下單、後台確認),用 axe-core 在每條 E2E 結尾做 a11y 自動檢查,用 Lighthouse 12.x 設定效能預算守護。三層測試加兩道守護讓任何改動都能在 CI 階段被驗證,後續 Day 41 部署時把這個 workflow 跑過一次,就能直接上 production。所有資料仍是虛構示範,沿用 Day 31-39 的共用設定。明天 Day 41 會把整套系統部署到 Vercel,用 NEXT_PUBLIC_API_MODE=live 切換到真實後端,並驗證 Speed Insights 開始收到現場資料。

回頭看今天的所有測試,有幾條值得強調的設計原則。第一,純函式優先:reducer 與工具函式都比元件更易測,這也是 Day 38 我們刻意把狀態邏輯搬出 React 元件的理由。第二,fixture 集中化:mock 帳號、mock 預約清單都集中在 tests/fixtures/,所有測試共用,確保一個改動就同步所有測試。第三,E2E 寧少勿濫:我們只寫三條核心路徑而不是全部頁面,這樣維護成本可控、CI 跑得快。第四,CI 即規格:GitHub Actions workflow 本身就是「如何 build / test / deploy」的執行紀錄,新人第一天看 workflow 就知道專案結構。

另一個值得記下的細節是「測試失敗時的修復成本」。我們的 Playwright config 設了 screenshot: 'only-on-failure'、trace: 'retain-on-failure',這樣 CI 失敗時會留下截圖與 trace artifact,開發者不用本地重跑也能從 GitHub Actions 直接下載 trace 開啟測試瀏覽器復現問題。這個機制讓 CI 失敗的「除錯時間」從 10 分鐘壓到 2 分鐘,是 productivity 投資最高的小工具之一。同樣的道理也適用於 Vitest:--reporter=html 會在 html/ 目錄產生互動式報告,可以列出每個失敗 case 的 call stack。

測試與部署之間的關係也很重要。我們刻意把 NEXT_PUBLIC_API_MODE 設為環境變數,讓「同一份程式碼」可以分別跑 mock 模式(測試、demo)與 live 模式(production)。這種「環境切換」是現代前端專案的標配:local 用 mock、preview deploy 用 mock、staging 用真實後端、production 用真實後端。Day 41 部署時我們會把這條鏈路完整接起來,並說明怎麼在 GitHub Actions workflow 內區分四種環境的設定。

最後是測試與 a11y 的長期投資。寫一次 axe scan 是幾行程式碼,但「持續維持無違規」是每天的紀律。我們的策略是:每開一個 PR 都跑一次 axe,有違規就解、不累積。實務上「累積 50 個違規再一起修」幾乎不可能,因為每個違規背後都是 UI 設計或程式碼結構問題;累積太多會讓整個 codebase 都「過 a11y 是不可能的」。一天修 1-2 個違規是甜蜜點:負擔小、進度可見、半年後 codebase 就清乾淨了。這種「持續改進」的節奏比「一次大改」健康很多,也是測試自動化帶給團隊的最大紅利。

結語

今天的重點是把測試變成「可重現的程式碼」。我們刻意把 Vitest、Playwright、axe、Lighthouse 都包進 GitHub Actions,讓測試不只是「我們團隊知道要跑」而是「PR 進不去 main」。這種「用 CI 守護品質」的設計比「靠工程師自律」可靠很多。明天的部署篇會把這整套 workflow 接到 Vercel preview deploy,每次 PR 都能拿到一個 preview URL 給 reviewer 看實際效果,而不只是看程式碼 diff。我們今天在 mock 模式下驗證的所有測試,明天切到 live 模式時只要再跑一次同樣的腳本,確認真實後端的契約仍然相符,整個交付鏈就閉環了。

延伸資源

  • Vitest 官方文件(3.x,2026):https://vitest.dev/,jest-compatible API、Vite 整合、coverage 設定。
  • Playwright 官方文件(1.5x,2026):https://playwright.dev/,fixture、parallel、webServer 設定。
  • axe-core 規則集(4.x,2026):https://github.com/dequelabs/axe-core,WCAG 2.1 AA 完整規則集。
  • Lighthouse CI 官方說明(12.x,2026):https://github.com/GoogleChrome/lighthouse-ci,lighthouserc.json 與 GitHub Action。
  • React Testing Library(16.x,2026):https://testing-library.com/docs/react-testing-library/intro,以使用者角度查詢 DOM 的測試寫法。
  • Day 15 測試 Vitest 與元件測試:原文連結,本系列第一輪的測試章節。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...