EP.01工程品質與 DevOps

測試金字塔:Unit、Integration、E2E
什麼值得測?怎麼測才有效?

「寫測試很浪費時間」是初階工程師的想法。 「不寫測試才是最浪費時間的」是走過大型專案的工程師的體悟。 這篇帶你建立正確的測試心態與實作技巧。

Joseph Chen 2026 14 min read Jest · RTL · Playwright · TDD · Test Pyramid

「沒有測試的程式碼不是完成了,只是還不知道哪裡壞了。 測試的真正價值不是驗證現在能跑,而是保證未來修改時不會悄悄壞掉。」

測試不是讓你的程式碼沒有 bug——而是讓你有勇氣重構、有信心部署。 這篇從測試金字塔出發,教你分辨哪些地方該寫什麼層次的測試, 以及如何用 Jest + React Testing Library 寫出真正有價值的測試。

測試金字塔:三層測試的職責分工

測試金字塔描述了三種測試的理想比例:下層數量多但快,上層數量少但真實。 顛倒金字塔(只寫 E2E)是常見反模式——慢、不穩定、難以定位問題。

E2E 測試

Playwright / Cypress

少量(~10%)

模擬真實使用者操作,從瀏覽器到資料庫的完整流程

Integration 測試

Jest + Supertest / MSW

中量(~30%)

測試多個模組組合後的行為(API 路由、DB 查詢)

Unit 測試

Jest + RTL

大量(~60%)

測試單一函數或組件的邏輯,完全隔離外部依賴

速度

慢 ← → 快

維護成本

高 ← → 低

錯誤定位

模糊 ← → 精確

Unit Test:測試最小可測單元

Unit Test 針對單一函數或組件,隔離所有外部依賴(DB、API、時間)。 執行快(毫秒級),失敗時能精確定位問題在哪行。

純函數的 Unit Test(Jest)
// utils/price.ts
export function calculateDiscount(price: number, discountRate: number): number {
    if (discountRate < 0 || discountRate > 1) throw new Error('Invalid rate');
    return Math.round(price * (1 - discountRate));
}

// utils/price.test.ts
import { calculateDiscount } from './price';

describe('calculateDiscount', () => {
    test('正常折扣計算', () => {
        expect(calculateDiscount(1000, 0.1)).toBe(900);   // 9 折
        expect(calculateDiscount(999, 0.2)).toBe(799);    // 8 折(四捨五入)
    });

    test('零折扣(不打折)', () => {
        expect(calculateDiscount(500, 0)).toBe(500);
    });

    test('全額折扣', () => {
        expect(calculateDiscount(500, 1)).toBe(0);
    });

    test('無效折扣率應拋出錯誤', () => {
        expect(() => calculateDiscount(1000, -0.1)).toThrow('Invalid rate');
        expect(() => calculateDiscount(1000, 1.5)).toThrow('Invalid rate');
    });
});
React 組件 Unit Test(React Testing Library)
// components/Button.tsx
interface ButtonProps { label: string; onClick: () => void; disabled?: boolean; }
export function Button({ label, onClick, disabled }: ButtonProps) {
    return <button onClick={onClick} disabled={disabled}>{label}</button>;
}

// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
    test('顯示正確的文字', () => {
        render(<Button label="送出" onClick={() => {}} />);
        expect(screen.getByText('送出')).toBeInTheDocument();
    });

    test('點擊時呼叫 onClick', () => {
        const mockClick = jest.fn();
        render(<Button label="送出" onClick={mockClick} />);
        fireEvent.click(screen.getByText('送出'));
        expect(mockClick).toHaveBeenCalledTimes(1);
    });

    test('disabled 時不觸發 onClick', () => {
        const mockClick = jest.fn();
        render(<Button label="送出" onClick={mockClick} disabled />);
        fireEvent.click(screen.getByText('送出'));
        expect(mockClick).not.toHaveBeenCalled();
    });
});

RTL 的設計哲學:測試行為,不測實作

React Testing Library 刻意不提供選取 class 名稱、測試組件內部 state 的 API。 它強迫你從「使用者視角」測試:getByTextgetByRolegetByLabelText。 這樣的測試在你重構實作細節時不會壞掉——只要行為沒變,測試就通過。

