Тестирование компонентов с @testing-library

Библиотека @testing-library предназначена для тестирования пользовательского интерфейса через поведение, максимально приближённое к действиям пользователя. В экосистеме React чаще всего используется пакет @testing-library/react, но аналогичные решения существуют и для других фреймворков.

Главная идея библиотеки — тестировать не внутреннюю реализацию компонента, а его внешний интерфейс:

  • отображаемый текст;
  • кнопки;
  • формы;
  • поля ввода;
  • события;
  • взаимодействие пользователя;
  • изменения DOM после действий.

Такой подход делает тесты устойчивыми к рефакторингу и снижает зависимость от внутренней структуры компонентов.


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

Для React-проекта на Vite обычно устанавливаются следующие пакеты:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

Назначение библиотек:

Пакет Назначение
vitest тестовый раннер
jsdom браузерное окружение
@testing-library/react рендер React-компонентов
@testing-library/jest-dom дополнительные matcher-выражения
@testing-library/user-event симуляция действий пользователя

Настройка Vitest для тестирования компонентов

Конфигурация vite.config.js:

/// <reference types="vitest" />

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
    plugins: [react()],

    test: {
        environment: 'jsdom',
        globals: true,
        setupFiles: './src/test/setup.js'
    }
})

Файл глобальной настройки

Файл src/test/setup.js:

import '@testing-library/jest-dom'

После подключения становятся доступны дополнительные проверки:

expect(element).toBeInTheDocument()
expect(button).toBeDisabled()
expect(input).toHaveValue('admin')

Первый тест компонента

Компонент:

export function Button() {
    return (
        <button>
            Сохранить
        </button>
    )
}

Тест:

import { render, screen } from '@testing-library/react'
import { Button } from './Button'

describe('Button', () => {
    test('рендерит кнопку', () => {
        render(<Button />)

        expect(
            screen.getByText('Сохранить')
        ).toBeInTheDocument()
    })
})

Функция render

Метод render() создаёт компонент в виртуальном DOM.

render(<App />)

После рендера доступны методы поиска элементов.


Объект screen

screen предоставляет глобальный доступ к DOM после рендера.

Пример:

screen.getByText('Отправить')

Использование screen считается более предпочтительным, чем деструктуризация результата render().


Методы поиска элементов

getBy

Используется, когда элемент обязан существовать.

screen.getByText('Войти')

Если элемент не найден — тест завершится ошибкой.


queryBy

Используется для проверки отсутствия элемента.

expect(
    screen.queryByText('Ошибка')
).not.toBeInTheDocument()

Не выбрасывает исключение при отсутствии элемента.


findBy

Используется для асинхронного ожидания.

const element = await screen.findByText('Загрузка завершена')

Метод автоматически ожидает появление элемента.


Приоритет поиска элементов

Testing Library рекомендует искать элементы так же, как их находит пользователь.

Приоритет запросов:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByTestId

Поиск через getByRole

Наиболее рекомендуемый способ.

Компонент:

<button>
    Отправить
</button>

Тест:

screen.getByRole('button', {
    name: 'Отправить'
})

Такой тест ближе к реальному пользовательскому взаимодействию.


Поиск текстового поля

Компонент:

<label htmlFor="email">
    Email
</label>

<input id="email" />

Тест:

screen.getByLabelText('Email')

Использование data-testid

Используется только в крайних случаях.

<div data-testid="loader" />

Тест:

screen.getByTestId('loader')

Избыточное использование data-testid считается плохой практикой.


Проверка содержимого

expect(screen.getByText('Главная'))
    .toBeInTheDocument()

Проверка CSS-классов

expect(button).toHaveClass('active')

Проверка атрибутов

expect(link).toHaveAttribute(
    'href',
    '/profile'
)

Проверка состояния формы

expect(input).toHaveValue('admin')

expect(checkbox).toBeChecked()

expect(button).toBeDisabled()

Проверка отсутствия элемента

expect(
    screen.queryByText('Ошибка')
).not.toBeInTheDocument()

Тестирование событий

Событие клика

