Перейти к основному содержимому

Best Practices

Качество тестов определяется устойчивостью к изменениям реализации, читаемостью и отражением реального поведения системы.


Что НЕ тестировать

FORBIDDEN

Не тестируйте библиотеки — предполагается, что у них есть собственное покрытие.

// ❌ Тестируем next/navigation
expect(mockPush).toHaveBeenCalledWith('/');

// ✅ Тестируем свою логику
it('redirects to dashboard after successful login', async () => {
render(<LoginPage />);
await userEvent.type(screen.getByLabelText('Email'), '[email protected]');
await userEvent.type(screen.getByLabelText('Password'), 'secret');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Dashboard')).toBeInTheDocument();
});

Не тестируйте моки — тест должен проверять результат, а не факт вызова.

// ❌ Тестируем мок
const mockFetch = vi.fn();
render(<Component fetch={mockFetch} />);
expect(mockFetch).toHaveBeenCalled();

// ✅ Тестируем результат
server.use(http.get('/api/user', () => HttpResponse.json({ name: 'John' })));
render(<Component />);
expect(await screen.findByText('John')).toBeInTheDocument();

Не используйте импортированные константы в assertions — тест должен читаться как спецификация.

// ❌
import { ERROR_MESSAGE } from './constants';
expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument();

// ✅
expect(screen.getByText('Invalid email')).toBeInTheDocument();

Именование тестов

MUST

Тесты описывают поведение, а не детали реализации.

// ❌
it('calls useState with initial value');

// ✅
it('shows error message on invalid email');
it('when form is submitted with empty fields → shows validation errors');

Рекомендуемый формат: it('[условие] → [ожидаемое поведение]')


DOM-селекторы

MUST

ПриоритетСелекторКогда использовать
1 ✅byRole + nameКнопки, ссылки, инпуты с ролью
2 ✅byLabelTextПоля форм с <label>
3 ⚠️byTextВидимый текст без семантической роли
4 ❌byTestIdТолько если нет семантической альтернативы
// ✅
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
within(screen.getByRole('form')).getByLabelText('Email');

// ❌
container.querySelector('.email-input');
container.querySelector('form > div:nth-child(2) > input');

FORBIDDEN

  • CSS-классы как селекторы
  • Опора на порядок элементов в DOM
  • Опора на детали реализации

Асинхронность

MUST

// ✅
expect(await screen.findByText('Saved')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('Loaded')).toBeInTheDocument());
await expect(fetchUser()).rejects.toThrow('User not found');

// ❌ Проверка до завершения async-операции
expect(screen.getByText('Saved')).toBeInTheDocument();

// ❌ Promise не awaited
fetchUser();

FORBIDDEN

  • Неявные ожидания
  • Незавершённые промисы

⚠️ userEvent v14 требует setup() только при работе с fake timers. В остальных случаях прямой импорт достаточен.


Таймеры в тестах

FORBIDDEN

// ❌
await new Promise((r) => setTimeout(r, 500));

// ✅
expect(await screen.findByText('Saved')).toBeInTheDocument();

SHOULD

Для управления временем используйте fake timers:

vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));
vi.advanceTimersByTime(1000);
vi.runAllTimers();
vi.useRealTimers();

Assertions

MUST

Проверяем наблюдаемый результат — что изменилось в UI.

// ✅
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Saved successfully')).toBeInTheDocument();

// ⚠️ Допустимо как вспомогательное
expect(mockOnSave).toHaveBeenCalledWith({ name: 'John' });

Моки и стабы

MUST

Мокируются только внешние границы системы.

Что мокироватьИнструмент
Сетевые запросыMSW
Таймерыvi.useFakeTimers()
Дата и времяvi.setSystemTime()
Web Storagevi.stubGlobal()
server.use(http.get('/api/user', () => HttpResponse.json({ name: 'John' })));
vi.setSystemTime(new Date('2024-01-01'));
vi.stubGlobal('localStorage', { getItem: vi.fn(), setItem: vi.fn() });

SHOULD

Чем больше моков — тем ниже доверие к тесту.


Глобальные моки

MAY

Только для зависимостей, несовместимых с jsdom. Мок должен быть минимальным и иметь комментарий. Локальные моки предпочтительнее глобальных.

// IntersectionObserver не поддерживается в jsdom.
// Минимальный мок для компонентов с lazy-load / infinite scroll.
global.IntersectionObserver = class {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
};

