iT邦幫忙

2025 iThome 鐵人賽

0
Modern Web

前端工程師的 Modern Web 實踐之道系列 第 15

測試驅動的現代化開發:單元測試到 E2E 的完整策略

  • 分享至 

  • xImage
  •  

系列文章: 前端工程師的 Modern Web 實踐之道 - Day 15
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆

🎯 今日目標

在前兩週的文章中,我們從現代化基礎建設到核心開發實踐,建構了完整的 Modern Web 開發體系。從今天開始,我們進入第三週的主題:品質與效能。而測試,正是確保程式碼品質的第一道防線。

想像一下這個場景:週五下午五點,你剛準備下班,突然收到一個緊急通知——剛上線的新功能導致整個購物車系統崩潰,線上使用者無法結帳。你急忙回滾程式碼,卻發現問題出在一個看似無關的功能模組,而這個 bug 在開發環境中完全沒有被發現。

這樣的經驗,相信許多工程師都不陌生。根據 IBM 的研究報告,在生產環境中修復一個 bug 的成本,是在開發階段發現並修復的 15 倍。這就是為什麼測試不是可有可無的「額外工作」,而是現代化開發流程中不可或缺的核心環節。

為什麼測試是現代化開發的必修課?

  • 降低維護成本: 完善的測試能在重構時快速驗證功能完整性,讓你敢於改進程式碼
  • 提升開發信心: 自動化測試就像安全網,讓團隊能更快速地推進新功能
  • 文件化效果: 好的測試本身就是最佳的技術文件,清楚展示功能的預期行為
  • 團隊協作保障: 測試確保每個人的改動不會破壞他人的功能,保護團隊的生產力

今天我們將深入探討從單元測試到 E2E 測試的完整策略,建立一套真正適合 Modern Web 應用的測試體系。

🔍 測試金字塔:現代化測試策略的基礎

在談具體的測試技術之前,我們需要先理解一個重要的概念:測試金字塔。這個由 Mike Cohn 提出的模型,至今仍是測試策略設計的黃金準則。

           /\
          /  \  E2E Tests (端到端測試)
         /____\
        /      \
       / Integr-\ Integration Tests (整合測試)
      /   ation  \
     /____________\
    /              \
   /  Unit Tests    \ Unit Tests (單元測試)
  /__________________\

為什麼是金字塔而不是其他形狀?

這個金字塔的形狀隱含了三個重要的測試原則:

  1. 數量比例: 單元測試應該最多,整合測試次之,E2E 測試最少
  2. 執行速度: 底層測試跑得快,頂層測試跑得慢
  3. 維護成本: 底層測試穩定易維護,頂層測試脆弱需常調整

很多專案失敗的原因,就是把金字塔倒過來了——大量依賴 E2E 測試,卻忽略單元測試。結果就是測試跑一次要 30 分鐘,而且經常因為環境問題失敗,最後團隊乾脆不跑測試了。

現代前端專案的測試配比建議

根據我在多個大型專案的實踐經驗,以下是比較理想的測試配比:

  • 單元測試: 70% - 測試純函式、工具函式、業務邏輯
  • 整合測試: 20% - 測試組件互動、API 整合、狀態管理
  • E2E 測試: 10% - 測試關鍵使用者路徑、核心業務流程

這個比例不是絕對的,但可以作為起點。重點是要理解每種測試的特性和適用場景。

💻 單元測試:測試體系的基石

單元測試是測試金字塔的基礎,也是最容易被低估的部分。很多人認為單元測試「太細碎」、「沒有實際價值」,但實際上,單元測試是整個測試體系中投資報酬率最高的部分。

什麼值得寫單元測試?

不是所有程式碼都需要單元測試。以下是我的判斷標準:

✅ 應該寫單元測試的:

  • 純函式和工具函式
  • 複雜的業務邏輯
  • 資料轉換和計算邏輯
  • 邊界條件和錯誤處理
  • 關鍵的演算法實作

