Component TestingVitestReact Testing LibraryVue Testing Library前端測試前端教學

Component Testing 教學:Vitest 元件測試完全指南

如何測試 React/Vue 元件?Vitest 元件測試教學含 RTL、Vue Testing Library、行為測試而非實作。打造可靠的 UI 測試策略。

· 6 分鐘閱讀

單元測試告訴你函式輸出對不對,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),寫第一個元件測試。從簡單的按鈕行為開始,建立信心後再逐步覆蓋更複雜的邏輯。


延伸閱讀