Mock:隔離外部依賴的藝術

純函數的 Unit Test 很直觀,但實際的組件幾乎都需要呼叫 API、讀 localStorage、或依賴目前時間。 如果測試真的打 API,測試就會慢、不穩定、依賴外部服務的狀態。Mock 是用「假的實作」替換真實依賴,讓測試快速、可預測、不依賴環境。

Mock API 呼叫(Jest + MSW)
// ── 方法一:直接 mock fetch(簡單場景)────────────
global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Joseph' }),
});

// ── 方法二:MSW(Mock Service Worker,推薦)────────
// 優點:在 Node 環境(測試)和瀏覽器都能用,API 結構更真實

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
    http.get('/api/users/:id', ({ params }) => {
        return HttpResponse.json({ id: params.id, name: 'Joseph' });
    }),
    http.post('/api/users', async ({ request }) => {
        const body = await request.json();
        return HttpResponse.json({ id: 99, ...body }, { status: 201 });
    }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());  // 每個測試後重置,避免污染
afterAll(() => server.close());

test('載入使用者資料', async () => {
    render(<UserProfile userId={1} />);
    // 等待非同步內容出現
    const name = await screen.findByText('Joseph');
    expect(name).toBeInTheDocument();
});

test('API 錯誤時顯示錯誤訊息', async () => {
    // 暫時覆寫這個測試的 handler
    server.use(
        http.get('/api/users/:id', () => {
            return new HttpResponse(null, { status: 500 });
        }),
    );
    render(<UserProfile userId={1} />);
    await screen.findByText('載入失敗,請稍後再試');
});

Integration Test:測試模組的組合

Integration Test 測試多個模組組合後的行為——例如 API 路由 + 資料庫查詢, 或多個 React 組件的互動。比 Unit Test 慢,但能發現模組間的整合問題。

Integration Test 的資料庫設置

Integration Test 通常需要連接一個真實但隔離的測試資料庫(而非 Mock DB), 這樣才能發現真實的 SQL 錯誤、constraint violation、join 邏輯問題等。

# .env.test — 測試環境用獨立的 DB

DATABASE_URL="postgresql://localhost/myapp_test"

# package.json 測試指令帶入 .env.test

"test": "dotenv -e .env.test -- jest"

CI 環境通常用 GitHub Actions 的 service containers(Docker PostgreSQL)提供乾淨的測試 DB。

API 路由 Integration Test(Next.js + Jest)
// __tests__/api/users.test.ts
import { createMocks } from 'node-mocks-http';
import { GET, POST } from '@/app/api/users/route';

// 連接隔離的測試 DB(.env.test 設定);每次測試前清空確保獨立性
beforeEach(async () => {
    await db.user.deleteMany();
});

describe('GET /api/users', () => {
    test('回傳使用者列表', async () => {
        // Arrange:建立測試資料
        await db.user.createMany({
            data: [
                { name: 'Alice', email: 'alice@test.com' },
                { name: 'Bob', email: 'bob@test.com' },
            ],
        });

        // Act
        const response = await GET(new Request('http://localhost/api/users'));
        const data = await response.json();

        // Assert
        expect(response.status).toBe(200);
        expect(data).toHaveLength(2);
        expect(data[0].name).toBe('Alice');
    });
});

describe('POST /api/users', () => {
    test('成功建立使用者', async () => {
        const request = new Request('http://localhost/api/users', {
            method: 'POST',
            body: JSON.stringify({ name: 'Joseph', email: 'joseph@test.com' }),
            headers: { 'Content-Type': 'application/json' },
        });

        const response = await POST(request);
        const data = await response.json();

        expect(response.status).toBe(201);
        expect(data.id).toBeDefined();
        expect(data.name).toBe('Joseph');

        // 確認 DB 真的有資料
        const userInDB = await db.user.findUnique({ where: { email: 'joseph@test.com' } });
        expect(userInDB).not.toBeNull();
    });

    test('email 重複應回傳 409', async () => {
        await db.user.create({ data: { name: 'A', email: 'dup@test.com' } });
        const request = new Request('http://localhost/api/users', {
            method: 'POST',
            body: JSON.stringify({ name: 'B', email: 'dup@test.com' }),
            headers: { 'Content-Type': 'application/json' },
        });
        const response = await POST(request);
        expect(response.status).toBe(409);
    });
});