❌ 不需要單元測試的:

  • 簡單的 getter/setter
  • 框架自帶的功能
  • 純 UI 佈局組件(應該用視覺測試)
  • 第三方套件的封裝(除非有複雜邏輯)

實戰範例:測試複雜的業務邏輯

假設我們有一個電商網站的折扣計算系統,需要處理多種優惠規則的組合:

// src/utils/priceCalculator.ts

/**
 * 價格計算器 - 處理複雜的折扣規則
 */

export interface DiscountRule {
  type: 'percentage' | 'fixed' | 'buyXGetY';
  value: number;
  minAmount?: number;
  maxDiscount?: number;
}

export interface CartItem {
  id: string;
  price: number;
  quantity: number;
  category: string;
}

export class PriceCalculator {
  /**
   * 計算購物車總價,套用折扣規則
   * @param items - 購物車商品列表
   * @param discounts - 折扣規則列表
   * @returns 最終價格資訊
   */
  static calculateTotal(items: CartItem[], discounts: DiscountRule[] = []) {
    // 計算原始總價
    const subtotal = items.reduce((sum, item) => {
      return sum + item.price * item.quantity;
    }, 0);

    // 套用折扣規則
    let totalDiscount = 0;

    for (const rule of discounts) {
      const discount = this.applyDiscountRule(subtotal, rule);
      totalDiscount += discount;
    }

    // 確保折扣不超過原價
    totalDiscount = Math.min(totalDiscount, subtotal);

    return {
      subtotal,
      discount: totalDiscount,
      total: subtotal - totalDiscount,
    };
  }

  /**
   * 套用單一折扣規則
   */
  private static applyDiscountRule(amount: number, rule: DiscountRule): number {
    // 檢查最低消費門檻
    if (rule.minAmount && amount < rule.minAmount) {
      return 0;
    }

    let discount = 0;

    switch (rule.type) {
      case 'percentage':
        discount = amount * (rule.value / 100);
        break;

      case 'fixed':
        discount = rule.value;
        break;

      case 'buyXGetY':
        // 簡化的買 X 送 Y 邏輯
        discount = rule.value;
        break;

      default:
        throw new Error(`Unknown discount type: ${(rule as any).type}`);
    }

    // 套用最大折扣限制
    if (rule.maxDiscount) {
      discount = Math.min(discount, rule.maxDiscount);
    }

    return discount;
  }

  /**
   * 驗證折扣規則的合法性
   */
  static validateDiscountRule(rule: DiscountRule): boolean {
    if (rule.value < 0) {
      return false;
    }

    if (rule.type === 'percentage' && rule.value > 100) {
      return false;
    }

    if (rule.minAmount && rule.minAmount < 0) {
      return false;
    }

    return true;
  }
}

現在讓我們為這個邏輯寫完整的單元測試:

// src/utils/priceCalculator.test.ts

import { describe, it, expect } from 'vitest';
import { PriceCalculator, CartItem, DiscountRule } from './priceCalculator';

