react-testing-library

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React Testing Library Skill

React Testing Library Skill

Overview

概述

Master React Testing Library for writing maintainable tests that focus on user behavior rather than implementation details.
掌握React Testing Library,编写专注于用户行为而非实现细节的可维护测试。

Learning Objectives

学习目标

  • Write component tests with RTL
  • Test user interactions
  • Handle async operations
  • Test hooks and context
  • Follow testing best practices
  • 使用RTL编写组件测试
  • 测试用户交互
  • 处理异步操作
  • 测试Hooks和Context
  • 遵循测试最佳实践

Quick Start

快速开始

Installation

安装

bash
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
bash
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

Setup

配置

javascript
// setupTests.js
import '@testing-library/jest-dom';
javascript
// setupTests.js
import '@testing-library/jest-dom';

Basic Component Testing

基础组件测试

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

describe('Button', () => {
  it('renders button text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});
jsx
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button', () => {
  it('renders button text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Queries

查询方式

Priority Order

查询优先级

jsx
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');

// 2. Semantic queries
screen.getByAltText('Profile picture');
screen.getByTitle('Close');

// 3. Test IDs (last resort)
screen.getByTestId('custom-element');
jsx
// 1. Accessible queries (preferred)
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');

// 2. Semantic queries
screen.getByAltText('Profile picture');
screen.getByTitle('Close');

// 3. Test IDs (last resort)
screen.getByTestId('custom-element');

Query Variants

查询变体

jsx
// getBy - throws error if not found
screen.getByText('Hello');

// queryBy - returns null if not found
screen.queryByText('Hello'); // Use for asserting non-existence

// findBy - async, waits for element
await screen.findByText('Hello'); // Use for async content
jsx
// getBy - throws error if not found
screen.getByText('Hello');

// queryBy - returns null if not found
screen.queryByText('Hello'); // Use for asserting non-existence

// findBy - async, waits for element
await screen.findByText('Hello'); // Use for async content

Testing Forms

表单测试

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

describe('LoginForm', () => {
  it('submits form with correct data', async () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    await userEvent.click(screen.getByRole('button', { name: /login/i }));

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

  it('shows validation errors', async () => {
    render(<LoginForm onSubmit={jest.fn()} />);

    await userEvent.click(screen.getByRole('button', { name: /login/i }));

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
  });
});
jsx
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  it('submits form with correct data', async () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    await userEvent.click(screen.getByRole('button', { name: /login/i }));

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

  it('shows validation errors', async () => {
    render(<LoginForm onSubmit={jest.fn()} />);

    await userEvent.click(screen.getByRole('button', { name: /login/i }));

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
  });
});

Testing Async Operations

异步操作测试

jsx
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';

const server = setupServer(
  rest.get('/api/users/:userId', (req, res, ctx) => {
    return res(ctx.json({ id: 1, name: 'John Doe' }));
  })
);

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

describe('UserProfile', () => {
  it('loads and displays user data', async () => {
    render(<UserProfile userId={1} />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    expect(await screen.findByText('John Doe')).toBeInTheDocument();
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  it('handles API errors', async () => {
    server.use(
      rest.get('/api/users/:userId', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    render(<UserProfile userId={1} />);

    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});
jsx
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';

const server = setupServer(
  rest.get('/api/users/:userId', (req, res, ctx) => {
    return res(ctx.json({ id: 1, name: 'John Doe' }));
  })
);

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

describe('UserProfile', () => {
  it('loads and displays user data', async () => {
    render(<UserProfile userId={1} />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    expect(await screen.findByText('John Doe')).toBeInTheDocument();
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  it('handles API errors', async () => {
    server.use(
      rest.get('/api/users/:userId', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    render(<UserProfile userId={1} />);

    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
});

Testing Hooks

Hooks测试

jsx
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  it('increments counter', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });
});
jsx
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  it('increments counter', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });
});

Testing Context

Context测试

jsx
// ThemeToggle.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import ThemeToggle from './ThemeToggle';

describe('ThemeToggle', () => {
  it('toggles theme', async () => {
    render(
      <ThemeProvider>
        <ThemeToggle />
      </ThemeProvider>
    );

    expect(screen.getByText(/current theme: light/i)).toBeInTheDocument();

    await userEvent.click(screen.getByRole('button'));

    expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument();
  });
});
jsx
// ThemeToggle.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import ThemeToggle from './ThemeToggle';

describe('ThemeToggle', () => {
  it('toggles theme', async () => {
    render(
      <ThemeProvider>
        <ThemeToggle />
      </ThemeProvider>
    );

    expect(screen.getByText(/current theme: light/i)).toBeInTheDocument();

    await userEvent.click(screen.getByRole('button'));

    expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument();
  });
});

Custom Render Utility

自定义渲染工具

jsx
// test-utils.jsx
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';

function AllProviders({ children }) {
  return (
    <BrowserRouter>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </BrowserRouter>
  );
}

function customRender(ui, options) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };
jsx
// test-utils.jsx
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';

function AllProviders({ children }) {
  return (
    <BrowserRouter>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </BrowserRouter>
  );
}

function customRender(ui, options) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };

Best Practices

最佳实践

  1. Test user behavior, not implementation
  2. Use accessible queries (role, label)
  3. Avoid testing internal state
  4. Use userEvent for realistic interactions
  5. Mock external dependencies (API calls)
  6. Test error states and edge cases
  7. Keep tests simple and focused
  1. 测试用户行为,而非实现细节
  2. 使用可访问的查询方式(role、label)
  3. 避免测试内部状态
  4. 使用userEvent模拟真实用户交互
  5. Mock外部依赖(如API调用)
  6. 测试错误状态和边缘情况
  7. 保持测试简洁且聚焦

Common Patterns

常见模式

jsx
// Wait for element to disappear
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

// Wait for multiple elements
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3);
});

// Debugging
screen.debug(); // Print DOM
screen.logTestingPlaygroundURL(); // Get Testing Playground URL
jsx
// Wait for element to disappear
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

// Wait for multiple elements
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3);
});

// Debugging
screen.debug(); // Print DOM
screen.logTestingPlaygroundURL(); // Get Testing Playground URL

Practice Exercises

练习任务

  1. Test form validation
  2. Test async data fetching
  3. Test user authentication flow
  4. Test routing navigation
  5. Test modal interactions
  6. Test list filtering
  7. Test error boundaries
  1. 测试表单验证
  2. 测试异步数据获取
  3. 测试用户认证流程
  4. 测试路由导航
  5. 测试模态框交互
  6. 测试列表过滤
  7. 测试错误边界

Resources

参考资源

Flaky Test Prevention

不稳定测试预防

jsx
// Configure Jest retry for CI
// jest.config.js
module.exports = {
  testRetry: process.env.CI ? 2 : 0,
  testTimeout: 10000,
};

// Robust async pattern
it('handles async operations reliably', async () => {
  render(<AsyncComponent />);

  // Use findBy for async content
  expect(await screen.findByText(/loaded/i, {}, { timeout: 5000 }))
    .toBeInTheDocument();

  // Ensure loading is gone
  await waitFor(() => {
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });
});
jsx
// Configure Jest retry for CI
// jest.config.js
module.exports = {
  testRetry: process.env.CI ? 2 : 0,
  testTimeout: 10000,
};

// Robust async pattern
it('handles async operations reliably', async () => {
  render(<AsyncComponent />);

  // Use findBy for async content
  expect(await screen.findByText(/loaded/i, {}, { timeout: 5000 }))
    .toBeInTheDocument();

  // Ensure loading is gone
  await waitFor(() => {
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });
});

MSW 2.0 Setup

MSW 2.0 配置

jsx
// mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'John' }]);
  }),

  http.post('/api/users', async ({ request }) => {
    const user = await request.json();
    return HttpResponse.json({ ...user, id: Date.now() }, { status: 201 });
  }),

  // Error simulation
  http.get('/api/error', () => {
    return HttpResponse.json({ message: 'Server Error' }, { status: 500 });
  }),
];
jsx
// mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'John' }]);
  }),

  http.post('/api/users', async ({ request }) => {
    const user = await request.json();
    return HttpResponse.json({ ...user, id: Date.now() }, { status: 201 });
  }),

  // Error simulation
  http.get('/api/error', () => {
    return HttpResponse.json({ message: 'Server Error' }, { status: 500 });
  }),
];

Coverage Configuration

测试覆盖率配置

javascript
// jest.config.js
module.exports = {
  collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

Version: 2.0.0 Last Updated: 2025-12-30 SASMP Version: 2.0.0 Difficulty: Intermediate Estimated Time: 2-3 weeks Prerequisites: React Fundamentals, Jest Basics Changelog: Added MSW 2.0, flaky test prevention, and coverage config
javascript
// jest.config.js
module.exports = {
  collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

版本: 2.0.0 最后更新: 2025-12-30 SASMP版本: 2.0.0 难度: 中级 预计学习时间: 2-3周 前置要求: React基础, Jest基础 更新日志: 新增MSW 2.0、不稳定测试预防和覆盖率配置