Тесты — это страховая сетка при рефакторинге. Без тестов каждое изменение в коде — риск сломать что-то незаметно. С тестами вы рефакторите уверенно: если тесты прошли, поведение не изменилось.
Три уровня тестирования:
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 — первый тест упадёт. Второй — нет.
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()
})
})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()// Снапшот — "фотография" рендера компонента
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()
// })Тесты — это страховая сетка при рефакторинге. Без тестов каждое изменение в коде — риск сломать что-то незаметно. С тестами вы рефакторите уверенно: если тесты прошли, поведение не изменилось.
Три уровня тестирования:
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 — первый тест упадёт. Второй — нет.
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()
})
})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()// Снапшот — "фотография" рендера компонента
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} или другое число.