跳至主要内容

前端測試

本指南說明如何在花店系統中測試前端程式碼。

測試工具

  • Vitest - 測試框架
  • React Testing Library - 組件測試
  • MSW - API Mock
  • @testing-library/user-event - 模擬使用者互動

組件測試

基本組件

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

describe('ProductCard', () => {
it('renders product information', () => {
const product = {
id: '1',
name: 'Rose Bouquet',
price: 1500,
stock: 10,
};

render(<ProductCard product={product} />);

expect(screen.getByText('Rose Bouquet')).toBeInTheDocument();
expect(screen.getByText('$1500')).toBeInTheDocument();
expect(screen.getByText('庫存: 10')).toBeInTheDocument();
});

it('shows out of stock message when stock is 0', () => {
const product = {
id: '1',
name: 'Rose Bouquet',
price: 1500,
stock: 0,
};

render(<ProductCard product={product} />);

expect(screen.getByText('缺貨')).toBeInTheDocument();
});
});

使用者互動

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './ProductCard';

describe('ProductCard', () => {
it('calls onAddToCart when button clicked', async () => {
const user = userEvent.setup();
const handleAddToCart = vi.fn();
const product = { id: '1', name: 'Rose', price: 1500 };

render(<ProductCard product={product} onAddToCart={handleAddToCart} />);

const button = screen.getByRole('button', { name: '加入購物車' });
await user.click(button);

expect(handleAddToCart).toHaveBeenCalledWith(product);
});
});

表單測試

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductForm } from './ProductForm';

describe('ProductForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();

render(<ProductForm onSubmit={handleSubmit} />);

await user.type(screen.getByLabelText('商品名稱'), 'Rose Bouquet');
await user.type(screen.getByLabelText('價格'), '1500');
await user.click(screen.getByRole('button', { name: '儲存' }));

expect(handleSubmit).toHaveBeenCalledWith({
name: 'Rose Bouquet',
price: 1500,
});
});

it('shows validation error for empty name', async () => {
const user = userEvent.setup();

render(<ProductForm />);

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

expect(screen.getByText('商品名稱為必填')).toBeInTheDocument();
});
});

Hook 測試

// hooks/useProduct.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useProduct } from './useProduct';
import { productService } from '@/services/product.service';

vi.mock('@/services/product.service');

describe('useProduct', () => {
it('loads product on mount', async () => {
const mockProduct = { id: '1', name: 'Rose', price: 1500 };
vi.mocked(productService.findById).mockResolvedValue(mockProduct);

const { result } = renderHook(() => useProduct('1'));

expect(result.current.loading).toBe(true);

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.product).toEqual(mockProduct);
});
});

API 整合測試

使用 MSW

// applets/products/ProductListApplet.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { ProductListApplet } from './ProductListApplet';

const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'Rose', price: 1500 },
{ id: '2', name: 'Tulip', price: 1200 },
]);
})
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('ProductListApplet', () => {
it('renders products from API', async () => {
render(<ProductListApplet />);

await waitFor(() => {
expect(screen.getByText('Rose')).toBeInTheDocument();
expect(screen.getByText('Tulip')).toBeInTheDocument();
});
});

it('handles API error', async () => {
server.use(
http.get('/api/products', () => {
return new HttpResponse(null, { status: 500 });
})
);

render(<ProductListApplet />);

await waitFor(() => {
expect(screen.getByText('載入失敗')).toBeInTheDocument();
});
});
});

快照測試

import { render } from '@testing-library/react';
import { ProductCard } from './ProductCard';

describe('ProductCard', () => {
it('matches snapshot', () => {
const product = { id: '1', name: 'Rose', price: 1500 };
const { container } = render(<ProductCard product={product} />);

expect(container).toMatchSnapshot();
});
});

測試最佳實踐

1. 測試使用者行為,而非實作細節

推薦

// 測試使用者看到什麼、做什麼
expect(screen.getByRole('button', { name: '加入購物車' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '加入購物車' }));

避免

// 測試實作細節
expect(wrapper.find('.add-to-cart-button')).toHaveLength(1);
wrapper.find('.add-to-cart-button').simulate('click');

2. 使用語意化查詢

優先順序:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId(最後選擇)

3. 避免測試第三方庫

推薦

// 測試你的程式碼
it('calls API with correct data', async () => {
await user.click(button);
expect(productService.create).toHaveBeenCalledWith(expectedData);
});

避免

// 測試 React Hook Form 的行為
it('validates form correctly', () => {
// React Hook Form 已經有自己的測試
});

執行測試

# 執行所有測試
npm run test

# 監視模式
npm run test:watch

# 涵蓋率報告
npm run test:coverage

# 執行單一檔案
npm run test -- ProductCard.test.tsx

下一步