Testing Library 最佳实践 #

核心原则 #

Testing Library 的核心原则是"测试越接近用户使用软件的方式,测试就越可靠":

text
┌─────────────────────────────────────────────────────────────┐
│                     测试核心原则                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 用户视角                                                │
│     ├── 测试用户看到的内容                                   │
│     ├── 测试用户交互的行为                                   │
│     └── 避免测试实现细节                                     │
│                                                             │
│  2. 可访问性优先                                            │
│     ├── 使用语义化查询                                       │
│     ├── 关注无障碍性                                        │
│     └── 提高代码可访问性                                     │
│                                                             │
│  3. 可维护性                                                │
│     ├── 测试应该稳定                                        │
│     ├── 重构不应破坏测试                                     │
│     └── 测试代码要清晰                                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

查询优先级 #

推荐的查询顺序 #

jsx
// ✅ 1. getByRole - 最推荐
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });

// ✅ 2. getByLabelText - 表单元素
screen.getByLabelText(/email/i);
screen.getByLabelText(/password/i);

// ✅ 3. getByPlaceholderText - 输入框
screen.getByPlaceholderText('Search...');

// ✅ 4. getByText - 文本内容
screen.getByText('Hello World');

// ✅ 5. getByDisplayValue - 当前值
screen.getByDisplayValue('Selected option');

// ⚠️ 6. getByAltText - 图片
screen.getByAltText('Product image');

// ⚠️ 7. getByTitle - 标题属性
screen.getByTitle('Close');

// ❌ 8. getByTestId - 最后手段
screen.getByTestId('submit-button');

查询选择指南 #

元素类型 推荐查询
按钮 getByRole('button', { name })
链接 getByRole('link', { name })
输入框 getByLabelText()
标题 getByRole('heading', { name })
图片 getByRole('img', { name })getByAltText()
列表项 getByRole('listitem')
对话框 getByRole('dialog')
表单 getByRole('form')

测试命名规范 #

好的测试命名 #

jsx
// ✅ 描述用户行为和预期结果
test('should display welcome message when user logs in', () => {});

test('should show error when password is incorrect', () => {});

test('should add item to cart when add button is clicked', () => {});

test('should disable submit button when form is invalid', () => {});

// ✅ 使用中文描述(团队约定)
test('用户登录成功后应该显示欢迎消息', () => {});

test('密码错误时应该显示错误提示', () => {});

不好的测试命名 #

jsx
// ❌ 太模糊
test('works', () => {});
test('test1', () => {});

// ❌ 描述技术细节
test('calls setState', () => {});
test('renders component', () => {});

// ❌ 太长
test('should display the welcome message when the user successfully logs in to the application', () => {});

测试组织 #

使用 describe 分组 #

jsx
describe('LoginForm', () => {
  describe('rendering', () => {
    test('should render email input', () => {});
    test('should render password input', () => {});
    test('should render submit button', () => {});
  });
  
  describe('validation', () => {
    test('should show error for invalid email', () => {});
    test('should show error for short password', () => {});
    test('should show errors for empty fields', () => {});
  });
  
  describe('submission', () => {
    test('should submit with valid credentials', () => {});
    test('should show error on failed login', () => {});
  });
});

AAA 模式 #

jsx
test('should add item to cart', async () => {
  // Arrange - 准备
  const user = userEvent.setup();
  const product = { id: 1, name: 'Product', price: 100 };
  
  render(<ProductCard product={product} />);
  
  // Act - 执行
  await user.click(screen.getByRole('button', { name: /add to cart/i }));
  
  // Assert - 断言
  expect(screen.getByText('Added to cart')).toBeInTheDocument();
});

测试文件结构 #

text
src/
├── components/
│   ├── Button/
│   │   ├── Button.jsx
│   │   ├── Button.test.jsx
│   │   └── Button.styles.js
│   └── Form/
│       ├── Form.jsx
│       └── Form.test.jsx
├── hooks/
│   ├── useCounter.js
│   └── useCounter.test.js
└── utils/
    ├── test-utils.jsx    # 自定义渲染器
    └── helpers.js

避免测试实现细节 #

不好的做法 #

jsx
// ❌ 测试组件状态
test('counter state', () => {
  const wrapper = shallow(<Counter />);
  expect(wrapper.state('count')).toBe(0);
  wrapper.instance().increment();
  expect(wrapper.state('count')).toBe(1);
});

