36. 单元测试组件?(Jest, React Testing Library)

19 阅读4分钟

React 面试题详细答案 - 第 36 题

36. 单元测试组件?(Jest, React Testing Library)

单元测试概述

单元测试是软件开发中的重要实践,对于 React 组件测试,主要目标是:

  • 验证组件行为是否符合预期
  • 确保组件在不同 props 下的正确渲染
  • 测试用户交互和事件处理
  • 保证代码重构时的安全性

Jest 基础

1. Jest 配置
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
      '<rootDir>/__mocks__/fileMock.js',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/reportWebVitals.js',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}
2. 基础测试语法
// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'

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

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

    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  test('applies custom className', () => {
    render(<Button className="custom-class">Click me</Button>)
    const button = screen.getByRole('button')
    expect(button).toHaveClass('custom-class')
  })
})

React Testing Library 详解

1. 查询方法
// 优先级:可访问性 > 语义化 > 测试 ID
import { render, screen } from '@testing-library/react'

// 1. 可访问性查询(推荐)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/username/i)
screen.getByText(/welcome/i)
screen.getByPlaceholderText(/enter email/i)

// 2. 语义化查询
screen.getByAltText(/profile picture/i)
screen.getByTitle(/close dialog/i)
screen.getByDisplayValue(/john@example.com/i)

// 3. 测试 ID(最后选择)
screen.getByTestId('submit-button')

// 查询多个元素
screen.getAllByRole('listitem')
screen.queryAllByText(/error/i) // 不抛出异常
2. 用户交互测试
import { render, screen, fireEvent, userEvent } from '@testing-library/react'

test('form submission', async () => {
  const user = userEvent.setup()
  const handleSubmit = jest.fn()

  render(<LoginForm onSubmit={handleSubmit} />)

  // 使用 userEvent 模拟真实用户行为
  await user.type(screen.getByLabelText(/username/i), 'john')
  await user.type(screen.getByLabelText(/password/i), 'password123')
  await user.click(screen.getByRole('button', { name: /login/i }))

  expect(handleSubmit).toHaveBeenCalledWith({
    username: 'john',
    password: 'password123',
  })
})

test('keyboard navigation', async () => {
  const user = userEvent.setup()
  render(<TodoList />)

  const input = screen.getByRole('textbox')
  await user.type(input, 'New todo')
  await user.keyboard('{Enter}')

  expect(screen.getByText('New todo')).toBeInTheDocument()
})
3. 异步操作测试
import { render, screen, waitFor } from '@testing-library/react'

test('loads user data', async () => {
  const mockFetch = jest.fn().mockResolvedValue({
    json: () => Promise.resolve({ name: 'John', email: 'john@example.com' }),
  })
  global.fetch = mockFetch

  render(<UserProfile userId="123" />)

  // 等待异步操作完成
  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument()
  })

  expect(mockFetch).toHaveBeenCalledWith('/api/users/123')
})

test('handles loading and error states', async () => {
  const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'))
  global.fetch = mockFetch

  render(<UserProfile userId="123" />)

  // 测试加载状态
  expect(screen.getByText(/loading/i)).toBeInTheDocument()

  // 测试错误状态
  await waitFor(() => {
    expect(screen.getByText(/error/i)).toBeInTheDocument()
  })
})

高级测试技巧

1. Mock 和 Stub
// Mock 外部依赖
jest.mock('../api/userService', () => ({
  fetchUser: jest.fn(),
  updateUser: jest.fn(),
}))

// Mock React Router
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useNavigate: () => jest.fn(),
  useParams: () => ({ id: '123' }),
}))

// Mock 自定义 Hook
jest.mock('../hooks/useAuth', () => ({
  useAuth: () => ({
    user: { id: '1', name: 'John' },
    login: jest.fn(),
    logout: jest.fn(),
  }),
}))

test('component with mocked dependencies', () => {
  render(<ProtectedComponent />)
  expect(screen.getByText('Welcome, John')).toBeInTheDocument()
})
2. 自定义渲染函数
// test-utils.js
import { render } from '@testing-library/react'
import { ThemeProvider } from 'styled-components'
import { BrowserRouter } from 'react-router-dom'

const AllTheProviders = ({ children }) => {
  return (
    <BrowserRouter>
      <ThemeProvider theme={theme}>{children}</ThemeProvider>
    </BrowserRouter>
  )
}

const customRender = (ui, options) =>
  render(ui, { wrapper: AllTheProviders, ...options })

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

// 在测试中使用
import { render, screen } from '../test-utils'

test('renders with providers', () => {
  render(<MyComponent />)
  // 组件会自动包装在 providers 中
})
3. 快照测试
import { render } from '@testing-library/react'
import Button from './Button'

test('button snapshot', () => {
  const { container } = render(<Button>Click me</Button>)
  expect(container.firstChild).toMatchSnapshot()
})

// 更新快照:npm test -- --updateSnapshot

测试最佳实践

1. 测试结构
describe('UserProfile Component', () => {
  // 每个测试前的设置
  beforeEach(() => {
    jest.clearAllMocks()
  })

  describe('when user is loading', () => {
    test('shows loading spinner', () => {
      render(<UserProfile loading={true} />)
      expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
    })
  })

  describe('when user data is loaded', () => {
    test('displays user information', () => {
      const user = { name: 'John', email: 'john@example.com' }
      render(<UserProfile user={user} />)

      expect(screen.getByText('John')).toBeInTheDocument()
      expect(screen.getByText('john@example.com')).toBeInTheDocument()
    })
  })

  describe('when user clicks edit button', () => {
    test('opens edit form', async () => {
      const user = userEvent.setup()
      render(<UserProfile user={{ name: 'John' }} />)

      await user.click(screen.getByRole('button', { name: /edit/i }))
      expect(screen.getByRole('form')).toBeInTheDocument()
    })
  })
})
2. 可访问性测试
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'

expect.extend(toHaveNoViolations)

test('should not have accessibility violations', async () => {
  const { container } = render(<MyComponent />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

// 测试键盘导航
test('supports keyboard navigation', async () => {
  const user = userEvent.setup()
  render(<Menu />)

  const firstItem = screen.getByRole('menuitem', { name: /first item/i })
  firstItem.focus()

  await user.keyboard('{ArrowDown}')
  expect(screen.getByRole('menuitem', { name: /second item/i })).toHaveFocus()
})
3. 性能测试
import { render } from '@testing-library/react'
import { act } from 'react-dom/test-utils'

test('renders large list efficiently', () => {
  const items = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`,
  }))

  const startTime = performance.now()
  render(<VirtualizedList items={items} />)
  const endTime = performance.now()

  // 确保渲染时间在合理范围内
  expect(endTime - startTime).toBeLessThan(100)
})

测试覆盖率

1. 覆盖率配置
// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/**/*.stories.js',
    '!src/**/*.test.js',
  ],
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    './src/components/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
}
2. 覆盖率报告
# 生成覆盖率报告
npm test -- --coverage

# 只运行失败的测试
npm test -- --onlyFailures

# 监视模式
npm test -- --watch

总结

React 组件测试的核心原则:

  1. 测试用户行为:关注用户如何使用组件,而不是实现细节
  2. 可访问性优先:使用可访问性查询方法
  3. 真实用户交互:使用 userEvent 而不是 fireEvent
  4. 异步操作处理:正确使用 waitFor 和 async/await
  5. Mock 外部依赖:隔离组件逻辑
  6. 测试覆盖率:确保关键逻辑被测试覆盖

通过 Jest 和 React Testing Library 的组合使用,可以构建健壮、可维护的测试套件,确保 React 应用的质量和稳定性。