Изоляция тестов и cleanup

MUST

beforeEach(() => {
vi.restoreAllMocks(); // включает clearAllMocks — двойной вызов не нужен
localStorage.clear();
});

// RTL вызывает cleanup() автоматически при стандартной настройке Vitest.
// Явный вызов нужен только при нестандартной конфигурации.
afterEach(() => cleanup());

FORBIDDEN

  • Общее состояние без явной инициализации
  • Тесты, зависящие от порядка выполнения

Cleanup побочных эффектов

MUST

it('removes event listener on unmount', () => {
const addSpy = vi.spyOn(window, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');

const { unmount } = render(<ResizeAwareComponent />);
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));

unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});

Кастомные хуки

MUST

Используйте renderHook из RTL — не рендерьте обёрточные компоненты вручную.

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

it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});

Server Components (Next.js App Router)

SHOULD

Тестируйте как асинхронные функции:

it('renders user name from server', async () => {
const component = await UserCard({ userId: '1' });
expect(render(component).getByText('John Doe')).toBeInTheDocument();
});

MAY

Для полного рендера App Router страниц используйте Playwright — RTL не поддерживает серверный контекст Next.js.


Недетерминированные тесты

FORBIDDEN

// ❌ Сломается через год
expect(getAge(birthDate)).toBe(30);

// ✅
vi.setSystemTime(new Date('2024-01-01'));
expect(getAge(new Date('1994-01-01'))).toBe(30);

Недетерминированность — основная причина flaky-тестов и нестабильного CI.


Console в тестах

MUST

beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation((msg) => {
throw new Error(`Unexpected console.error: ${msg}`);
});
vi.spyOn(console, 'warn').mockImplementation((msg) => {
throw new Error(`Unexpected console.warn: ${msg}`);
});
});

MAY

Точечное подавление допустимо, если warning известен и задокументирован:

it('handles known Next.js hydration warning', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<ComponentWithHydration />);
spy.mockRestore();
});

Fixtures для интеграционных тестов

SHOULD

Для сложных API-ответов используйте fixtures вместо inline JSON. Fixtures должны отражать реальные API-контракты (OpenAPI / backend типы).

// __fixtures__/user.json
{ "id": "123", "name": "John Doe", "email": "[email protected]" }

// в тесте
import userFixture from './__fixtures__/user.json';
server.use(http.get('/api/user', () => HttpResponse.json(userFixture)));

Snapshot тесты

MAY

Только как дополнение к явным assertions. Предпочитайте toMatchInlineSnapshot — snapshot виден прямо в коде теста.

// ❌ Только snapshot
expect(container).toMatchSnapshot();

// ✅ Явные проверки + inline snapshot
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByTestId('user-profile')).toMatchInlineSnapshot(`...`);

Flaky тесты и карантин

MUST

Нестабильный тест:

  1. Исправить — найти и устранить причину
  2. Перенести на более низкий уровень — возможно, неправильно выбран уровень
  3. Отправить в карантин — с комментарием и тикетом
// ⚠️ Карантин — TICKET-123
it.skip('shows tooltip on hover (flaky in CI)', () => { ... });

Vitest — специфичные рекомендации

toBe vs toEqual

expect(count).toBe(1); // ✅ примитивы
expect(user).toEqual({ name: 'John', age: 30 }); // ✅ объекты и массивы
expect(count).toEqual(1); // ❌ избыточно

Специализированные matchers

// ✅
expect(items).toHaveLength(2);
expect(user).toHaveProperty('email', '[email protected]');
expect(text).toContain('Hello');

// ❌
expect(items.length).toBe(2);
expect(text.includes('Hello')).toBe(true);

Избегайте слабых матчеров

expect(value).toBeFalsy(); // ❌ null, false, 0 — все falsy
expect(result).toBeDefined(); // ❌ пропускает null

expect(value).toBe(false); // ✅
expect(result).not.toBeNull(); // ✅

Не используйте setImmediate

await new Promise(setImmediate); // ❌ нет в jsdom
vi.runAllTimers(); // ✅

Чеклист качественных assertions

СвойствоОписание
СтрогиеПроверяют конкретное значение, а не «что-то truthy»
ЯвныеПонятны без чтения кода теста
ЧитаемыеИспользуют специализированные matchers
УстойчивыеНе проходят случайно (нет false-positive)