E2E Test:模擬真實使用者

E2E(End-to-End)測試用真實瀏覽器操作真實的 App,測試從前端到後端的完整流程。 最接近真實使用情境,但慢(秒級)且容易因網路或 UI 變動而失敗。只寫最關鍵的使用者旅程(Happy Path)

Playwright E2E 測試:登入流程
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('登入流程', () => {
    test('正確帳密可以成功登入', async ({ page }) => {
        await page.goto('/login');

        await page.fill('[data-testid="email"]', 'joseph@example.com');
        await page.fill('[data-testid="password"]', 'correctpassword');
        await page.click('[data-testid="submit"]');

        // 登入成功後導向 dashboard
        await expect(page).toHaveURL('/dashboard');
        await expect(page.getByText('歡迎回來,Joseph')).toBeVisible();
    });

    test('錯誤密碼顯示錯誤訊息', async ({ page }) => {
        await page.goto('/login');

        await page.fill('[data-testid="email"]', 'joseph@example.com');
        await page.fill('[data-testid="password"]', 'wrongpassword');
        await page.click('[data-testid="submit"]');

        await expect(page.getByText('帳號或密碼錯誤')).toBeVisible();
        await expect(page).toHaveURL('/login');  // 留在登入頁
    });
});

test.describe('購物流程', () => {
    test('加入購物車並結帳', async ({ page }) => {
        // 先登入
        await page.goto('/login');
        await page.fill('[data-testid="email"]', 'test@example.com');
        // ...

        await page.goto('/products/1');
        await page.click('text=加入購物車');
        await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');

        await page.click('text=結帳');
        await expect(page).toHaveURL('/checkout');
        // ...
    });
});

TDD(Test-Driven Development)簡單定位

TDD 是「先寫測試再寫功能」的開發節奏:紅(寫失敗的測試)→ 綠(寫最小可過的程式碼)→ 重構(清理)。 它的核心價值不是測試覆蓋率,而是強迫你在實作前思清楚 API 介面與邊界條件。 不是每個情境都適合 TDD,但它在「需求明確的純函數邏輯」上效果最好。

測試反模式與最佳實踐

❌ 反模式:測試實作細節

不好的寫法

// ❌ 測試內部 state(重構後馬上壞掉)
expect(component.state.isLoading).toBe(false);

✅ 改善後

// ✅ 測試使用者看到的結果
expect(screen.queryByText('載入中...')).not.toBeInTheDocument();

❌ 反模式:不獨立的測試(順序依賴)

不好的寫法

// ❌ 測試 B 依賴測試 A 建立的資料
test('A', () => db.create(...));
test('B', () => { /* 假設 A 的資料還在 */ });

✅ 改善後

// ✅ 每個測試獨立設置自己需要的資料
beforeEach(async () => { await db.deleteMany(); });
test('B', async () => { await db.create(...); /* 自己的資料 */ });

❌ 反模式:斷言太模糊

不好的寫法

// ❌ 只驗證有回應,不驗證內容
expect(response.status).toBe(200);

✅ 改善後

// ✅ 驗證具體的業務邏輯結果
expect(response.status).toBe(200);
expect(data.name).toBe('Joseph');
expect(data.email).toBe('joseph@test.com');

本篇重點回顧

🏔️測試金字塔:Unit(多、快、精確)> Integration(中)> E2E(少、慢、真實)。不要顛倒金字塔只寫 E2E。
Unit Test 用 Jest,測試純函數邏輯;React 組件用 RTL,測試使用者行為而非實作細節。
🎭Mock 隔離外部依賴:推薦 MSW(Mock Service Worker),比直接 mock fetch 更真實且可重用。
🔗Integration Test 測試多模組組合,通常連接真實(但隔離的)測試資料庫,每次測試前清空。
🌐E2E 只測最關鍵的 Happy Path(登入、購買、核心功能),用 Playwright 或 Cypress。
🚫反模式:不測實作細節、不讓測試互相依賴、不用模糊的斷言。好的測試是重構時的安全網,不是負擔。
Testing
Unit Test
Integration
E2E
Jest
RTL
Playwright
MSW
TDD
EP.01