// ❌ 测试组件方法
test('calls handleClick', () => {
  const wrapper = shallow(<Button />);
  const instance = wrapper.instance();
  jest.spyOn(instance, 'handleClick');
  wrapper.find('button').simulate('click');
  expect(instance.handleClick).toHaveBeenCalled();
});

// ❌ 测试 DOM 结构
test('has correct structure', () => {
  const wrapper = mount(<Card />);
  expect(wrapper.find('.card-header').length).toBe(1);
  expect(wrapper.find('.card-body').length).toBe(1);
});

好的做法 #

jsx
// ✅ 测试用户看到的内容
test('counter displays correct count', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  await user.click(screen.getByRole('button', { name: /increment/i }));
  
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

// ✅ 测试用户交互结果
test('button click triggers action', async () => {
  const onClick = jest.fn();
  const user = userEvent.setup();
  
  render(<Button onClick={onClick}>Click me</Button>);
  
  await user.click(screen.getByRole('button', { name: /click me/i }));
  
  expect(onClick).toHaveBeenCalled();
});

// ✅ 测试可见内容
test('card displays content', () => {
  render(<Card title="Title" content="Content" />);
  
  expect(screen.getByRole('heading', { name: /title/i })).toBeInTheDocument();
  expect(screen.getByText('Content')).toBeInTheDocument();
});

用户交互最佳实践 #

使用 user-event #

jsx
// ✅ 推荐:使用 user-event
import userEvent from '@testing-library/user-event';

test('form submission', async () => {
  const user = userEvent.setup();
  
  render(<LoginForm />);
  
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  expect(screen.getByText('Welcome')).toBeInTheDocument();
});

// ❌ 不推荐:使用 fireEvent
import { fireEvent } from '@testing-library/react';

test('form submission', () => {
  render(<LoginForm />);
  
  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: 'test@example.com' },
  });
  fireEvent.click(screen.getByRole('button', { name: /login/i }));
});

正确处理异步 #

jsx
// ✅ 使用 findBy 等待元素
test('loads data', async () => {
  render(<DataComponent />);
  
  const data = await screen.findByText('Loaded data');
  expect(data).toBeInTheDocument();
});

// ✅ 使用 waitFor 复杂条件
test('multiple async updates', async () => {
  render(<AsyncComponent />);
  
  await waitFor(() => {
    expect(screen.getByText('Step 1')).toBeInTheDocument();
    expect(screen.getByText('Step 2')).toBeInTheDocument();
  });
});

// ❌ 不推荐:使用固定等待
test('loads data', async () => {
  render(<DataComponent />);
  
  await new Promise(resolve => setTimeout(resolve, 1000));
  
  expect(screen.getByText('Loaded data')).toBeInTheDocument();
});

测试隔离 #

每个测试独立 #