Компонент:

export function Counter() {
    const [count, setCount] = useState(0)

    return (
        <>
            <span>{count}</span>

            <button onCl ick={() => setCount(count + 1)}>
                +
            </button>
        </>
    )
}

Тест:

import userEvent from '@testing-library/user-event'

test('увеличивает счётчик', async () => {
    render(<Counter />)

    const user = userEvent.setup()

    await user.click(
        screen.getByRole('button')
    )

    expect(
        screen.getByText('1')
    ).toBeInTheDocument()
})

Почему используется user-event

Ранее часто применялся fireEvent, однако user-event точнее повторяет поведение браузера и пользователя.

Например:

await user.type(input, 'admin')

выполняет:

  • focus;
  • keydown;
  • input;
  • keyup;
  • change.

Ввод текста

Компонент:

export function LoginForm() {
    return (
        <input aria-label="Логин" />
    )
}

Тест:

test('вводит текст', async () => {
    render(<LoginForm />)

    const user = userEvent.setup()

    const input = screen.getByLabelText('Логин')

    await user.type(input, 'admin')

    expect(input).toHaveValue('admin')
})

Очистка поля

await user.clear(input)

Работа с checkbox

Компонент:

<input type="checkbox" />

Тест:

const checkbox = screen.getByRole('checkbox')

await user.click(checkbox)

expect(checkbox).toBeChecked()

Работа с select

Компонент:

<select>
    <option value="ru">RU</option>
    <option value="en">EN</option>
</select>

Тест:

await user.selectOptions(select, 'en')

expect(sel ect).toHaveValue('en')

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

Асинхронный компонент

export function User() {
    const [name, setName] = useState('')

    useEffect(() => {
        setTimeout(() => {
            setName('Admin')
        }, 1000)
    }, [])

    return <div>{name}</div>
}

Тест:

test('загружает пользователя', async () => {
    render(<User />)

    expect(
        await screen.findByText('Admin')
    ).toBeInTheDocument()
})

Использование waitFor

waitFor повторяет проверку, пока она не станет успешной.

import { waitFor } fr om '@testing-library/react'

await waitFor(() => {
    expect(apiMock).toHaveBeenCalled()
})

Проверка удаления элемента

await waitFor(() => {
    expect(
        screen.queryByText('Loading')
    ).not.toBeInTheDocument()
})

Тестирование загрузчиков

Компонент:

export function Loader() {
    const [loading, setLoading] = useState(true)

    useEffect(() => {
        setTimeout(() => {
            setLoading(false)
        }, 1000)
    }, [])

    return loading
        ? <span>Loading...</span>
        : <span>Done</span>
}

Тест:

test('скрывает loader', async () => {
    render(<Loader />)

    expect(
        screen.getByText('Loading...')
    ).toBeInTheDocument()

    expect(
        await screen.findByText('Done')
    ).toBeInTheDocument()
})

Тестирование callback-функций

Компонент:

export function SaveButton({ onSave }) {
    return (
        <button onCl ick={onSave}>
            Сохранить
        </button>
    )
}

Тест:

test('вызывает onSave', async () => {
    const onS ave = vi.fn()

    render(
        <SaveButton onS ave={onSave} />
    )

    const user = userEvent.setup()

    await user.click(
        screen.getByRole('button')
    )

    expect(onSave).toHaveBeenCalled()
})

Проверка количества вызовов

expect(mockFn).toHaveBeenCalledTimes(2)

Проверка аргументов

expect(mockFn).toHaveBeenCalledWith({
    id: 1
})

Тестирование условного рендера

Компонент:

export function Alert({ error }) {
    if (!error) {
        return null
    }

    return (
        <div>
            Ошибка
        </div>
    )
}

Тест:

test('показывает ошибку', () => {
    render(<Alert error={true} />)

    expect(
        screen.getByText('Ошибка')
    ).toBeInTheDocument()
})

Проверка отсутствия:

test('скрывает ошибку', () => {
    render(<Alert error={false} />)

    expect(
        screen.queryByText('Ошибка')
    ).not.toBeInTheDocument()
})

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

