测试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