← React/Тестирование React компонентов#274 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Тестирование React компонентов

Зачем тестировать UI

Тесты — это страховая сетка при рефакторинге. Без тестов каждое изменение в коде — риск сломать что-то незаметно. С тестами вы рефакторите уверенно: если тесты прошли, поведение не изменилось.

Три уровня тестирования:

  • Unit-тесты — тестируем функции/хуки в изоляции
  • Интеграционные тесты — тестируем компоненты с их взаимодействиями
  • E2E-тесты — тестируем пользовательский сценарий в браузере (Playwright, Cypress)
  • React Testing Library: философия

    RTL построен на одном принципе: тестируй поведение, а не реализацию.

    // Плохо: тест реализации (хрупкий)
    expect(wrapper.state('count')).toBe(1)
    expect(component.find('.counter-display').text()).toBe('1')
    
    // Хорошо: тест поведения (устойчивый)
    expect(screen.getByText('1')).toBeInTheDocument()
    userEvent.click(screen.getByRole('button', { name: /увеличить/i }))
    expect(screen.getByText('2')).toBeInTheDocument()

    Если вы переименуете класс .counter-display — первый тест упадёт. Второй — нет.

    Базовые инструменты RTL

    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    
    // render — монтирует компонент в виртуальный DOM
    const { unmount } = render(<Counter initialValue={0} />)
    
    // screen — объект для поиска элементов
    screen.getByText('0')           // по тексту (бросает если не найден)
    screen.getByRole('button')      // по роли ARIA (предпочтительно)
    screen.getByLabelText('Имя')    // по label для inputs
    screen.getByTestId('my-elem')   // по data-testid (последний резерв)
    screen.queryByText('...')       // возвращает null если не найден
    screen.findByText('...')        // async, ждёт появления элемента

    Пример: тестирование счётчика

    // Counter.test.jsx
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import Counter from './Counter'
    
    describe('Counter', () => {
      test('начальное значение равно 0', () => {
        render(<Counter />)
        expect(screen.getByText('0')).toBeInTheDocument()
      })
    
      test('кнопка "+" увеличивает счётчик', async () => {
        render(<Counter />)
        await userEvent.click(screen.getByRole('button', { name: /+/i }))
        expect(screen.getByText('1')).toBeInTheDocument()
      })
    
      test('кнопка "Сброс" возвращает к 0', async () => {
        render(<Counter />)
        await userEvent.click(screen.getByRole('button', { name: /+/i }))
        await userEvent.click(screen.getByRole('button', { name: /сброс/i }))
        expect(screen.getByText('0')).toBeInTheDocument()
      })
    })

    Асинхронные тесты с waitFor

    test('загружает пользователей из API', async () => {
      // Мокируем fetch
      global.fetch = jest.fn(() =>
        Promise.resolve({
          ok: true,
          json: () => Promise.resolve([{ id: 1, name: 'Алексей' }])
        })
      )
    
      render(<UserList />)
    
      // Сначала видим лоадер
      expect(screen.getByText(/загрузка/i)).toBeInTheDocument()
    
      // Ждём появления данных
      await waitFor(() => {
        expect(screen.getByText('Алексей')).toBeInTheDocument()
      })
    
      // Лоадер исчез
      expect(screen.queryByText(/загрузка/i)).not.toBeInTheDocument()
    })

    Мокирование зависимостей

    // Мок модуля
    jest.mock('./api', () => ({
      fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Тест' }))
    }))
    
    // Шпион за функцией
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
    // ...
    consoleSpy.mockRestore()

    Snapshot-тестирование: когда использовать

    // Снапшот — "фотография" рендера компонента
    test('рендер карточки соответствует снапшоту', () => {
      const { container } = render(<Card title="Привет" />)
      expect(container).toMatchSnapshot()
    })

    Снапшоты полезны для компонентов без логики (статичные карточки, иконки). Не используйте для компонентов с изменяемым состоянием — снапшоты будут постоянно устаревать.

    Рекомендуемый стек: Vitest + @testing-library/react + @testing-library/user-event

    Примеры

    Реализация простого тест-раннера с нуля: describe/test/expect, mock DOM, тестирование счётчика

    // Реализуем минимальный тест-раннер чтобы понять,
    // как работает Vitest/Jest "под капотом".
    
    // --- Тест-раннер ---
    
    const results = { passed: 0, failed: 0, errors: [] }
    
    function describe(suiteName, fn) {
      console.log(`
    === ${suiteName} ===`)
      fn()
    }
    
    function test(testName, fn) {
      try {
        fn()
        results.passed++
        console.log(`  ✓ ${testName}`)
      } catch (e) {
        results.failed++
        results.errors.push({ testName, error: e.message })
        console.log(`  ✗ ${testName}: ${e.message}`)
      }
    }
    
    // Матчеры (как expect().toBe())
    function expect(received) {
      return {
        toBe(expected) {
          if (received !== expected) {
            throw new Error(`Ожидалось ${JSON.stringify(expected)}, получено ${JSON.stringify(received)}`)
          }
        },
        toEqual(expected) {
          const r = JSON.stringify(received)
          const e = JSON.stringify(expected)
          if (r !== e) throw new Error(`Ожидалось ${e}, получено ${r}`)
        },
        toBeNull() {
          if (received !== null) throw new Error(`Ожидалось null, получено ${received}`)
        },
        toBeTruthy() {
          if (!received) throw new Error(`Ожидалось truthy, получено ${received}`)
        },
        not: {
          toBe(expected) {
            if (received === expected) {
              throw new Error(`Не ожидалось ${JSON.stringify(expected)}`)
            }
          }
        }
      }
    }
    
    // --- "Компонент" счётчика (логика без JSX) ---
    
    function createCounter(initialValue = 0) {
      let count = initialValue
    
      return {
        getValue: () => count,
        increment: () => { count++ },
        decrement: () => { count-- },
        reset: () => { count = initialValue },
        // Симуляция рендера
        render: () => `<div><span>${count}</span></div>`
      }
    }
    
    // --- Тесты ---
    
    describe('createCounter', () => {
      test('начальное значение равно переданному аргументу', () => {
        const counter = createCounter(5)
        expect(counter.getValue()).toBe(5)
      })
    
      test('increment увеличивает счётчик на 1', () => {
        const counter = createCounter(0)
        counter.increment()
        expect(counter.getValue()).toBe(1)
      })
    
      test('decrement уменьшает счётчик на 1', () => {
        const counter = createCounter(3)
        counter.decrement()
        expect(counter.getValue()).toBe(2)
      })
    
      test('reset возвращает начальное значение', () => {
        const counter = createCounter(0)
        counter.increment()
        counter.increment()
        counter.increment()
        expect(counter.getValue()).toBe(3)
        counter.reset()
        expect(counter.getValue()).toBe(0)
      })
    
      test('счётчик может уйти в отрицательные значения', () => {
        const counter = createCounter(0)
        counter.decrement()
        expect(counter.getValue()).toBe(-1)
      })
    
      test('render возвращает HTML со значением', () => {
        const counter = createCounter(42)
        expect(counter.render()).toBe('<div><span>42</span></div>')
      })
    })
    
    // --- Итог ---
    
    console.log(`
    Итого: ${results.passed} прошло, ${results.failed} упало`)
    
    if (results.errors.length > 0) {
      console.log('
    Ошибки:')
      results.errors.forEach(({ testName, error }) => {
        console.log(`  - "${testName}": ${error}`)
      })
    }
    
    // Реальный код с @testing-library/react:
    //
    // import { render, screen } from '@testing-library/react'
    // import userEvent from '@testing-library/user-event'
    //
    // test('increment button works', async () => {
    //   render(<Counter initialValue={0} />)
    //   const button = screen.getByRole('button', { name: /+/ })
    //   await userEvent.click(button)
    //   expect(screen.getByText('1')).toBeInTheDocument()
    // })

    Тестирование React компонентов

    Зачем тестировать UI

    Тесты — это страховая сетка при рефакторинге. Без тестов каждое изменение в коде — риск сломать что-то незаметно. С тестами вы рефакторите уверенно: если тесты прошли, поведение не изменилось.

    Три уровня тестирования:

  • Unit-тесты — тестируем функции/хуки в изоляции
  • Интеграционные тесты — тестируем компоненты с их взаимодействиями
  • E2E-тесты — тестируем пользовательский сценарий в браузере (Playwright, Cypress)
  • React Testing Library: философия

    RTL построен на одном принципе: тестируй поведение, а не реализацию.

    // Плохо: тест реализации (хрупкий)
    expect(wrapper.state('count')).toBe(1)
    expect(component.find('.counter-display').text()).toBe('1')
    
    // Хорошо: тест поведения (устойчивый)
    expect(screen.getByText('1')).toBeInTheDocument()
    userEvent.click(screen.getByRole('button', { name: /увеличить/i }))
    expect(screen.getByText('2')).toBeInTheDocument()

    Если вы переименуете класс .counter-display — первый тест упадёт. Второй — нет.

    Базовые инструменты RTL

    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    
    // render — монтирует компонент в виртуальный DOM
    const { unmount } = render(<Counter initialValue={0} />)
    
    // screen — объект для поиска элементов
    screen.getByText('0')           // по тексту (бросает если не найден)
    screen.getByRole('button')      // по роли ARIA (предпочтительно)
    screen.getByLabelText('Имя')    // по label для inputs
    screen.getByTestId('my-elem')   // по data-testid (последний резерв)
    screen.queryByText('...')       // возвращает null если не найден
    screen.findByText('...')        // async, ждёт появления элемента

    Пример: тестирование счётчика

    // Counter.test.jsx
    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    import Counter from './Counter'
    
    describe('Counter', () => {
      test('начальное значение равно 0', () => {
        render(<Counter />)
        expect(screen.getByText('0')).toBeInTheDocument()
      })
    
      test('кнопка "+" увеличивает счётчик', async () => {
        render(<Counter />)
        await userEvent.click(screen.getByRole('button', { name: /+/i }))
        expect(screen.getByText('1')).toBeInTheDocument()
      })
    
      test('кнопка "Сброс" возвращает к 0', async () => {
        render(<Counter />)
        await userEvent.click(screen.getByRole('button', { name: /+/i }))
        await userEvent.click(screen.getByRole('button', { name: /сброс/i }))
        expect(screen.getByText('0')).toBeInTheDocument()
      })
    })

    Асинхронные тесты с waitFor

    test('загружает пользователей из API', async () => {
      // Мокируем fetch
      global.fetch = jest.fn(() =>
        Promise.resolve({
          ok: true,
          json: () => Promise.resolve([{ id: 1, name: 'Алексей' }])
        })
      )
    
      render(<UserList />)
    
      // Сначала видим лоадер
      expect(screen.getByText(/загрузка/i)).toBeInTheDocument()
    
      // Ждём появления данных
      await waitFor(() => {
        expect(screen.getByText('Алексей')).toBeInTheDocument()
      })
    
      // Лоадер исчез
      expect(screen.queryByText(/загрузка/i)).not.toBeInTheDocument()
    })

    Мокирование зависимостей

    // Мок модуля
    jest.mock('./api', () => ({
      fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'Тест' }))
    }))
    
    // Шпион за функцией
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
    // ...
    consoleSpy.mockRestore()

    Snapshot-тестирование: когда использовать

    // Снапшот — "фотография" рендера компонента
    test('рендер карточки соответствует снапшоту', () => {
      const { container } = render(<Card title="Привет" />)
      expect(container).toMatchSnapshot()
    })

    Снапшоты полезны для компонентов без логики (статичные карточки, иконки). Не используйте для компонентов с изменяемым состоянием — снапшоты будут постоянно устаревать.

    Рекомендуемый стек: Vitest + @testing-library/react + @testing-library/user-event

    Примеры

    Реализация простого тест-раннера с нуля: describe/test/expect, mock DOM, тестирование счётчика

    // Реализуем минимальный тест-раннер чтобы понять,
    // как работает Vitest/Jest "под капотом".
    
    // --- Тест-раннер ---
    
    const results = { passed: 0, failed: 0, errors: [] }
    
    function describe(suiteName, fn) {
      console.log(`
    === ${suiteName} ===`)
      fn()
    }
    
    function test(testName, fn) {
      try {
        fn()
        results.passed++
        console.log(`  ✓ ${testName}`)
      } catch (e) {
        results.failed++
        results.errors.push({ testName, error: e.message })
        console.log(`  ✗ ${testName}: ${e.message}`)
      }
    }
    
    // Матчеры (как expect().toBe())
    function expect(received) {
      return {
        toBe(expected) {
          if (received !== expected) {
            throw new Error(`Ожидалось ${JSON.stringify(expected)}, получено ${JSON.stringify(received)}`)
          }
        },
        toEqual(expected) {
          const r = JSON.stringify(received)
          const e = JSON.stringify(expected)
          if (r !== e) throw new Error(`Ожидалось ${e}, получено ${r}`)
        },
        toBeNull() {
          if (received !== null) throw new Error(`Ожидалось null, получено ${received}`)
        },
        toBeTruthy() {
          if (!received) throw new Error(`Ожидалось truthy, получено ${received}`)
        },
        not: {
          toBe(expected) {
            if (received === expected) {
              throw new Error(`Не ожидалось ${JSON.stringify(expected)}`)
            }
          }
        }
      }
    }
    
    // --- "Компонент" счётчика (логика без JSX) ---
    
    function createCounter(initialValue = 0) {
      let count = initialValue
    
      return {
        getValue: () => count,
        increment: () => { count++ },
        decrement: () => { count-- },
        reset: () => { count = initialValue },
        // Симуляция рендера
        render: () => `<div><span>${count}</span></div>`
      }
    }
    
    // --- Тесты ---
    
    describe('createCounter', () => {
      test('начальное значение равно переданному аргументу', () => {
        const counter = createCounter(5)
        expect(counter.getValue()).toBe(5)
      })
    
      test('increment увеличивает счётчик на 1', () => {
        const counter = createCounter(0)
        counter.increment()
        expect(counter.getValue()).toBe(1)
      })
    
      test('decrement уменьшает счётчик на 1', () => {
        const counter = createCounter(3)
        counter.decrement()
        expect(counter.getValue()).toBe(2)
      })
    
      test('reset возвращает начальное значение', () => {
        const counter = createCounter(0)
        counter.increment()
        counter.increment()
        counter.increment()
        expect(counter.getValue()).toBe(3)
        counter.reset()
        expect(counter.getValue()).toBe(0)
      })
    
      test('счётчик может уйти в отрицательные значения', () => {
        const counter = createCounter(0)
        counter.decrement()
        expect(counter.getValue()).toBe(-1)
      })
    
      test('render возвращает HTML со значением', () => {
        const counter = createCounter(42)
        expect(counter.render()).toBe('<div><span>42</span></div>')
      })
    })
    
    // --- Итог ---
    
    console.log(`
    Итого: ${results.passed} прошло, ${results.failed} упало`)
    
    if (results.errors.length > 0) {
      console.log('
    Ошибки:')
      results.errors.forEach(({ testName, error }) => {
        console.log(`  - "${testName}": ${error}`)
      })
    }
    
    // Реальный код с @testing-library/react:
    //
    // import { render, screen } from '@testing-library/react'
    // import userEvent from '@testing-library/user-event'
    //
    // test('increment button works', async () => {
    //   render(<Counter initialValue={0} />)
    //   const button = screen.getByRole('button', { name: /+/ })
    //   await userEvent.click(button)
    //   expect(screen.getByText('1')).toBeInTheDocument()
    // })

    Задание

    Создай компонент Counter с кнопками "+", "-" и "Сброс". Добавь к элементам атрибуты data-testid для тестирования: data-testid="count" для отображения числа, data-testid="increment" для кнопки "+", data-testid="decrement" для "-", data-testid="reset" для "Сброс". Это стандартная практика при написании тестов с React Testing Library.

    Подсказка

    data-testid="count" на p с числом. data-testid="increment" на кнопку "+". data-testid="decrement" на кнопку "−". Сброс: setCount(initialValue). В App передай initialValue={5} или другое число.

    Загружаем среду выполнения...
    Загружаем AI-помощника...