Библиотека @testing-library предназначена для
тестирования пользовательского интерфейса через поведение, максимально
приближённое к действиям пользователя. В экосистеме React чаще всего
используется пакет @testing-library/react, но аналогичные
решения существуют и для других фреймворков.
Главная идея библиотеки — тестировать не внутреннюю реализацию компонента, а его внешний интерфейс:
Такой подход делает тесты устойчивыми к рефакторингу и снижает зависимость от внутренней структуры компонентов.
Для 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 |
симуляция действий пользователя |
Конфигурация 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 />)
После рендера доступны методы поиска элементов.
screenscreen предоставляет глобальный доступ к DOM после
рендера.
Пример:
screen.getByText('Отправить')
Использование screen считается более предпочтительным,
чем деструктуризация результата render().
getByИспользуется, когда элемент обязан существовать.
screen.getByText('Войти')
Если элемент не найден — тест завершится ошибкой.
queryByИспользуется для проверки отсутствия элемента.
expect(
screen.queryByText('Ошибка')
).not.toBeInTheDocument()
Не выбрасывает исключение при отсутствии элемента.
findByИспользуется для асинхронного ожидания.
const element = await screen.findByText('Загрузка завершена')
Метод автоматически ожидает появление элемента.
Testing Library рекомендует искать элементы так же, как их находит пользователь.
Приоритет запросов:
getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValuegetByTestIdgetByRoleНаиболее рекомендуемый способ.
Компонент:
<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()
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')
выполняет:
Компонент:
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)
Компонент:
<input type="checkbox" />
Тест:
const checkbox = screen.getByRole('checkbox')
await user.click(checkbox)
expect(checkbox).toBeChecked()
Компонент:
<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()
})
waitForwaitFor повторяет проверку, пока она не станет
успешной.
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()
})
Компонент:
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()
})
Для компонентов с роутингом обычно используется
MemoryRouter.
import { MemoryRouter } from 'react-router-dom'
render(
<MemoryRouter>
<App />
</MemoryRouter>
)
render(
<ThemeContext.Provider value="dark">
<Component />
</ThemeContext.Provider>
)
Часто создают собственный 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)
})
actact() необходим при изменении состояния вне обычного
пользовательского сценария.
act(() => {
result.current.increment()
})
Без act() возможны предупреждения React.
beforeEach(() => {
vi.clearAllMocks()
})
Пример:
global.fetch = vi.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
name: 'Admin'
})
})
)
expect(fetch).toHaveBeenCalledWith(
'/api/user'
)
Testing Library поддерживает snapshot-тестирование:
const { container } = render(<App />)
expect(container).toMatchSnapshot()
Однако snapshot-тесты рекомендуется использовать ограниченно, так как они плохо отражают пользовательское поведение.
Плохой пример:
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()
})
Тесты не ломаются при внутреннем рефакторинге компонента.
Проверяется именно пользовательский сценарий.
Использование getByRole, label и semantic
HTML автоматически улучшает accessibility приложения.
Testing Library полностью совместима с экосистемой Vite и работает без сложной конфигурации.