Компонент:

export function Users() {
    return (
        <ul>
            <li>Admin</li>
            <li>Manager</li>
            <li>Guest</li>
        </ul>
    )
}

Тест:

test('рендерит список', () => {
    render(<Users />)

    const items = screen.getAllByRole('listitem')

    expect(items).toHaveLength(3)
})

Тестирование появления ошибок

Компонент:

export function Form() {
    const [error, setError] = useState(false)

    return (
        <>
            <button onCl ick={() => setError(true)}>
                Submit
            </button>

            {error && (
                <span>Error</span>
            )}
        </>
    )
}

Тест:

test('показывает ошибку', async () => {
    render(<Form />)

    const user = userEvent.setup()

    await user.click(
        screen.getByText('Submit')
    )

    expect(
        screen.getByText('Error')
    ).toBeInTheDocument()
})

Тестирование React Router

Для компонентов с роутингом обычно используется MemoryRouter.

import { MemoryRouter } from 'react-router-dom'

render(
    <MemoryRouter>
        <App />
    </MemoryRouter>
)

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

render(
    <ThemeContext.Provider value="dark">
        <Component />
    </ThemeContext.Provider>
)

Создание helper-функции render

Часто создают собственный render для подключения провайдеров.

import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'

export function renderWithProviders(ui) {
    return render(
        <MemoryRouter>
            {ui}
        </MemoryRouter>
    )
}

Использование:

renderWithProviders(<App />)

Тестирование кастомных хуков

Для хуков используется renderHook.

npm install -D @testing-library/react

Пример:

import { renderHook } from '@testing-library/react'

function useCounter() {
    const [count, setCount] = useState(0)

    return {
        count,
        increment: () => setCount(c => c + 1)
    }
}

Тест:

test('увеличивает count', () => {
    const { result } = renderHook(() => useCounter())

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

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

Использование act

act() необходим при изменении состояния вне обычного пользовательского сценария.

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

Без act() возможны предупреждения React.


Очистка mock-функций

beforeEach(() => {
    vi.clearAllMocks()
})

Mock API-запросов

Пример:

global.fetch = vi.fn(() =>
    Promise.resolve({
        json: () =>
            Promise.resolve({
                name: 'Admin'
            })
    })
)

Проверка API-запроса

expect(fetch).toHaveBeenCalledWith(
    '/api/user'
)

Snapshot-тесты

Testing Library поддерживает snapshot-тестирование:

const { container } = render(<App />)

expect(container).toMatchSnapshot()

Однако snapshot-тесты рекомендуется использовать ограниченно, так как они плохо отражают пользовательское поведение.


Ошибки при тестировании компонентов

Проверка внутренних state

Плохой пример:

expect(component.state.loading)
    .toBe(true)

Testing Library не предполагает доступ к внутреннему состоянию.


Использование container.querySelector

Плохой пример:

container.querySelector('.btn')

Предпочтительнее:

screen.getByRole('button')

Избыточные data-testid

Частая ошибка:

<div data-testid="title">

Хотя можно использовать:

screen.getByText('Главная')

Рекомендации по структуре тестов

Популярный шаблон:

test('авторизует пользователя', async () => {
    render(<LoginForm />)

    const user = userEvent.setup()

    const loginInput =
        screen.getByLabelText('Логин')

    const passwordInput =
        screen.getByLabelText('Пароль')

    await user.type(loginInput, 'admin')
    await user.type(passwordInput, '123456')

    await user.click(
        screen.getByRole('button', {
            name: 'Войти'
        })
    )

    expect(
        screen.getByText('Добро пожаловать')
    ).toBeInTheDocument()
})

Преимущества Testing Library

Независимость от реализации

Тесты не ломаются при внутреннем рефакторинге компонента.


Проверка поведения

Проверяется именно пользовательский сценарий.


Улучшение доступности

Использование getByRole, label и semantic HTML автоматически улучшает accessibility приложения.


Простая интеграция с Vitest

Testing Library полностью совместима с экосистемой Vite и работает без сложной конфигурации.