Пошук уроків, статей та іншого контенту
Поєднаєте компоненти, маршрутизацію та стан, щоб перевіряти наскрізні користувацькі сценарії в межах застосунку.
Інтеграційний тест перевіряє не один компонент ізольовано, а взаємодію кількох частин застосунку:
компонентів інтерфейсу;
спільного стану;
маршрутизації;
обробників подій;
переходів між сторінками.
Мета такого тесту — відтворити реальний користувацький сценарій і перевірити результат, який бачить користувач.
Наприклад:
Користувач відкриває сторінку товарів.
Додає товар до кошика.
Лічильник кошика оновлюється.
Користувач переходить на сторінку кошика.
Доданий товар відображається на новому маршруті.
Цей сценарій охоплює одразу кілька частин застосунку, тому його недоцільно перевіряти лише юніт-тестами окремих функцій.
Юніт-тест зазвичай перевіряє одну невелику одиницю:
expect(addItem([], product)).toEqual([product]);Інтеграційний тест перевіряє поведінку через інтерфейс:
await user.click(screen.getByRole("button", {
name: "Додати чашку до кошика",
}));У другому випадку тест не знає, як саме реалізовано стан. Йому важливо, що після дії користувач бачить правильний результат.
Інтеграційні тести корисні, коли потрібно перевірити:
чи правильно компонент використовує контекст або інше сховище стану;
чи змінюється інтерфейс після дії користувача;
чи працює перехід на інший маршрут;
чи передається стан між різними сторінками;
чи працює важливий сценарій так, як очікує користувач.
Для прикладу використаємо:
Vitest як тестовий раннер;
React Testing Library для рендерингу компонентів;
@testing-library/user-event для дій користувача;
MemoryRouter для тестування маршрутизації в пам’яті.
Встановіть залежності:
npm install react-router-dom
npm install --save-dev vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domДля Vite конфігурація може мати такий вигляд:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.js",
},
});Підключіть додаткові матчери Testing Library:
// src/test/setup.js
import "@testing-library/jest-dom/vitest";У package.json додайте команду:
{
"scripts": {
"test": "vitest"
}
}Нехай застосунок має сторінку товарів і сторінку кошика. Спільний стан кошика зберігається в React Context.
// src/App.jsx
import { createContext, useContext, useReducer } from "react";
import {
Link,
MemoryRouter,
Route,
Routes,
} from "react-router-dom";
const products = [
{
id: 1,
name: "Чашка",
},
];
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "add":
return {
...state,
items: [...state.items, action.product],
};
default:
return state;
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
});
function addItem(product) {
dispatch({
type: "add",
product,
});
}
return (
<CartContext.Provider
value={{
items: state.items,
addItem,
}}
>
{children}
</CartContext.Provider>
);
}
function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart має використовуватися всередині CartProvider");
}
return context;
}
function Navigation() {
const { items } = useCart();
return (
<nav aria-label="Головна навігація">
<Link to="/">Товари</Link>{" "}
<Link to="/cart">Кошик ({items.length})</Link>
</nav>
);
}
function ProductsPage() {
const { addItem } = useCart();
return (
<main>
<h1>Товари</h1>
{products.map((product) => (
<article key={product.id}>
<h2>{product.name}</h2>
<button onClick={() => addItem(product)}>
Додати чашку до кошика
</button>
</article>
))}
</main>
);
}
function CartPage() {
const { items } = useCart();
return (
<main>
<h1>Кошик</h1>
{items.length === 0 ? (
<p>Кошик порожній</p>
) : (
<ul>
{items.map((item, index) => (
<li key={`${item.id}-${index}`}>{item.name}</li>
))}
</ul>
)}
</main>
);
}
export function App() {
return (
<>
<Navigation />
<Routes>
<Route path="/" element={<ProductsPage />} />
<Route path="/cart" element={<CartPage />} />
</Routes>
</>
);
}
export function TestApp({ initialEntries = ["/"] }) {
return (
<MemoryRouter initialEntries={initialEntries}>
<CartProvider>
<App />
</CartProvider>
</MemoryRouter>
);
}MemoryRouter зберігає історію переходів у пам’яті. Це зручно для тестів, оскільки тесту не потрібен реальний браузерний URL.
CartProvider розташований вище за маршрути, тому обидві сторінки отримують доступ до одного стану кошика.
Створимо тест, який перевіряє повний сценарій додавання товару та переходу до кошика:
// src/App.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { TestApp } from "./App";
describe("робота кошика", () => {
it("додає товар і показує його після переходу до кошика", async () => {
const user = userEvent.setup();
render(<TestApp />);
expect(
screen.getByRole("heading", { name: "Товари" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", {
name: "Додати чашку до кошика",
}),
);
expect(
screen.getByRole("link", { name: "Кошик (1)" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("link", { name: "Кошик (1)" }),
);
expect(
await screen.findByRole("heading", { name: "Кошик" }),
).toBeInTheDocument();
expect(screen.getByRole("listitem", { name: "Чашка" }))
.toBeInTheDocument();
});
});У цьому тесті перевіряються всі основні частини сценарію:
TestApp створює маршрутизатор і провайдер стану.
Застосунок відкривається на маршруті /.
Тест натискає кнопку через userEvent.
Обробник кнопки змінює стан Context.
Навігація показує оновлений лічильник.
Клік по посиланню змінює маршрут на /cart.
Сторінка кошика читає той самий стан.
Доданий товар з’являється в інтерфейсі.
Це інтеграційний тест, тому він не викликає cartReducer напряму і не перевіряє внутрішні поля Context. Уся взаємодія відбувається через видимий інтерфейс.
MemoryRouter може починати роботу з будь-якого маршруту:
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TestApp } from "./App";
describe("маршрути", () => {
it("відкриває сторінку кошика за прямим маршрутом", () => {
render(<TestApp initialEntries={["/cart"]} />);
expect(
screen.getByRole("heading", { name: "Кошик" }),
).toBeInTheDocument();
expect(screen.getByText("Кошик порожній"))
.toBeInTheDocument();
});
});Такий тест перевіряє, що:
маршрут /cart зареєстрований;
за ним відображається правильна сторінка;
початковий стан провайдера створюється коректно;
порожній кошик має очікуваний вигляд.
Інтеграційні тести повинні перевіряти стан опосередковано — через те, як він впливає на інтерфейс.
Добре:
expect(
screen.getByRole("link", { name: "Кошик (1)" }),
).toBeInTheDocument();Гірше:
expect(cartContext.items).toHaveLength(1);Другий варіант прив’язує тест до внутрішньої реалізації. Якщо Context замінять на Redux, Zustand або інший механізм, поведінка застосунку може залишитися тією самою, але тест доведеться переписати.
Користувача цікавить не те, де зберігається товар, а те, що:
лічильник змінився;
товар доступний у кошику;
правильна сторінка відкрилася.
userEvent замість ручного виклику обробниківДля інтеграційних сценаріїв використовуйте userEvent:
const user = userEvent.setup();
await user.click(button);
await user.type(input, "Чашка");
await user.tab();Це ближче до реальної поведінки користувача, ніж прямий виклик функції або синтетичне змінення стану.
Для асинхронних дій використовуйте await. Навіть якщо конкретна дія зараз виконується синхронно, такий стиль залишається стабільним, коли в компоненті з’явиться асинхронна логіка.
Запити через ролі допомагають тестувати інтерфейс так, як його сприймають користувачі та допоміжні технології:
screen.getByRole("button", {
name: "Додати чашку до кошика",
});
screen.getByRole("link", {
name: "Кошик (1)",
});
screen.getByRole("heading", {
name: "Кошик",
});Переваги такого підходу:
тест перевіряє доступний інтерфейс;
селектори не залежать від CSS-класів;
зміни структури HTML рідше ламають тест;
назва елемента одночасно описує очікувану поведінку.
Якщо елемент не має зрозумілої доступної назви, це може бути проблемою самого інтерфейсу, а не лише тесту.
Кожен тест повинен отримувати чистий стан. У прикладі це забезпечується тим, що TestApp рендериться заново в кожному тесті:
it("показує порожній кошик", () => {
render(<TestApp initialEntries={["/cart"]} />);
expect(screen.getByText("Кошик порожній"))
.toBeInTheDocument();
});
it("показує доданий товар", async () => {
const user = userEvent.setup();
render(<TestApp />);
await user.click(
screen.getByRole("button", {
name: "Додати чашку до кошика",
}),
);
await user.click(
screen.getByRole("link", { name: "Кошик (1)" }),
);
expect(screen.getByText("Чашка"))
.toBeInTheDocument();
});Не покладайтеся на стан, залишений попереднім тестом. Кожен сценарій має бути зрозумілим і самодостатнім.
getBy, findBy і queryByОсновні запити мають різне призначення:
getBy... — елемент уже має бути в DOM, інакше тест одразу завершується помилкою;
findBy... — елемент може з’явитися асинхронно, метод повертає Promise;
queryBy... — зручно перевіряти відсутність елемента, не створюючи помилку пошуку.
Приклади:
expect(screen.getByRole("heading", {
name: "Товари",
})).toBeInTheDocument();
expect(await screen.findByText("Замовлення створено"))
.toBeInTheDocument();
expect(screen.queryByText("Помилка"))
.not.toBeInTheDocument();Не використовуйте queryBy для елемента, який обов’язково має існувати. У такому випадку getBy дасть чіткіше повідомлення про помилку.
Корисно ділити сценарій на три частини:
Підготуйте користувача та відрендерте застосунок:
const user = userEvent.setup();
render(<TestApp />);Виконайте дії користувача:
await user.click(
screen.getByRole("button", {
name: "Додати чашку до кошика",
}),
);
await user.click(
screen.getByRole("link", {
name: "Кошик (1)",
}),
);Перевірте видимий результат:
expect(
await screen.findByRole("heading", {
name: "Кошик",
}),
).toBeInTheDocument();
expect(screen.getByText("Чашка"))
.toBeInTheDocument();Такий поділ допомагає швидко зрозуміти, що саме робить тест.
Не перевіряйте напряму:
значення внутрішнього useState;
виклик конкретного dispatch;
назви внутрішніх функцій;
CSS-класи замість поведінки;
структуру компонентів, якщо вона не впливає на користувача.
Перевіряйте результат взаємодії: текст, роль, доступність кнопки, відкритий маршрут або повідомлення про стан.
BrowserRouter у тестіBrowserRouter працює з реальною адресою браузера, тому для компонентного та інтеграційного тестування він часто створює зайву залежність від середовища.
Для перевірки переходів усередині тесту використовуйте MemoryRouter:
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>Недостатньо перевірити, що натискання змінило URL. Важливо перевірити, що на новому маршруті відображається правильний контент:
await user.click(
screen.getByRole("link", { name: "Кошик (1)" }),
);
expect(
await screen.findByRole("heading", { name: "Кошик" }),
).toBeInTheDocument();Якщо провайдер або сховище створюються поза тестом і повторно використовуються, один тест може вплинути на інший.
Створюйте новий провайдер під час кожного render або використовуйте фабрику тестового оточення.
Один сценарій не повинен перевіряти всі можливі варіанти застосунку. Краще мати кілька коротких тестів:
додавання товару;
відображення порожнього кошика;
перехід на сторінку кошика;
відображення товару після переходу.
Так тести легше читати та підтримувати.
Інтеграційні тести React-застосунків перевіряють взаємодію компонентів, маршрутизації та спільного стану через реальні дії користувача.
Основні принципи:
використовуйте MemoryRouter для маршрутів у тестах;
обгортайте застосунок потрібними провайдерами;
виконуйте дії через userEvent;
шукайте елементи за ролями та доступними назвами;
перевіряйте видимий результат, а не внутрішню реалізацію;
ізолюйте стан кожного тесту;
описуйте сценарій у послідовності «підготовка — дія — перевірка».