nmk

Лекція №15 (2 години). Тестування React-додатків: Забезпечення якості та стабільності систем

План лекції

  1. Піраміда тестування: Unit, Integration та End-to-End (E2E) тести.
  2. Інфраструктура тестування: Test Runner (Jest / Vitest) та Assertion бібліотеки.
  3. React Testing Library (RTL): Парадигма тестування поведінки, а не імплементації.
  4. Анатомія тесту компонента: Рендеринг, Запити (Queries) та Асерції (Assertions).
  5. Емуляція взаємодії користувача: fireEvent проти @testing-library/user-event.
  6. Техніки ізоляції: Використання заглушок (Mocking) для API та залежностей.
  7. E2E тестування (Cypress / Playwright) та інтеграція в CI/CD пайплайни.

Перелік умовних скорочень

Вступ

Важливість тестування для забезпечення стабільності систем. Вивчення Jest та React Testing Library для тестування компонентів з точки зору користувача. Огляд інструментів для наскрізного тестування (Playwright, Cypress) та їх роль у CI/CD процесах.

Програмне забезпечення будь-якого рівня без покриття автоматизованими тестами вважається “спадщиною” (Legacy-кодом) вже в момент його написання. У сучасному Enterprise-середовищі зміна хоча б одного рядка коду може призвести до ефекту метелика, обваливши функціональність у непов’язаних модулях (Регресія). Ручне тестування (QA-інженерами) кожної гілки логіки під час щоденних релізів фізично неможливе. Тому інженери 4-го курсу зобов’язані володіти методологією створення автоматизованих “запобіжників” — тестів. У React-екосистемі наразі домінує симбіоз інструментів Jest (або Vitest) та React Testing Library (RTL). Дана лекція розкриває архітектурні принципи написання тестів, які орієнтуються не на внутрішній код, а на реальний користувацький досвід (UX).


1. Піраміда тестування в Frontend архітектурі

Індустрія програмної інженерії поділяє тести на три основні категорії (“Піраміда тестування”, Мартін Фаулер):

  1. Unit (Модульні) тести (70%): Тестують ізольовані чисті функції, хуки або максимально прості компоненти (напр., функція formatDate або кнопка <Button/>). Виконуються за мілісекунди.
  2. Integration (Інтеграційні) тести (20%): Тестують взаємодію кількох компонентів або компонента з провайдером стану (напр., чи закривається <Modal/>, якщо в ньому натиснути на <CloseButton/>).
  3. E2E (Наскрізні) тести (10%): Запускають реальний браузер (Chrome/Webkit), піднімають реальний бекенд і симулюють реальні кліки користувача на сторінці. Найповільніші (займають хвилини), але дають найбільшу гарантію працездатності.

У цій лекції ми сфокусуємося на Модульному та Інтеграційному рівнях.


2. Інфраструктура: Test Runner (Jest)

Для запуску тестів у JavaScript потрібне спеціальне середовище виконання (Test Runner), яке надає глобальні змінні типу describe, it, test, expect.

Найпопулярнішими є Jest (стандарт де-факто для React) та Vitest (швидка сучасна альтернатива для проектів на базі збирача Vite).

Анатомія базового тесту на Jest:

// math.js
export const sum = (a, b) => a + b;

// math.test.js
import { sum } from "./math";

// Блок об'єднання тестів (Test Suite)
describe("Математичний модуль", () => {
  // Конкретний тестовий випадок (Test Case)
  it("повинен коректно додавати два додатні числа", () => {
    // 1. Arrange (Підготовка даних)
    const a = 5,
      b = 10;
    // 2. Act (Виконання)
    const result = sum(a, b);
    // 3. Assert (Перевірка очікувань - Assertion)
    expect(result).toBe(15);
  });
});

Якщо sum поверне 16, Jest викине червону помилку в консоль терміналу, і CI/CD сервер відхилить злиття коду (Git Push відхилено).


3. React Testing Library: Тестування поведінки

Історично компоненти тестували бібліотекою Enzyme, яка перевіряла “імплементацію” (чи викликався setState, яке ім’я має клас, скільки <div/> всередині). Це була погана інженерна практика, оскільки будь-який рефакторинг змушував переписувати тести.

Творець RTL, Kent C. Dodds, ввів золоте правило тестування UI:

“Чим більше ваші тести наближені до того, як програму використовує кінцевий користувач, тим більше впевненості вони вам дають.”

Користувачу все одно, чи використовуєте ви useState, чи useReducer, або який CSS-клас у кнопки. Користувач шукає текст “Надіслати” і клікає по ньому. Саме так працює RTL — він віртуально (за допомогою JSDOM) рендерить компонент і дозволяє шукати елементи як це робить “сліпа людина” (через ARIA-ролі, тексти та плейсхолдери).