describe('PriceCalculator', () => {
  // 準備測試資料
  const mockItems: CartItem[] = [
    { id: '1', price: 100, quantity: 2, category: 'electronics' },
    { id: '2', price: 50, quantity: 1, category: 'books' },
  ];

  describe('calculateTotal', () => {
    it('應該正確計算沒有折扣的總價', () => {
      const result = PriceCalculator.calculateTotal(mockItems);

      expect(result).toEqual({
        subtotal: 250,
        discount: 0,
        total: 250,
      });
    });

    it('應該正確套用百分比折扣', () => {
      const discounts: DiscountRule[] = [
        { type: 'percentage', value: 10 }, // 9折
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      expect(result).toEqual({
        subtotal: 250,
        discount: 25,
        total: 225,
      });
    });

    it('應該正確套用固定金額折扣', () => {
      const discounts: DiscountRule[] = [
        { type: 'fixed', value: 50 },
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      expect(result).toEqual({
        subtotal: 250,
        discount: 50,
        total: 200,
      });
    });

    it('應該處理最低消費門檻', () => {
      const discounts: DiscountRule[] = [
        { type: 'fixed', value: 50, minAmount: 300 }, // 需滿 300 才能折
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      // 因為總價 250 < 300,所以不套用折扣
      expect(result).toEqual({
        subtotal: 250,
        discount: 0,
        total: 250,
      });
    });

    it('應該處理最大折扣限制', () => {
      const discounts: DiscountRule[] = [
        { type: 'percentage', value: 50, maxDiscount: 100 }, // 最多折 100
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      // 50% 折扣是 125,但限制最多 100
      expect(result).toEqual({
        subtotal: 250,
        discount: 100,
        total: 150,
      });
    });

    it('應該正確組合多個折扣', () => {
      const discounts: DiscountRule[] = [
        { type: 'percentage', value: 10 },
        { type: 'fixed', value: 20 },
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      expect(result).toEqual({
        subtotal: 250,
        discount: 45, // 25 + 20
        total: 205,
      });
    });

    it('應該確保折扣不超過原價', () => {
      const discounts: DiscountRule[] = [
        { type: 'fixed', value: 300 }, // 折扣超過總價
      ];

      const result = PriceCalculator.calculateTotal(mockItems, discounts);

      expect(result.total).toBeGreaterThanOrEqual(0);
      expect(result.discount).toBeLessThanOrEqual(result.subtotal);
    });

    it('應該處理空購物車', () => {
      const result = PriceCalculator.calculateTotal([]);

      expect(result).toEqual({
        subtotal: 0,
        discount: 0,
        total: 0,
      });
    });

    it('應該拋出錯誤當遇到未知的折扣型別', () => {
      const invalidDiscounts: any = [
        { type: 'unknown', value: 10 },
      ];

      expect(() => {
        PriceCalculator.calculateTotal(mockItems, invalidDiscounts);
      }).toThrow('Unknown discount type: unknown');
    });
  });

  describe('validateDiscountRule', () => {
    it('應該拒絕負數折扣', () => {
      const rule: DiscountRule = { type: 'fixed', value: -10 };
      expect(PriceCalculator.validateDiscountRule(rule)).toBe(false);
    });

    it('應該拒絕超過 100% 的百分比折扣', () => {
      const rule: DiscountRule = { type: 'percentage', value: 150 };
      expect(PriceCalculator.validateDiscountRule(rule)).toBe(false);
    });

    it('應該接受合法的折扣規則', () => {
      const rule: DiscountRule = { type: 'percentage', value: 10 };
      expect(PriceCalculator.validateDiscountRule(rule)).toBe(true);
    });
  });
});

單元測試的最佳實踐

從上面的範例中,我們可以總結幾個重要的測試原則:

1. AAA 模式 (Arrange-Act-Assert)

it('測試描述', () => {
  // Arrange: 準備測試資料和環境
  const input = { ... };

  // Act: 執行要測試的功能
  const result = someFunction(input);

  // Assert: 驗證結果
  expect(result).toBe(expected);
});

2. 測試描述要清晰明確

  • ❌ 不好: it('測試計算功能')
  • ✅ 好: it('應該正確套用百分比折扣')

3. 一個測試只驗證一個行為
不要在一個 it 中測試多個不相關的情境,保持測試的單一職責。

4. 測試邊界條件和錯誤情況
不只測試正常流程,更要測試:

  • 空值、null、undefined
  • 邊界值(0、負數、超大數值)
  • 錯誤輸入
  • 異常情況

🔗 整合測試:驗證組件間的協作

單元測試確保每個零件正常運作,但零件組裝起來會不會出問題?這就是整合測試要解決的問題。

在前端開發中,整合測試主要關注:

  • React/Vue 組件的互動行為
  • 組件與狀態管理的整合
  • API 呼叫與資料處理
  • 路由導航與頁面切換

實戰範例:測試 React 組件整合

讓我們實作一個使用者登入流程的整合測試:

// src/components/LoginForm.tsx

import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login, isLoading } = useAuth();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      await login(email, password);
      // 登入成功後會自動導航(由 useAuth 處理)
    } catch (err) {
      setError(err instanceof Error ? err.message : '登入失敗,請稍後再試');
    }
  };

  return (
    <form onSubmit={handleSubmit} aria-label="登入表單">
      <div>
        <label htmlFor="email">電子郵件</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          aria-invalid={!!error}
        />
      </div>

      <div>
        <label htmlFor="password">密碼</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          aria-invalid={!!error}
        />
      </div>

      {error && (
        <div role="alert" className="error-message">
          {error}
        </div>
      )}

      <button type="submit" disabled={isLoading}>
        {isLoading ? '登入中...' : '登入'}
      </button>
    </form>
  );
}

現在讓我們寫整合測試,使用 React Testing Library:

// src/components/LoginForm.test.tsx

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { AuthProvider } from '../contexts/AuthContext';
import * as authApi from '../api/auth';

// Mock API 呼叫
vi.mock('../api/auth');

describe('LoginForm 整合測試', () => {
  // 設定測試環境
  const renderLoginForm = () => {
    return render(
      <AuthProvider>
        <LoginForm />
      </AuthProvider>
    );
  };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('應該正確渲染登入表單', () => {
    renderLoginForm();

    expect(screen.getByLabelText('電子郵件')).toBeInTheDocument();
    expect(screen.getByLabelText('密碼')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '登入' })).toBeInTheDocument();
  });

  it('應該能輸入帳號密碼並送出', async () => {
    const user = userEvent.setup();

    // Mock 成功的 API 回應
    vi.mocked(authApi.login).mockResolvedValue({
      user: { id: '1', email: 'test@example.com' },
      token: 'fake-token',
    });

    renderLoginForm();

    // 使用者輸入帳號密碼
    await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
    await user.type(screen.getByLabelText('密碼'), 'password123');

    // 點擊登入按鈕
    await user.click(screen.getByRole('button', { name: '登入' }));

    // 驗證 API 被正確呼叫
    expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
  });

  it('應該顯示載入狀態', async () => {
    const user = userEvent.setup();

    // Mock 一個延遲的 API 回應
    vi.mocked(authApi.login).mockImplementation(() =>
      new Promise((resolve) => setTimeout(resolve, 1000))
    );

    renderLoginForm();

    await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
    await user.type(screen.getByLabelText('密碼'), 'password123');
    await user.click(screen.getByRole('button', { name: '登入' }));

    // 按鈕應該顯示載入中
    expect(screen.getByRole('button', { name: '登入中...' })).toBeDisabled();
  });

  it('應該正確處理登入失敗', async () => {
    const user = userEvent.setup();

    // Mock 失敗的 API 回應
    vi.mocked(authApi.login).mockRejectedValue(
      new Error('帳號或密碼錯誤')
    );

    renderLoginForm();

    await user.type(screen.getByLabelText('電子郵件'), 'wrong@example.com');
    await user.type(screen.getByLabelText('密碼'), 'wrongpass');
    await user.click(screen.getByRole('button', { name: '登入' }));

    // 應該顯示錯誤訊息
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('帳號或密碼錯誤');
    });
  });

  it('應該在輸入錯誤時正確標記表單欄位', async () => {
    const user = userEvent.setup();

    vi.mocked(authApi.login).mockRejectedValue(
      new Error('帳號或密碼錯誤')
    );

    renderLoginForm();

    await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
    await user.type(screen.getByLabelText('密碼'), 'wrong');
    await user.click(screen.getByRole('button', { name: '登入' }));

    await waitFor(() => {
      const emailInput = screen.getByLabelText('電子郵件');
      const passwordInput = screen.getByLabelText('密碼');

      expect(emailInput).toHaveAttribute('aria-invalid', 'true');
      expect(passwordInput).toHaveAttribute('aria-invalid', 'true');
    });
  });

  it('應該清除先前的錯誤訊息', async () => {
    const user = userEvent.setup();

    // 第一次登入失敗
    vi.mocked(authApi.login).mockRejectedValueOnce(
      new Error('帳號或密碼錯誤')
    );

    renderLoginForm();

    await user.type(screen.getByLabelText('電子郵件'), 'test@example.com');
    await user.type(screen.getByLabelText('密碼'), 'wrong');
    await user.click(screen.getByRole('button', { name: '登入' }));

    // 確認錯誤訊息出現
    await waitFor(() => {
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    // 第二次登入成功
    vi.mocked(authApi.login).mockResolvedValueOnce({
      user: { id: '1', email: 'test@example.com' },
      token: 'fake-token',
    });

    await user.clear(screen.getByLabelText('密碼'));
    await user.type(screen.getByLabelText('密碼'), 'correct');
    await user.click(screen.getByRole('button', { name: '登入' }));

    // 錯誤訊息應該消失
    await waitFor(() => {
      expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    });
  });
});

