📋 目錄
單元測試告訴你函式輸出對不對,E2E 測試告訴你整個頁面走得通不通——但兩者之間,有一個甜蜜點:元件測試。元件測試專注測試一個 UI 元件的行為,不需要啟動整個瀏覽器,卻能捕捉多數的 UI 邏輯錯誤。
前言:為什麼需要元件測試?
元件測試填補了單元測試和 E2E 測試之間的空白:
| 測試類型 | 測試範圍 | 執行速度 | 捕捉的問題 |
|---|---|---|---|
| 單元測試 | 函式邏輯 | 快 | 函式錯誤、商業邏輯錯誤 |
| 元件測試 | UI 元件行為 | 中 | Props 處理、互動邏輯、狀態變化 |
| E2E 測試 | 整個頁面 | 慢 | 頁面流程錯誤、跨系統整合 |
元件測試的價值在於:用合理的速度,捕捉多數 UI 相關的錯誤。壞掉的 Button、壞掉的 Modal、條件渲染邏輯錯誤——這些 E2E 測試能捕捉,但跑得慢;單元測試無法捕捉因為它們不是函式邏輯。元件測試正好在中間。
測試工具棧
Vitest + React Testing Library
React Testing Library(RTL)的核心理念:測試元件的行為,而不是測試元件的實作。
這句話的意思是:不要測試「setState 被調用了幾次」,而是測試「使用者看到什麼、使用者做了什麼,結果是什麼」。
npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom
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/setupTests.ts'],
},
});
// src/setupTests.ts
import '@testing-library/jest-dom';
// 這個讓 Vitest 認得 DOM matchers(如 toBeInTheDocument())
第一個元件測試
待測試元件
// src/components/Counter.tsx
import React, { useState } from 'react';
interface CounterProps {
initialCount?: number;
label?: string;
}
export function Counter({ initialCount = 0, label = '計數' }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialCount);
return (
<div>
<span data-testid="count-display">{label}: {count}</span>
<button onClick={decrement} aria-label="減少">-</button>
<button onClick={increment} aria-label="增加">+</button>
<button onClick={reset} aria-label="重設">Reset</button>
</div>
);
}
測試檔案
// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';
describe('Counter 元件', () => {
it('應該顯示初始計數', () => {
render(<Counter initialCount={5} label="計數" />);
expect(screen.getByTestId('count-display')).toHaveTextContent('計數: 5');
});
it('點擊增加按鈕應該遞增計數', () => {
render(<Counter initialCount={0} />);
const incrementButton = screen.getByRole('button', { name: '增加' });
fireEvent.click(incrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('計數: 1');
});
it('點擊減少按鈕應該遞減計數', () => {
render(<Counter initialCount={3} />);
const decrementButton = screen.getByRole('button', { name: '減少' });
fireEvent.click(decrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('計數: 2');
});
it('點擊重設按鈕應該回到初始值', () => {
render(<Counter initialCount={10} />);
// 先加幾次
fireEvent.click(screen.getByRole('button', { name: '增加' }));
fireEvent.click(screen.getByRole('button', { name: '增加' }));
expect(screen.getByTestId('count-display')).toHaveTextContent('計數: 12');
// 再重設
fireEvent.click(screen.getByRole('button', { name: '重設' }));
expect(screen.getByTestId('count-display')).toHaveTextContent('計數: 10');
});
});
測試表單元件
元件測試最常見的場景之一:表單處理。
// src/components/LoginForm.tsx
import React, { useState } from 'react';
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.includes('@')) {
setError('請輸入有效的 Email');
return;
}
setError('');
setIsLoading(true);
try {
await onSubmit(email, password);
} catch {
setError('登入失敗,請稍後再試');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
aria-label="Email"
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="密碼"
aria-label="密碼"
/>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? '登入中...' : '登入'}
</button>
</form>
);
}
// src/components/LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';
describe('LoginForm 元件', () => {
it('應該正確 render 表單', () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('密碼')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登入' })).toBeInTheDocument();
});
it('應該在 email 格式錯誤時顯示錯誤', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'invalid-email' },
});
fireEvent.change(screen.getByPlaceholderText('密碼'), {
target: { value: 'password123' },
});
fireEvent.click(screen.getByRole('button', { name: '登入' }));
expect(screen.getByRole('alert')).toHaveTextContent('請輸入有效的 Email');
});
it('應該在提交時呼叫 onSubmit 並處理成功', async () => {
const mockSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'xiaoming@example.com' },
});
fireEvent.change(screen.getByPlaceholderText('密碼'), {
target: { value: 'password123' },
});
fireEvent.click(screen.getByRole('button', { name: '登入' }));
expect(mockSubmit).toHaveBeenCalledWith('xiaoming@example.com', 'password123');
});
it('應該在提交時呼叫 onSubmit 並處理失敗', async () => {
const mockSubmit = vi.fn().mockRejectedValue(new Error('Wrong credentials'));
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'xiaoming@example.com' },
});
fireEvent.change(screen.getByPlaceholderText('密碼'), {
target: { value: 'wrongpassword' },
});
fireEvent.click(screen.getByRole('button', { name: '登入' }));
// 等待 async 處理完成
expect(await screen.findByRole('alert')).toHaveTextContent('登入失敗,請稍後再試');
});
});
Vue 的 Testing Library
Vue 的 Testing Library 叫 @testing-library/vue,API 設計理念與 React 版本完全一致:
// src/components/Counter.spec.ts
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import { Counter } from './Counter.vue';
describe('Counter', () => {
it('應該正確遞增', async () => {
render(Counter, {
props: { initialCount: 0 },
});
await fireEvent.click(screen.getByText('+'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
});
常見問題
Q:元件測試和 E2E 測試哪個更重要?
A:兩個都重要,但優先從元件測試開始。元件測試執行快速(比 E2E 快 10-100 倍),可以作為 TDD 的基礎。E2E 測試不需要多,測試核心 user journey 就好。兩者不是替代關係,是互補關係。
Q:元件測試要測試多少才夠?
A:元件測試的目標不是追求覆蓋率,而是測試有意義的行為。重點測試:
- 使用者看得見的輸出(render 了什麼)
- 使用者能操作的互動(點擊、表單輸入)
- Props 變化時的行為改變
Q:可以用 Vitest + Testing Library 測試 React 嗎?
A:可以,而且這是 2026 年的主流組合。Vitest 比 Jest 快一個數量級,加上 Testing Library 的「測試行為而非實作」理念,是目前最推薦的 React 元件測試方案。
Q:Snapshot 測試和 Component Testing 有什麼不同?
A:Snapshot 測試專門測試「元件的 DOM 結構」,適用於不希望樣式或結構意外改變的場景。Component Testing 測試的是「元件的互動邏輯」。兩者用途不同,通常會一起使用。
總結:元件測試是 UI 品質的守門員
元件測試的核心價值:用合理的速度,捕捉多數 UI 相關的錯誤。
這篇文章涵蓋:
- 元件測試在測試金字塔中的位置
- Vitest + React Testing Library 的設定
- 第一個 Counter 元件的完整測試
- 表單元件的互動測試(成功/失敗/驗證)
- Vue Testing Library 的對應 API
下一步:在你的專案選一個有互動邏輯的元件(Toggle、Modal、Form),寫第一個元件測試。從簡單的按鈕行為開始,建立信心後再逐步覆蓋更複雜的邏輯。