4. Анатомія тесту компонента: Queries (Запити)

У RTL є три категорії функцій для пошуку елементів у відрендереному віртуальному DOM:

Пріоритет пошуку має спиратися на доступність (Accessibility / a11y): Найкраще шукати через getByRole, гірше — через ідентифікатори getByTestId (спеціальні атрибути data-testid).

import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom"; // Розширює expect() методами типу toBeInTheDocument()
import Greeting from "./Greeting";

describe("<Greeting />", () => {
  it("відмальовує привітання для користувача", () => {
    // 1. Рендеримо компонент у віртуальний JSDOM
    render(<Greeting name="Іван" />);

    // 2. Шукаємо елемент заголовка h1, h2, h3...
    // Користувач бачить текст, тому ми перевіряємо як користувач
    const heading = screen.getByRole("heading", { level: 1 });

    // 3. Асерція
    expect(heading).toHaveTextContent("Привіт, Іван!");
    expect(heading).toBeInTheDocument();
  });
});

5. Емуляція взаємодії користувача: Скептицизм до fireEvent

Щоб перевірити інтерактивність (кліки, ввід тексту), ми повинні імітувати фізичні дії.

У RTL є вбудована утиліта fireEvent, але вона відправляє лише одну синтетичну подію браузеру. Наприклад, fireEvent.change(input, { target: { value: 'a' } }) спрацює, але в реальному браузері, коли користувач натискає на клавіатуру, насправді генеруються події: keyDown, keyPress, зміна тексту, input, keyUp.

Для справжньої (Enterprise) імітації використовують бібліотеку @testing-library/user-event.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginForm from "./LoginForm";

it("повинен зробити кнопку активною після вводу пароля", async () => {
  render(<LoginForm />);

  // Ініціалізуємо імітатор користувача
  const user = userEvent.setup();

  const passwordInput = screen.getByLabelText("Пароль");
  const submitButton = screen.getByRole("button", { name: "Увійти" });

  // Перевіряємо початковий стан
  expect(submitButton).toBeDisabled();

  // Симулюємо РЕАЛЬНИЙ ввід тексту на клавіатурі (з усіма keyDown подіями)
  await user.type(passwordInput, "admin123");

  // Перевіряємо, чи компонент відреагував правильно
  expect(submitButton).toBeEnabled();

  // Симулюємо клік
  await user.click(submitButton);
});

6. Техніки ізоляції: Заглушки (Mocking)

Головне правило Unit/Integration тестів: Тести ніколи не повинні ходити в інтернет до справжнього бази даних або API. Вони мають бути повністю ізольованими, детермінованими і швидкими.

Якщо ваш компонент робить axios.get('/users'), ви зобов’язані зробити Mock (Заглушку) цієї функції — підмінити її на вашу “фейкову” функцію, яка миттєво повертає наперед визначені (захардкоджені) дані. Це робиться за допомогою jest.mock.

import { render, screen } from "@testing-library/react";
import axios from "axios";
import UserList from "./UserList";

// Перехоплюємо весь модуль axios
jest.mock("axios");

it("повинен вивести список користувачів з API", async () => {
  // Налаштовуємо нашу заглушку: "Коли хтось викличе get, поверни цей Promise(resolve)"
  const mockData = { data: [{ id: 1, name: "Alice" }] };
  axios.get.mockResolvedValueOnce(mockData);

  render(<UserList />);

  // Оскільки запит "асинхронний", елементи не з'являться миттєво.
  // Використовуємо .findBy... (чекаємо до появи в DOM)
  const listItem = await screen.findByText("Alice");

  expect(listItem).toBeInTheDocument();

  // Додаткова архітектурна асерція: Перевіряємо, чи axios був викликаний правильним роутом!
  expect(axios.get).toHaveBeenCalledWith("/users");
  expect(axios.get).toHaveBeenCalledTimes(1);
});

В сучасній розробці також популярним є підхід MSW (Mock Service Worker) — інстумент, який створює віртуальний сервер прямо в браузерері/Node.js і перехоплює HTTP-запити на рівні мережевого стеку, а не шляхом ламання модулів через jest.mock.


7. E2E тестування (Playwright) та CI/CD

Після того, як сотні Unit-тестів довели міцність окремих “цеглин”, архітекторам потрібно переконатися, що весь “будинок” не розвалиться.