jsx
// ✅ 好的做法:每个测试独立
describe('Counter', () => {
  beforeEach(() => {
    render(<Counter />);
  });
  
  test('starts at 0', () => {
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
  
  test('increments correctly', async () => {
    const user = userEvent.setup();
    await user.click(screen.getByRole('button', { name: '+' }));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

// ❌ 不好的做法:测试之间有依赖
describe('Counter', () => {
  let count = 0;
  
  test('starts at 0', () => {
    expect(count).toBe(0);
  });
  
  test('increments', () => {
    count++; // 依赖上一个测试
    expect(count).toBe(1);
  });
});

清理副作用 #

jsx
// ✅ 清理定时器
afterEach(() => {
  jest.useRealTimers();
  jest.clearAllMocks();
});

// ✅ 清理 DOM
afterEach(() => {
  cleanup();
});

// ✅ 重置模块
beforeEach(() => {
  jest.resetModules();
});

Mock 最佳实践 #

合理使用 Mock #

jsx
// ✅ Mock 外部依赖
jest.mock('./api', () => ({
  fetchUser: jest.fn(() => Promise.resolve({ name: 'John' })),
}));

// ✅ Mock 浏览器 API
beforeAll(() => {
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn().mockImplementation(query => ({
      matches: false,
      media: query,
      addEventListener: jest.fn(),
      removeEventListener: jest.fn(),
    })),
  });
});

// ❌ 过度 Mock
jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useState: jest.fn(), // 不要 Mock React
}));

Mock 清理 #

jsx
// ✅ 清理 Mock
afterEach(() => {
  jest.clearAllMocks();
});

// ✅ 重置 Mock
beforeEach(() => {
  fetchUser.mockReset();
});

// ✅ 恢复原始实现
afterAll(() => {
  jest.restoreAllMocks();
});

测试覆盖边界情况 #

测试正常和异常情况 #

jsx
describe('divide function', () => {
  test('divides positive numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });
  
  test('divides negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5);
  });
  
  test('handles zero dividend', () => {
    expect(divide(0, 5)).toBe(0);
  });
  
  test('throws error for division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

测试边界值 #

jsx
describe('age validation', () => {
  test('accepts minimum age', () => {
    expect(isValidAge(18)).toBe(true);
  });
  
  test('rejects under minimum age', () => {
    expect(isValidAge(17)).toBe(false);
  });
  
  test('accepts maximum age', () => {
    expect(isValidAge(120)).toBe(true);
  });
  
  test('rejects over maximum age', () => {
    expect(isValidAge(121)).toBe(false);
  });
  
  test('rejects negative age', () => {
    expect(isValidAge(-1)).toBe(false);
  });
});

性能优化 #

避免不必要的渲染 #

jsx
// ✅ 使用 rerender 测试 props 变化
test('updates on prop change', () => {
  const { rerender } = render(<Counter initialCount={0} />);
  
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  rerender(<Counter initialCount={10} />);
  
  expect(screen.getByText('Count: 10')).toBeInTheDocument();
});

并行测试 #

jsx
// ✅ 测试应该可以并行运行
describe('Component A', () => {
  test('test 1', () => {});
  test('test 2', () => {});
});

describe('Component B', () => {
  test('test 1', () => {});
  test('test 2', () => {});
});

常见陷阱 #

陷阱 1:使用 container.querySelector #

jsx
// ❌ 避免
const element = container.querySelector('.my-class');

// ✅ 推荐
const element = screen.getByRole('button');

陷阱 2:忘记 await #

jsx
// ❌ 忘记 await
test('async test', () => {
  render(<AsyncComponent />);
  screen.findByText('Loaded'); // 缺少 await
});

// ✅ 正确使用
test('async test', async () => {
  render(<AsyncComponent />);
  await screen.findByText('Loaded');
});

陷阱 3:使用不稳定的查询 #

jsx
// ❌ 使用索引
const button = screen.getAllByRole('button')[0];

// ✅ 使用具体特征
const button = screen.getByRole('button', { name: /submit/i });

陷阱 4:测试外部库的行为 #

jsx
// ❌ 测试第三方库
test('datepicker opens', () => {
  render(<DatePicker />);
  // 测试 datepicker 库的行为
});

// ✅ 测试你的代码
test('form submits selected date', async () => {
  const onSubmit = jest.fn();
  render(<DateForm onSubmit={onSubmit} />);
  
  // 选择日期...
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  expect(onSubmit).toHaveBeenCalledWith(expect.any(Date));
});

测试工具函数 #

创建自定义渲染器 #

jsx
const AllProviders = ({ children }) => {
  return (
    <ThemeProvider>
      <AuthProvider>
        <Router>
          {children}
        </Router>
      </AuthProvider>
    </ThemeProvider>
  );
};

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

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

创建测试数据生成器 #

jsx
function createMockUser(overrides = {}) {
  return {
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
    ...overrides,
  };
}

function createMockProduct(overrides = {}) {
  return {
    id: 1,
    name: 'Product',
    price: 100,
    ...overrides,
  };
}

test('user profile', () => {
  const user = createMockUser({ name: 'Jane' });
  render(<UserProfile user={user} />);
  
  expect(screen.getByText('Jane')).toBeInTheDocument();
});

检查清单 #

编写测试前 #

  • [ ] 理解组件的用户交互
  • [ ] 确定测试的关键场景
  • [ ] 准备测试数据

编写测试时 #

  • [ ] 使用语义化查询
  • [ ] 测试用户可见的行为
  • [ ] 正确处理异步操作
  • [ ] 测试边界情况

编写测试后 #

  • [ ] 测试可以独立运行
  • [ ] 测试名称清晰描述行为
  • [ ] 没有测试实现细节
  • [ ] 代码覆盖率合理

总结 #

核心要点 #

  1. 以用户为中心:测试用户看到的内容和交互行为
  2. 使用语义化查询:优先使用 getByRolegetByLabelText
  3. 避免实现细节:不要测试组件内部状态和方法
  4. 正确处理异步:使用 findBywaitFor
  5. 保持测试独立:每个测试应该可以单独运行

持续改进 #

  • 定期重构测试代码
  • 保持测试与代码同步
  • 学习新的测试技巧
  • 关注测试性能

参考资源 #

最后更新:2026-03-28