Пошук уроків, статей та іншого контенту
Побудуєте стійкий набір тестів із повторним використанням налаштувань, фабрик даних і допоміжних функцій.
З часом тестовий набір часто стає складнішим за сам компонент:
у кожному тесті повторюється однаковий рендеринг;
тестові об’єкти створюються вручну;
зміна структури пропсів ламає десятки тестів;
глобальні моки впливають на сусідні тести;
незрозуміло, які налаштування потрібні конкретному тесту.
Стійкий тестовий набір має чіткі межі відповідальності:
загальне налаштування — один раз для всього проєкту;
допоміжний рендеринг — спільні провайдери й типові опції;
фабрики даних — створення валідних тестових об’єктів;
локальні допоміжні функції — лише для повторюваної логіки конкретної предметної області;
тест — опис поведінки, а не деталей підготовки.
При цьому повторне використання не повинно приховувати суть тесту. Якщо читач не може зрозуміти сценарій, абстракція стала надмірною.
Для React-проєкту з Vitest і React Testing Library зручно виділити окрему директорію для тестової інфраструктури:
src/
app/
providers.tsx
components/
UserCard.tsx
UserCard.test.tsx
test/
setup.ts
render.tsx
factories/
user.ts
helpers/
deferred.tsПризначення файлів:
setup.ts — глобальне налаштування тестового середовища;
render.tsx — власний render зі спільними провайдерами;
factories/ — фабрики тестових даних;
helpers/ — невеликі функції, які не належать конкретному компоненту;
*.test.tsx — тести поруч із кодом, який вони перевіряють.
Не варто складати всі функції в один універсальний файл test-utils.ts. Такий файл швидко перетворюється на неструктурований набір залежностей.
Файл setup.ts виконується перед тестами. У ньому слід розміщувати лише справді глобальні налаштування.
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});Підключіть файл у конфігурації Vitest:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});vi.restoreAllMocks() відновлює реалізації замокованих функцій після кожного тесту. Це не скасовує ручне очищення стану, який зберігається у власних модулях або глобальних об’єктах.
Глобальне налаштування не повинно:
створювати конкретного користувача для всіх тестів;
вмикати моки для всього застосунку без необхідності;
приховувати важливі залежності тесту;
змінювати поведінку браузера більше, ніж потрібно.
Якщо компоненти використовують спільні React Context, повторення провайдерів у кожному тесті створює зайвий шум.
Нехай застосунок має провайдер локалі:
// src/app/providers.tsx
import {
createContext,
type PropsWithChildren,
useContext,
} from 'react';
const LocaleContext = createContext('uk');
export function AppProviders({ children }: PropsWithChildren) {
return (
<LocaleContext.Provider value="uk">
{children}
</LocaleContext.Provider>
);
}
export function useLocale() {
return useContext(LocaleContext);
}Створімо обгортку над render:
// src/test/render.tsx
import type { ReactElement, PropsWithChildren } from 'react';
import {
render,
type RenderOptions,
} from '@testing-library/react';
import { AppProviders } from '../app/providers';
type AppRenderOptions = Omit<RenderOptions, 'wrapper'>;
export function renderWithApp(
ui: ReactElement,
options?: AppRenderOptions,
) {
function Wrapper({ children }: PropsWithChildren) {
return <AppProviders>{children}</AppProviders>;
}
return render(ui, {
wrapper: Wrapper,
...options,
});
}Тепер тест не знає деталей побудови дерева провайдерів:
import { renderWithApp } from '../test/render';Не кожен компонент потребує всіх провайдерів застосунку. Якщо обгортка автоматично додає маршрутизатор, клієнт запитів, тему та інші залежності, тест може стати важчим для розуміння.
Для ізольованого компонента краще використати звичайний render. Власний рендеринг доречний тоді, коли:
провайдер потрібен більшості тестів;
його налаштування однакове;
компонент справді перевіряється в контексті цього провайдера.
Якщо окремому тесту потрібні спеціальні значення контексту, передбачте явну опцію або використайте локальну обгортку. Не змінюйте глобальне налаштування заради одного сценарію.
Ручне створення об’єктів у кожному тесті ускладнює підтримку:
const user = {
id: 'user-1',
name: 'Олена Коваль',
role: 'member',
active: true,
};Коли до User додається обов’язкове поле, доводиться редагувати всі такі об’єкти. Фабрика централізує значення за замовчуванням і дозволяє змінювати лише важливі для сценарію поля.
// src/test/factories/user.ts
export type User = {
id: string;
name: string;
role: 'admin' | 'member';
active: boolean;
};
export function makeUser(
overrides: Partial<User> = {},
): User {
return {
id: 'user-1',
name: 'Олена Коваль',
role: 'member',
active: true,
...overrides,
};
}Тест тепер явно показує, яка властивість важлива:
const inactiveUser = makeUser({ active: false });
const administrator = makeUser({ role: 'admin' });Фабрика повинна:
повертати валідний об’єкт за замовчуванням;
приймати часткові перевизначення;
не повертати спільний змінюваний об’єкт;
бути детермінованою;
не залежати від порядку запуску тестів.
Фабрика може створювати вкладені об’єкти, але їх також потрібно копіювати:
type Project = {
id: string;
name: string;
owner: User;
tags: string[];
};
export function makeProject(
overrides: Partial<Project> = {},
): Project {
return {
id: 'project-1',
name: 'Портал клієнтів',
owner: makeUser(),
tags: ['react'],
...overrides,
};
}Якщо тест змінює tags, він не повинен випадково змінити дані іншого тесту. Саме тому фабрика створює новий масив під час кожного виклику.
Фабрика не повинна перетворювати тест на набір незрозумілих викликів:
renderWithApp(
<UserCard user={makeUser()} onArchive={archiveUser} />,
);Якщо сценарій перевіряє неактивного адміністратора, це має бути видно:
const user = makeUser({
role: 'admin',
active: false,
});
renderWithApp(
<UserCard user={user} onArchive={archiveUser} />,
);Допоміжна функція виправдана, якщо вона:
використовується в кількох тестах;
має одну чітку відповідальність;
не приховує основну дію сценарію;
не містить власних очікувань, якщо її призначення — лише підготовка.
Наприклад, функція для створення відкладеної обіцянки допомагає перевіряти стан завантаження:
// src/test/helpers/deferred.ts
export function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return {
promise,
resolve,
reject,
};
}Її не потрібно використовувати для кожного асинхронного тесту. Якщо звичайного mockResolvedValue достатньо, він буде простішим і зрозумілішим.
Розглянемо компонент, який відображає користувача та викликає onArchive після натискання кнопки.
// src/components/UserCard.tsx
import { useState } from 'react';
import { useLocale } from '../app/providers';
import type { User } from '../test/factories/user';
type UserCardProps = {
user: User;
onArchive: (userId: string) => Promise<void>;
};
export function UserCard({ user, onArchive }: UserCardProps) {
const locale = useLocale();
const [isArchiving, setIsArchiving] = useState(false);
async function handleArchive() {
setIsArchiving(true);
try {
await onArchive(user.id);
} finally {
setIsArchiving(false);
}
}
return (
<article aria-label={user.name}>
<h2>{user.name}</h2>
<p>
{locale === 'uk' && user.active ? 'Активний' : 'Неактивний'}
</p>
<button
type="button"
disabled={isArchiving}
onClick={handleArchive}
>
{isArchiving ? 'Архівація…' : 'Архівувати'}
</button>
</article>
);
}У прикладі тип User імпортовано з фабрики лише для стислості. У реальному проєкті тип предметної сутності краще зберігати у виробничому модулі, а фабрика має імпортувати його звідти. Фабрика не повинна бути джерелом типів для основного коду.
Тести компонента:
// src/components/UserCard.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';
import { makeUser } from '../test/factories/user';
import { renderWithApp } from '../test/render';
describe('UserCard', () => {
it('відображає ім’я та статус користувача', () => {
const user = makeUser({ name: 'Марко Бондар' });
renderWithApp(
<UserCard
user={user}
onArchive={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(
screen.getByRole('heading', { name: 'Марко Бондар' }),
).toBeInTheDocument();
expect(screen.getByText('Активний')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Архівувати' }),
).toBeEnabled();
});
it('передає ідентифікатор користувача після архівації', async () => {
const user = userEvent.setup();
const onArchive = vi.fn().mockResolvedValue(undefined);
const testUser = makeUser({ id: 'user-42' });
renderWithApp(
<UserCard user={testUser} onArchive={onArchive} />,
);
await user.click(
screen.getByRole('button', { name: 'Архівувати' }),
);
expect(onArchive).toHaveBeenCalledOnce();
expect(onArchive).toHaveBeenCalledWith('user-42');
});
it('блокує кнопку під час архівації', async () => {
const user = userEvent.setup();
const request = deferred<void>();
const onArchive = vi.fn(() => request.promise);
renderWithApp(
<UserCard
user={makeUser()}
onArchive={onArchive}
/>,
);
const archiveButton = screen.getByRole('button', {
name: 'Архівувати',
});
await user.click(archiveButton);
expect(
screen.getByRole('button', { name: 'Архівація…' }),
).toBeDisabled();
request.resolve();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}Останню функцію в тесті можна винести до src/test/helpers/deferred.ts, якщо вона використовується в кількох файлах. Якщо вона потрібна лише тут, локальне розташування зберігає залежність поруч із тестом.
Тест перевіряє:
доступний для користувача заголовок;
видимий статус;
доступність кнопки;
аргумент callback-функції;
стан компонента під час незавершеної асинхронної операції.
Він не перевіряє внутрішній стан React, назви функцій або структуру компонентів. Це робить його менш чутливим до рефакторингу.
Для простих тестів достатньо локальної підготовки всередині it:
it('показує неактивний статус', () => {
const user = makeUser({ active: false });
renderWithApp(
<UserCard
user={user}
onArchive={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.getByText('Неактивний')).toBeInTheDocument();
});beforeEach варто використовувати лише для справді спільної підготовки:
describe('UserCard', () => {
let onArchive: ReturnType<typeof vi.fn>;
beforeEach(() => {
onArchive = vi.fn().mockResolvedValue(undefined);
});
it('показує активного користувача', () => {
renderWithApp(
<UserCard user={makeUser()} onArchive={onArchive} />,
);
expect(screen.getByText('Активний')).toBeInTheDocument();
});
});Надмірний beforeEach погіршує читабельність. Читачеві доводиться переходити вгору файлом, щоб зрозуміти, звідки взялися дані тесту.
Дані, які відрізняють один сценарій від іншого, краще оголошувати в самому тесті. Спільними можуть бути:
стабільний callback;
незмінна конфігурація;
базова фабрика;
загальний рендеринг.
Сценарій не повинен залежати від того, у якому порядку виконуються інші тести.
Корисні абстракції зазвичай мають малий і стабільний API:
makeUser({ active: false });
renderWithApp(ui);
deferred<void>();Невдала абстракція може виглядати так:
renderUserCard({
name: 'Олена',
archived: false,
clickArchive: true,
});Така функція:
приховує, який компонент рендериться;
приховує користувацьку дію;
може містити очікування;
з часом отримує дедалі більше опцій.
Краще залишити в тесті стандартні інструменти Testing Library, а повторно використовувати лише підготовку:
const user = makeUser({ active: false });
renderWithApp(
<UserCard user={user} onArchive={onArchive} />,
);
await userEvent.setup().click(
screen.getByRole('button', { name: 'Архівувати' }),
);Коли змінюється виробничий код, спочатку визначте, до якого шару належить зміна:
змінилася модель даних — оновіть тип і фабрику;
змінилися спільні провайдери — оновіть renderWithApp;
змінився контракт компонента — оновіть лише його тести;
змінилася поведінка — змініть очікування, а не внутрішні деталі;
змінилася тестова інфраструктура — перевірте ізоляцію всього набору.
Фабрика не повинна приховувати регресію. Якщо нове поле є важливим для певного сценарію, задайте його явно в тесті. Значення за замовчуванням призначені для нейтрального базового випадку.
Після рефакторингу корисно запускати тести:
окремого компонента;
усієї директорії;
повного набору.
Це допомагає відрізнити локальну помилку від проблеми спільного налаштування.
const user = makeUser();
it('перший тест', () => {
user.active = false;
});
it('другий тест', () => {
// Тест залежить від результату першого.
});Створюйте дані всередині тесту або в beforeEach, щоб кожен сценарій отримував новий об’єкт.
Мок, створений на рівні модуля, може зберігати виклики між тестами. Використовуйте vi.clearAllMocks() для очищення історії викликів або vi.restoreAllMocks() для відновлення оригінальних реалізацій залежно від потреби.
Не замінюйте всі моки глобальним очищенням без розуміння різниці:
clearAllMocks очищає виклики;
resetAllMocks також скидає реалізації моків;
restoreAllMocks відновлює оригінальні реалізації для spy.
Якщо кожен тест автоматично отримує багато провайдерів, ізольований компонент може вимагати непотрібних залежностей. Тримайте AppProviders мінімальним або створюйте спеціальні обгортки для окремих груп тестів.
Випадкові імена, ідентифікатори або дати можуть зробити помилки нестабільними. Тестові дані мають бути передбачуваними. Якщо потрібно перевірити унікальність, створюйте різні значення явно.
Функція на кшталт expectUserCardToBeCorrect() ховає очікування й ускладнює повідомлення про помилку. Допоміжні функції повинні готувати дані або виконувати повторювану дію, а значущі очікування краще залишати в тесті.
Не прив’язуйте тести до:
конкретних назв state-змінних;
кількості викликів внутрішніх функцій;
структури компонентів;
CSS-класів, якщо клас не є частиною поведінкового контракту.
Перевіряйте те, що бачить користувач і що є контрактом компонента: ролі, доступні назви, текст, стани елементів і результати дій.
Виділяйте глобальне налаштування, власний рендеринг, фабрики та локальні helpers в окремі шари.
Використовуйте renderWithApp, коли багато компонентів потребують однакових провайдерів.
Створюйте детерміновані фабрики з підтримкою overrides.
Не повертайте зі спільної фабрики змінювані об’єкти або масиви.
Залишайте важливі для сценарію дані видимими в самому тесті.
Ізолюйте моки, стан і тестові об’єкти між тестами.
Повторно використовуйте підготовку, але не приховуйте основну поведінку та очікування.
Тести мають перевіряти зовнішній контракт компонента, а не його внутрішню реалізацію.