E2E-тестування (End-to-End) — це написання скриптів, які керують справжнім браузером (через веб-драйвери). В екосистемі наразі лідирує Playwright (від Microsoft) та класичний Cypress.

В E2E тесті ми буквально відкриваємо реальний URL додатку, який вже “крутиться” на тестовому сервері.

// Приклад тесту на Playwright (file.spec.js)
import { test, expect } from "@playwright/test";

test("головний потік покупки товару", async ({ page }) => {
  // 1. Заходимо на розгорнутий тестовий сервер
  await page.goto("http://localhost:3000");

  // 2. Сценарій клієнта
  await page.click("text=Каталог");
  await page.click('button:has-text("Додати до кошика")');
  await page.click('a[aria-label="Кошик"]');

  // 3. Асерція
  await expect(page.locator(".cart-total")).toHaveText("1000 грн");
});

Інтеграція в CI/CD (Continuous Integration): E2E тести і Jest тести вбудовуються у пайплайни розробки (напр., GitHub Actions). Створюється правило репозиторію: розробник пише код -> створює Pull Request -> Спрацьовує CI сервер, який в хмарі запускає всі 1000 тестів. Поки статус (Check) не стане зеленим (PASSED), кнопка “Merge into Main Branch” заблокована. Це і є запорукою інженерної стабільності.


Висновки

  1. Піраміда тестування визначає ієрархію авто-перевірок коду, балансуючи між швидкістю виконання (Unit-тести на базі Jest/Vitest) та рівнем гарантії загальної бізнес логіки (E2E-тести в Playwright).
  2. Історичний зсув парадигми тестування (React Testing Library) змусив інженерів тестувати поведінку, а не реалізацію. Перевірка наявності CSS-класу чи стану user: null є хрупким (Brittle) тестом, що ламається при рефакторингу. Перевірка наявності кнопки “Login” за текстом або ARIA-роллю — надійний тест.
  3. Утиліта user-event дозволяє асинхронно відтворювати всю послідовність нативних DOM-подій (event bubbling), які супроводжують натискання клавіш та миші, на відміну від обрізаного fireEvent.
  4. Вирішення проблеми зв’язаності тестів із зовнішнім середовищем (запитів в Інтернет до БД, виклик таймерів, дат) вимагає використання системи Моків (Mocks) — ізолюючих ін’єкцій, які роблять середовище повністю контрольованим для тестувальника коду.
  5. Розгортання E2E тестів у системах CI/CD (GitHub Actions, GitLab CI/CD) є фактичним інженерним бар’єром (Quality Gate), що автоматично не пропускає пошкоджений або регресійний код до продакшн-серверів, вимикаючи людський фактор (помилки ручного тестування).

Джерела

  1. Офіційна документація React Testing Library. URL: https://testing-library.com/docs/react-testing-library/intro/
  2. Офіційна документація Jest. URL: https://jestjs.io/
  3. “Write tests. Not too many. Mostly integration.” — Kent C. Dodds (Автор RTL). URL: https://kentcdodds.com/blog/write-tests
  4. “Common mistakes with React Testing Library”. Kent C. Dodds. URL: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
  5. Playwright Documentation. URL: https://playwright.dev/
  6. Cypress Documentation. URL: https://docs.cypress.io/
  7. Martin Fowler: “The Practical Test Pyramid”. URL: https://martinfowler.com/articles/practical-test-pyramid.html

Запитання для самоперевірки

  1. Згідно з парадигмою Kent C. Dodds, чому перевірка в тесті типу expect(wrapper.state('count')).toBe(1) (тестування внутрішнього стейту) є серйозним антипатерном і порушенням філософії надійності?
  2. В чому принципова різниця (щодо архітектури обробки подій) між використанням методу нативної відправки fireEvent.change() та симуляцією через пакет @testing-library/user-event? Наведіть приклад, коли використання лише першого варіанту “ховає” баг від тестувальника.
  3. Поясніть сутність процесу Mocking (заглушки) зовнішніх залежностей. Якщо компонент отримує прогноз погоди через axios, як саме ми повинні поводитися з цим викликом при написанні Unit-тесту компонента?
  4. Проаналізуйте різницю між Queries в методах пошуку RTL: за якої обставини архітектурно правильно застосувати метод queryByRole замість getByRole? (Порада: розгляньте сценарій умовного рендерингу відсутності елемента).
  5. Чому розробники не можуть покрити абсолютно весь код (100% покриття Coverage) лише E2E-тестами (End-to-End на базі Cypress/Playwright), оминаючи Unit-тести, якщо E2E-тести дають найдостовірнішу перевірку реалізації? Яке явище заважає цьому у безперервному розгортанні (CI/CD)?