整合測試的關鍵技巧

1. 使用 Testing Library 的原則

  • 不要測試實作細節,測試使用者行為
  • getByRole, getByLabelText 等語義化查詢
  • 避免使用 data-testid(除非別無選擇)

2. Mock 外部依賴

// Mock API 呼叫
vi.mock('../api/auth', () => ({
  login: vi.fn(),
  logout: vi.fn(),
}));

// Mock 第三方套件
vi.mock('axios', () => ({
  default: {
    get: vi.fn(),
    post: vi.fn(),
  },
}));

3. 測試非同步行為

// 使用 waitFor 等待異步更新
await waitFor(() => {
  expect(screen.getByText('成功')).toBeInTheDocument();
});

// 或使用 findBy 查詢(內建等待)
const successMessage = await screen.findByText('成功');

🎭 E2E 測試:從使用者角度驗證

E2E(End-to-End)測試站在使用者的角度,測試完整的業務流程。它是測試金字塔的頂端,數量最少但價值最高。

什麼樣的場景適合寫 E2E 測試?

  • 關鍵業務流程(註冊、登入、購買、支付)
  • 多頁面的複雜互動
  • 需要驗證真實 API 整合的流程
  • 跨瀏覽器相容性驗證

實戰範例:使用 Playwright 測試購物流程

