技術 6 分鐘閱讀

React 測試入門:用測試保護你的程式碼

React 測試入門:用測試保護你的程式碼

前言

老實說,接案工程師寫測試的比例並不高。時間壓力大、覺得手動測就好——我以前也是這樣想。直到有一次在線上環境改了一個小功能,結果意外破壞了另一個功能,才深刻體會到測試的重要性。

為什麼要寫測試?

  • 防止回歸錯誤:修改程式碼時不怕弄壞其他功能
  • 文件化:測試本身就是最好的使用範例
  • 重構信心:有測試保護,重構才敢放手做
  • 提升程式碼品質:可測試的程式碼通常設計更好

工具選擇

推薦組合

  • Vitest:取代 Jest,與 Vite 完美整合,速度更快
  • React Testing Library:以使用者角度測試元件
  • MSW(Mock Service Worker):模擬 API 請求

安裝

npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event jsdom

設定

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';

第一個測試

簡單的元件測試

// components/Greeting.tsx
interface GreetingProps {
  name: string;
}

export function Greeting({ name }: GreetingProps) {
  return <h1>你好,{name}!</h1>;
}
// components/Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting', () => {
  it('顯示使用者名稱', () => {
    render(<Greeting name="小明" />);
    expect(screen.getByText('你好,小明!')).toBeInTheDocument();
  });
});

執行測試:

npx vitest

測試使用者互動

// components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>目前計數:{count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加</button>
      <button onClick={() => setCount(c => c - 1)}>減少</button>
      <button onClick={() => setCount(0)}>重設</button>
    </div>
  );
}
// components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('初始計數為 0', () => {
    render(<Counter />);
    expect(screen.getByText('目前計數:0')).toBeInTheDocument();
  });

  it('點擊增加按鈕後計數加 1', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('增加'));
    expect(screen.getByText('目前計數:1')).toBeInTheDocument();
  });

  it('點擊減少按鈕後計數減 1', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('減少'));
    expect(screen.getByText('目前計數:-1')).toBeInTheDocument();
  });

  it('重設按鈕將計數歸零', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('增加'));
    await user.click(screen.getByText('增加'));
    await user.click(screen.getByText('重設'));

    expect(screen.getByText('目前計數:0')).toBeInTheDocument();
  });
});

測試表單

// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  const mockOnSubmit = vi.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  it('送出表單時呼叫 onSubmit', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);

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

    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });

  it('Email 為空時顯示錯誤訊息', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.click(screen.getByRole('button', { name: '登入' }));

    expect(screen.getByText('Email 為必填')).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
});

測試 API 呼叫

使用 MSW 來模擬 API:

npm install -D msw
// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/products', () => {
    return HttpResponse.json([
      { id: 1, name: '商品 A', price: 100 },
      { id: 2, name: '商品 B', price: 200 },
    ]);
  }),
];
// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/test/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// components/ProductList.test.tsx
import { render, screen } from '@testing-library/react';
import { ProductList } from './ProductList';

describe('ProductList', () => {
  it('顯示商品列表', async () => {
    render(<ProductList />);

    expect(await screen.findByText('商品 A')).toBeInTheDocument();
    expect(screen.getByText('商品 B')).toBeInTheDocument();
  });
});

測試策略

該測什麼?

  • 使用者行為:點擊按鈕、填寫表單、看到結果
  • 條件渲染:不同狀態顯示不同內容
  • 錯誤處理:API 失敗時的顯示

不該測什麼?

  • 實作細節:state 的值、元件內部的函式
  • 第三方套件:相信它們有自己的測試
  • 純 CSS 樣式:除非影響功能

測試金字塔

  • 單元測試(多):測試個別元件和函式
  • 整合測試(適中):測試元件之間的互動
  • E2E 測試(少):測試完整的使用者流程

結語

寫測試一開始會覺得花時間,但長期來看絕對值得。特別是接案專案需要長期維護時,測試能讓你安心地修改和更新程式碼。從最重要的功能開始寫測試,慢慢建立起測試的習慣和信心。

#React #測試 #Vitest #Testing Library
Thousand Lab

Thousand Lab