测试Preact组件 #
一、测试环境配置 #
1.1 安装依赖 #
bash
npm install -D vitest @testing-library/preact @testing-library/jest-dom jsdom
1.2 Vitest 配置 #
typescript
import { defineConfig } from 'vitest/config';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
test: {
environment: 'jsdom',
setupFiles: ['./test/setup.ts'],
globals: true
}
});
1.3 测试设置文件 #
typescript
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/preact';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});
二、基本测试 #
2.1 渲染测试 #
tsx
import { render, screen } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import Button from './Button';
describe('Button', () => {
it('renders button with text', () => {
render(<Button text="Click me" />);
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('renders disabled button', () => {
render(<Button text="Click me" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
2.2 快照测试 #
tsx
import { render } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import Card from './Card';
describe('Card', () => {
it('matches snapshot', () => {
const { container } = render(
<Card title="Test Title">
<p>Card content</p>
</Card>
);
expect(container.firstChild).toMatchSnapshot();
});
});
三、交互测试 #
3.1 点击事件 #
tsx
import { render, screen, fireEvent } from '@testing-library/preact';
import { describe, it, expect, vi } from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('increments count on button click', async () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
await fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await fireEvent.click(button);
expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
it('calls onClick handler', async () => {
const handleClick = vi.fn();
render(<Button text="Click me" onClick={handleClick} />);
await fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
3.2 表单输入 #
tsx
import { render, screen, fireEvent } from '@testing-library/preact';
import { describe, it, expect, vi } from 'vitest';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('updates input value on change', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText(/email/i);
await fireEvent.input(emailInput, { target: { value: 'test@example.com' } });
expect(emailInput).toHaveValue('test@example.com');
});
it('submits form with correct values', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await fireEvent.input(screen.getByLabelText(/email/i), {
target: { value: 'test@example.com' }
});
await fireEvent.input(screen.getByLabelText(/password/i), {
target: { value: 'password123' }
});
await fireEvent.submit(screen.getByRole('form'));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
3.3 键盘事件 #
tsx
import { render, screen, fireEvent } from '@testing-library/preact';
import { describe, it, expect, vi } from 'vitest';
import SearchInput from './SearchInput';
describe('SearchInput', () => {
it('submits on Enter key', async () => {
const handleSearch = vi.fn();
render(<SearchInput onSearch={handleSearch} />);
const input = screen.getByPlaceholderText(/search/i);
await fireEvent.input(input, { target: { value: 'test query' } });
await fireEvent.keyDown(input, { key: 'Enter' });
expect(handleSearch).toHaveBeenCalledWith('test query');
});
it('clears on Escape key', async () => {
render(<SearchInput />);
const input = screen.getByPlaceholderText(/search/i);
await fireEvent.input(input, { target: { value: 'test' } });
await fireEvent.keyDown(input, { key: 'Escape' });
expect(input).toHaveValue('');
});
});
四、异步测试 #
4.1 等待元素 #
tsx
import { render, screen, waitFor } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import UserProfile from './UserProfile';
describe('UserProfile', () => {
it('displays user data after loading', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
4.2 模拟 API #
tsx
import { render, screen, waitFor } from '@testing-library/preact';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import UserProfile from './UserProfile';
describe('UserProfile', () => {
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'John Doe' })
} as Response)
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fetches and displays user data', async () => {
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
});
});
五、Hooks 测试 #
5.1 测试自定义 Hook #
tsx
import { renderHook, act } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
5.2 测试异步 Hook #
tsx
import { renderHook, waitFor } from '@testing-library/preact';
import { describe, it, expect, vi } from 'vitest';
import { useFetch } from './useFetch';
describe('useFetch', () => {
it('fetches data successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: 'Test' })
} as Response)
);
const { result } = renderHook(() => useFetch('/api/test'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'Test' });
expect(result.current.error).toBeNull();
});
});
六、Context 测试 #
6.1 测试 Provider #
tsx
import { render, screen, fireEvent } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import { ThemeProvider, useTheme } from './ThemeContext';
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
describe('ThemeProvider', () => {
it('provides theme context', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
it('toggles theme', async () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
await fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
});
七、路由测试 #
7.1 测试路由组件 #
tsx
import { render, screen } from '@testing-library/preact';
import { describe, it, expect } from 'vitest';
import { Router } from 'preact-router';
import App from './App';
function renderWithRoute(route: string) {
window.history.pushState({}, '', route);
return render(
<Router>
<App />
</Router>
);
}
describe('App routing', () => {
it('renders home page at /', () => {
renderWithRoute('/');
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});
it('renders about page at /about', () => {
renderWithRoute('/about');
expect(screen.getByText(/about us/i)).toBeInTheDocument();
});
});
八、最佳实践 #
8.1 查询优先级 #
tsx
// 1. getByRole - 最推荐
screen.getByRole('button', { name: /submit/i });
// 2. getByLabelText
screen.getByLabelText(/email/i);
// 3. getByPlaceholderText
screen.getByPlaceholderText(/search/i);
// 4. getByText
screen.getByText(/hello/i);
// 5. getByTestId - 最后选择
screen.getByTestId('submit-button');
8.2 测试用户行为 #
tsx
// ✅ 测试用户行为
await fireEvent.click(screen.getByRole('button'));
await fireEvent.input(screen.getByLabelText(/email/i), {
target: { value: 'test@example.com' }
});
// ❌ 测试实现细节
expect(component.state.count).toBe(1);
8.3 测试覆盖率 #
json
{
"scripts": {
"test:coverage": "vitest run --coverage"
}
}
九、总结 #
| 要点 | 说明 |
|---|---|
| render | 渲染组件 |
| screen | 查询元素 |
| fireEvent | 触发事件 |
| waitFor | 等待异步 |
| renderHook | 测试 Hook |
核心原则:
- 测试用户可见行为
- 使用语义化查询
- 模拟外部依赖
- 保持测试简单
最后更新:2026-03-28