// e2e/checkout.spec.ts

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

test.describe('電商結帳流程', () => {
  test.beforeEach(async ({ page }) => {
    // 每個測試前先登入
    await page.goto('/login');
    await page.getByLabel('電子郵件').fill('test@example.com');
    await page.getByLabel('密碼').fill('password123');
    await page.getByRole('button', { name: '登入' }).click();

    // 等待導航完成
    await expect(page).toHaveURL('/');
  });

  test('應該能完成完整的購物流程', async ({ page }) => {
    // 1. 瀏覽商品
    await page.goto('/products');
    await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible();

    // 2. 選擇商品
    const firstProduct = page.getByTestId('product-card').first();
    await firstProduct.click();

    // 3. 加入購物車
    await page.getByRole('button', { name: '加入購物車' }).click();

    // 驗證購物車數量更新
    const cartBadge = page.getByTestId('cart-badge');
    await expect(cartBadge).toHaveText('1');

    // 4. 前往購物車
    await page.getByRole('link', { name: '購物車' }).click();
    await expect(page).toHaveURL('/cart');

    // 5. 驗證購物車內容
    const cartItems = page.getByTestId('cart-item');
    await expect(cartItems).toHaveCount(1);

    // 6. 前往結帳
    await page.getByRole('button', { name: '前往結帳' }).click();
    await expect(page).toHaveURL('/checkout');

    // 7. 填寫配送資訊
    await page.getByLabel('收件人姓名').fill('測試使用者');
    await page.getByLabel('聯絡電話').fill('0912345678');
    await page.getByLabel('配送地址').fill('台北市信義區信義路五段7號');

    // 8. 選擇付款方式
    await page.getByLabel('信用卡付款').check();

    // 9. 確認訂單
    await page.getByRole('button', { name: '確認訂單' }).click();

    // 10. 驗證訂單成功
    await expect(page).toHaveURL(/\/order\/\d+/);
    await expect(page.getByText('訂單成功')).toBeVisible();

    // 驗證訂單資訊
    await expect(page.getByText('測試使用者')).toBeVisible();
    await expect(page.getByText('0912345678')).toBeVisible();
  });

  test('應該能在結帳時套用優惠碼', async ({ page }) => {
    // 先加入商品到購物車(簡化流程)
    await page.goto('/cart/add/product-1');

    // 前往結帳頁
    await page.goto('/checkout');

    // 查看原始金額
    const originalTotal = await page.getByTestId('total-amount').textContent();

    // 輸入優惠碼
    await page.getByLabel('優惠碼').fill('WELCOME10');
    await page.getByRole('button', { name: '套用' }).click();

    // 驗證折扣訊息
    await expect(page.getByText('已套用 10% 折扣')).toBeVisible();

    // 驗證金額更新
    const discountedTotal = await page.getByTestId('total-amount').textContent();
    expect(discountedTotal).not.toBe(originalTotal);
  });

  test('應該在庫存不足時顯示警告', async ({ page }) => {
    // 訪問一個庫存為 0 的商品
    await page.goto('/products/out-of-stock-item');

    // 加入購物車按鈕應該被禁用
    const addToCartBtn = page.getByRole('button', { name: '加入購物車' });
    await expect(addToCartBtn).toBeDisabled();

    // 應該顯示缺貨訊息
    await expect(page.getByText('此商品目前缺貨')).toBeVisible();
  });

  test('應該能正確處理網路錯誤', async ({ page }) => {
    // 模擬網路錯誤
    await page.route('**/api/checkout', (route) => {
      route.abort('failed');
    });

    // 執行結帳流程
    await page.goto('/checkout');
    await page.getByLabel('收件人姓名').fill('測試使用者');
    await page.getByRole('button', { name: '確認訂單' }).click();

    // 應該顯示錯誤訊息
    await expect(page.getByRole('alert')).toHaveText(/網路連線失敗/);

    // 應該停留在結帳頁
    await expect(page).toHaveURL('/checkout');
  });
});

