Пошук уроків, статей та іншого контенту
Заміните HTTP-запити контрольованими відповідями та перевірите поведінку компонентів без реального бекенду.
Компонент часто залежить від HTTP-запиту:
показує стан завантаження;
відображає дані після успішної відповіді;
показує повідомлення про помилку, якщо запит не вдався.
У тестах не варто покладатися на реальний бекенд. Він може бути недоступним, повільним або повертати різні дані. До того ж тест має перевіряти поведінку компонента, а не роботу сервера.
Мок API — це контрольована заміна HTTP-запиту, яка дозволяє заздалегідь визначити:
яку адресу викликають;
з якими параметрами;
яку відповідь повертає сервер;
чи завершується запит помилкою.
У прикладах використаємо:
React;
React Testing Library;
Vitest;
глобальний fetch.
Створимо компонент UserProfile. Він завантажує користувача після монтування і має три стани:
завантаження;
успішне завантаження;
помилка.
// UserProfile.jsx
import { useEffect, useState } from "react";
export function UserProfile({ userId }) {
const [state, setState] = useState({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({
status: "loading",
data: null,
error: null,
});
fetch(`/api/users/${userId}`)
.then(async (response) => {
if (!response.ok) {
throw new Error("Не вдалося завантажити користувача");
}
return response.json();
})
.then((user) => {
if (!cancelled) {
setState({
status: "success",
data: user,
error: null,
});
}
})
.catch((error) => {
if (!cancelled) {
setState({
status: "error",
data: null,
error,
});
}
});
return () => {
// Не оновлюємо стан після розмонтування компонента
cancelled = true;
};
}, [userId]);
if (state.status === "loading") {
return <p role="status">Завантаження…</p>;
}
if (state.status === "error") {
return <p role="alert">{state.error.message}</p>;
}
return (
<section>
<h1>{state.data.name}</h1>
<p>{state.data.email}</p>
</section>
);
}Компонент не знає, чи є справжній сервер. Він просто викликає fetch і реагує на його результат.
Для прикладу потрібні пакети:
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-domУ конфігурації Vitest потрібно вказати середовище jsdom:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});Для запуску тестів можна додати скрипт у package.json:
{
"scripts": {
"test": "vitest"
}
}fetchVitest дозволяє замінити глобальну функцію за допомогою vi.stubGlobal.
У тесті ми замінюємо справжній fetch на функцію-мок:
vi.stubGlobal("fetch", vi.fn());Тепер виклик fetch не створює HTTP-запит. Натомість тест може вказати, що ця функція має повернути:
globalThis.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 42,
name: "Олена Коваль",
email: "olena@example.com",
}),
});mockResolvedValueOnce описує успішний результат асинхронної функції. Оскільки fetch повертає проміс, цей метод добре підходить для мокування HTTP-відповіді.
// UserProfile.test.jsx
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
describe("UserProfile", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
// Відновлюємо глобальне середовище після кожного тесту
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("завантажує та відображає дані користувача", async () => {
globalThis.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 42,
name: "Олена Коваль",
email: "olena@example.com",
}),
});
render(<UserProfile userId={42} />);
expect(screen.getByRole("status")).toHaveTextContent("Завантаження");
expect(
await screen.findByRole("heading", { name: "Олена Коваль" }),
).toBeInTheDocument();
expect(screen.getByText("olena@example.com")).toBeInTheDocument();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/users/42");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});Тест перевіряє поведінку, яку бачить користувач:
спочатку відображається стан завантаження;
після відповіді з’являється ім’я;
відображається email;
викликається правильний endpoint.
findByRole використовує очікування асинхронного результату. Це важливо, оскільки fetch завершується не під час самого render.
fetch не відхиляє проміс автоматично для статусів 4xx або 5xx. Тому компонент сам перевіряє response.ok і створює помилку.
Цю ситуацію можна змоделювати так:
it("показує помилку, якщо сервер повернув невдалий статус", async () => {
globalThis.fetch.mockResolvedValueOnce({
ok: false,
json: async () => ({}),
});
render(<UserProfile userId={42} />);
expect(
await screen.findByRole("alert"),
).toHaveTextContent("Не вдалося завантажити користувача");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/users/42");
});У цьому тесті метод json фактично не використовується, тому його можна залишити з порожнім об’єктом. Для компонента важливе значення ok: false.
Окремо потрібно перевірити ситуацію, коли сам fetch відхиляє проміс. Наприклад, через відсутність мережі або помилку DNS.
it("показує помилку мережі", async () => {
globalThis.fetch.mockRejectedValueOnce(
new Error("Мережа недоступна"),
);
render(<UserProfile userId={42} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Мережа недоступна",
);
});mockRejectedValueOnce використовується для асинхронної помилки.
Усі сценарії можна зібрати в одному файлі:
// UserProfile.test.jsx
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
describe("UserProfile", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
// Очищуємо мок, щоб тести не впливали один на одного
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("показує стан завантаження", () => {
globalThis.fetch.mockReturnValueOnce(new Promise(() => {}));
render(<UserProfile userId={42} />);
expect(screen.getByRole("status")).toHaveTextContent("Завантаження");
});
it("відображає користувача після успішної відповіді", async () => {
globalThis.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 42,
name: "Олена Коваль",
email: "olena@example.com",
}),
});
render(<UserProfile userId={42} />);
expect(
await screen.findByRole("heading", { name: "Олена Коваль" }),
).toBeInTheDocument();
expect(screen.getByText("olena@example.com")).toBeInTheDocument();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/users/42");
});
it("показує помилку для невдалого HTTP-статусу", async () => {
globalThis.fetch.mockResolvedValueOnce({
ok: false,
json: async () => ({}),
});
render(<UserProfile userId={42} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не вдалося завантажити користувача",
);
});
it("показує мережеву помилку", async () => {
globalThis.fetch.mockRejectedValueOnce(
new Error("Мережа недоступна"),
);
render(<UserProfile userId={42} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Мережа недоступна",
);
});
});Запустити тести можна командою:
npm testМетоди з суфіксом Once задають результати окремих викликів у визначеному порядку:
globalThis.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: "Перша відповідь" }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 2, name: "Друга відповідь" }),
});Це корисно, коли компонент виконує кілька запитів або коли тест перевіряє повторну спробу. Кожен виклик отримає наступну відповідь із черги.
Якщо потрібно однаково відповідати на всі наступні виклики, можна використати mockResolvedValue:
globalThis.fetch.mockResolvedValue({
ok: true,
json: async () => ({
id: 42,
name: "Олена Коваль",
}),
});Якщо компонент передає в fetch не лише URL, перевіряйте також налаштування запиту:
expect(globalThis.fetch).toHaveBeenCalledWith("/api/users/42", {
method: "GET",
headers: {
Accept: "application/json",
},
});Для складніших перевірок можна отримати аргументи конкретного виклику:
const [url, options] = globalThis.fetch.mock.calls[0];
expect(url).toBe("/api/users/42");
expect(options.method).toBe("GET");Перевіряйте лише ті параметри, які є частиною поведінки компонента. Не потрібно дублювати в тесті внутрішню реалізацію, якщо вона не має значення для користувача.
Моки мають бути незалежними в кожному тесті. Інакше кількість викликів або налаштовані відповіді з попереднього тесту можуть вплинути на наступний.
Основні операції:
vi.clearAllMocks() — очищає історію викликів;
vi.resetAllMocks() — очищає історію та налаштовані поведінки;
vi.restoreAllMocks() — відновлює оригінальні реалізації для створених шпигунів;
vi.unstubAllGlobals() — прибирає глобальні заміни, створені через vi.stubGlobal.
Якщо глобальний fetch створюється в beforeEach, після тесту його потрібно прибирати в afterEach.
Тест API-залежного компонента має відповідати на практичні запитання:
Що бачить користувач до завершення запиту?
Що бачить користувач після успішної відповіді?
Що бачить користувач після помилки?
Який endpoint викликається?
Чи не виконується зайвий запит?
Не потрібно перевіряти:
внутрішню назву стану;
кількість викликів setState;
конкретну структуру useEffect;
реалізацію промісів, якщо вона не впливає на поведінку.
Мок має описувати відповідь сервера, а тест — перевіряти результат для користувача.
getBy... для асинхронного результатуНеправильно:
render(<UserProfile userId={42} />);
expect(screen.getByRole("heading")).toBeInTheDocument();Заголовок з’явиться лише після завершення fetch. Використовуйте findBy...:
expect(
await screen.findByRole("heading", { name: "Олена Коваль" }),
).toBeInTheDocument();fetch відхилить проміс для статусу 500fetch зазвичай успішно завершується навіть для HTTP-статусу 500. Він відхиляє проміс переважно для мережевих помилок.
Тому відповідь зі статусом помилки потрібно мокати через:
{
ok: false,
}А мережеву помилку — через:
globalThis.fetch.mockRejectedValueOnce(new Error("Помилка мережі"));Якщо не очищати мок, перевірка toHaveBeenCalledTimes(1) може врахувати виклики з попереднього тесту.
Створюйте та очищуйте мок у beforeEach і afterEach.
Не потрібно замінювати сам UserProfile або його useEffect. Мокуйте лише зовнішню залежність — fetch. Так тест перевірить реальну поведінку компонента в контрольованих умовах.
Мок API замінює реальний HTTP-запит передбачуваною відповіддю.
vi.stubGlobal("fetch", vi.fn()) дозволяє замінити глобальний fetch.
mockResolvedValueOnce моделює успішну відповідь.
mockRejectedValueOnce моделює мережеву помилку.
Для асинхронного UI використовуйте findBy... або waitFor.
Перевіряйте стани завантаження, успіху та помилки.
Очищуйте моки після кожного тесту.
Тестуйте поведінку компонента, а не деталі його внутрішньої реалізації.