E2E 測試的最佳實踐

1. 使用測試專用的使用者帳號和資料

// 不要用真實使用者資料
const testUser = {
  email: 'e2e-test@example.com',
  password: 'test-password-123',
};

2. 獨立且可重複執行
每個測試都應該能獨立執行,不依賴其他測試的結果:

test.beforeEach(async ({ page }) => {
  // 重置測試環境
  await resetDatabase();
  await seedTestData();
});

3. 使用有意義的等待

// ❌ 不好:硬編碼等待時間
await page.waitForTimeout(3000);

// ✅ 好:等待特定元素或狀態
await page.waitForSelector('[data-testid="loading"]', { state: 'detached' });
await expect(page.getByText('載入完成')).toBeVisible();

4. 平行執行以提升速度

// playwright.config.ts
export default {
  workers: process.env.CI ? 2 : 4,
  fullyParallel: true,
};

🛠️ 建立完整的測試工作流

專案設定:整合 Vitest + Testing Library + Playwright

# 安裝單元測試和整合測試工具
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom

# 安裝 E2E 測試工具
npm install -D @playwright/test

# 初始化 Playwright
npx playwright install

Vitest 設定

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.test.ts',
        '**/*.test.tsx',
      ],
    },
  },
});
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';

// 擴展 expect 的匹配器
expect.extend(matchers);

// 每個測試後清理 DOM
afterEach(() => {
  cleanup();
});

Playwright 設定

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,

  reporter: [
    ['html'],
    ['list'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

package.json 腳本設定

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "npm run test:coverage && npm run test:e2e"
  }
}

🎯 測試策略實踐指南

測試覆蓋率的正確理解

很多團隊把測試覆蓋率當成 KPI,要求達到 80% 甚至 90%。但這個指標可能會誤導:

覆蓋率高 ≠ 測試品質好

// 這段程式碼有 100% 覆蓋率,但測試價值為零
function add(a, b) {
  return a + b;
}

test('測試 add 函式', () => {
  add(1, 2); // 沒有任何斷言!
});

更重要的指標:

  • 關鍵路徑覆蓋: 核心業務邏輯是否都有測試?
  • 邊界情況覆蓋: 極端情況是否都考慮到?
  • 回歸問題覆蓋: 每個 bug 修復後是否都有測試保護?

實務中的測試優先級

當時間有限時,應該優先測試什麼?

高優先級(必須測試):

  1. 支付、交易相關邏輯
  2. 使用者認證和授權
  3. 資料驗證和轉換
  4. 關鍵業務流程

中優先級(建議測試):

  1. 複雜的 UI 互動
  2. 表單驗證邏輯
  3. 狀態管理邏輯
  4. API 整合

低優先級(可選測試):

  1. 簡單的展示型組件
  2. 靜態配置
  3. 樣式相關邏輯

TDD(測試驅動開發)的實踐

TDD 不只是「先寫測試再寫程式碼」,它是一種設計方法論:

紅 → 綠 → 重構

1. 紅階段:寫一個失敗的測試

test('應該能計算折扣後的價格', () => {
  const result = calculateDiscount(100, 10);
  expect(result).toBe(90);
});
// 執行測試 → 失敗(函式還不存在)

2. 綠階段:寫最少的程式碼讓測試通過

function calculateDiscount(price: number, discount: number) {
  return price - discount;
}
// 執行測試 → 通過

3. 重構階段:改進程式碼品質

function calculateDiscount(price: number, discountPercent: number) {
  if (price < 0 || discountPercent < 0 || discountPercent > 100) {
    throw new Error('Invalid input');
  }
  return price * (1 - discountPercent / 100);
}
// 執行測試 → 仍然通過
// 加入新的邊界測試...

TDD 的好處:

  • 程式碼天生可測試(因為是為測試而設計)
  • 更清晰的 API 設計
  • 更高的信心進行重構
  • 測試即文件

📋 本日重點回顧

  1. 測試金字塔: 70% 單元測試、20% 整合測試、10% E2E 測試,這個比例確保測試套件快速且穩定。

  2. 單元測試: 專注測試純函式和業務邏輯,使用 AAA 模式,測試邊界條件和錯誤處理。每個測試應該獨立且只驗證一個行為。

  3. 整合測試: 使用 Testing Library 從使用者角度測試組件,Mock 外部依賴,測試真實的互動流程而非實作細節。

  4. E2E 測試: 只測試關鍵業務流程,確保測試可獨立執行,使用有意義的等待而非硬編碼延遲。

  5. 測試策略: 覆蓋率不是唯一指標,更重要的是關鍵路徑覆蓋和邊界情況處理。根據業務重要性安排測試優先級。

🎯 最佳實踐建議

  • 推薦做法: 每修復一個 bug,先寫一個重現該 bug 的測試,再修復程式碼
  • 推薦做法: 在 CI/CD 流程中強制執行測試,測試失敗則阻止部署
  • 推薦做法: 定期 review 和維護測試程式碼,刪除無效的測試
  • 推薦做法: 使用測試覆蓋率報告找出未測試的關鍵程式碼
  • 避免陷阱: 不要為了覆蓋率而寫無意義的測試
  • 避免陷阱: 不要測試框架或第三方套件的功能
  • 避免陷阱: 不要在測試中使用真實的外部服務(資料庫、API)
  • 避免陷阱: 不要讓測試之間互相依賴

🤔 延伸思考

  1. 測試與開發效率的平衡: 你的專案中,測試帶來的信心是否值得投入的時間成本?如何找到最佳平衡點?

  2. 遺留程式碼的測試策略: 面對一個完全沒有測試的舊專案,應該從哪裡開始補測試?是全面補齊還是只測新功能?

  3. 測試的 ROI: 如何衡量測試的投資報酬率?有哪些量化指標可以證明測試的價值?

  4. AI 時代的測試: 隨著 AI 輔助寫程式碼的普及,測試的角色會如何變化?我們是否需要調整測試策略?



上一篇
現代化表單處理:從原生 HTML 到智能表單的進化之路
系列文
前端工程師的 Modern Web 實踐之道15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言