Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Тестування

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

10 питань

React Testing Library побудована на принципі: чим більше тести схожі на те, як застосунок використовують, тим більше впевненості вони дають. Тест взаємодіє з компонентом як користувач - бачить текст і кнопки, натискає, вводить, - а не перевіряє стан, props чи внутрішні методи.

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

test('додає товар у кошик', async () => {
  const user = userEvent.setup();
  render(<Product product={{ id: 1, title: 'Чашка', price: 250 }} />);

  await user.click(screen.getByRole('button', { name: 'Додати в кошик' }));

  expect(screen.getByRole('status')).toHaveTextContent('У кошику: 1');
});

Пріоритет запитів (від кращого до гіршого):

  1. getByRole з name - роль доступності (кнопка, посилання, поле, заголовок) і доступна назва. Так елементи знаходять і користувачі зчитувачів екрана;
  2. getByLabelText - поля форми за підписом;
  3. getByPlaceholderText, getByText, getByDisplayValue;
  4. getByAltText, getByTitle;
  5. getByTestId - лише коли інших способів немає.

Чому роль, а не клас чи data-testid:

  • тест не ламається від рефакторингу: змінили розмітку, класи Tailwind, структуру - тест проходить, поки кнопка лишається кнопкою з тим самим текстом;
  • тест перевіряє доступність: якщо кнопку зробили <div onClick>, getByRole('button') її не знайде - і це правильно, бо клавіатурою та зчитувачем екрана її теж не використати;
  • getByLabelText падає, якщо поле не пов'язане з підписом, - ще одна безкоштовна перевірка.

Варіанти запитів:

  • getBy... - елемент має бути, інакше помилка;
  • queryBy... - повертає null, для перевірки відсутності (expect(screen.queryByText('Помилка')).not.toBeInTheDocument());
  • findBy... - чекає на появу (асинхронно);
  • getAllBy... тощо - кілька елементів.

screen замість деструктуризації результату render - коротше й не треба оновлювати деструктуризацію.

Налагодження: screen.debug() друкує поточний DOM, а screen.logTestingPlaygroundURL() дає посилання, де можна підібрати найкращий запит для елемента.

Докладніше в документації: Testing Library: пріоритет запитів

Обидва викликають події в тесті, але на різному рівні.

fireEvent відправляє одну конкретну подію DOM:

fireEvent.change(input, { target: { value: 'Оля' } });
fireEvent.click(button);

change одразу встановлює значення - без фокусу, без натискань клавіш, без подій keydown/input/keyup для кожної літери.

userEvent імітує дії користувача - послідовність подій, яку згенерував би браузер:

import userEvent from '@testing-library/user-event';

test('пошук', async () => {
  const user = userEvent.setup();
  render(<Search />);

  await user.type(screen.getByRole('searchbox'), 'laravel');
  await user.keyboard('{Enter}');
  await user.click(screen.getByRole('button', { name: 'Очистити' }));
});

user.type для кожної літери викликає keydown, keypress, input, keyup, переводить фокус на поле кліком; user.click - pointerdown, mousedown, focus, pointerup, mouseup, click.

Чому userEvent кращий за замовчуванням:

  • ловить реальні помилки: не дасть клікнути кнопку з disabled чи pointer-events: none, не введе текст у поле лише для читання. fireEvent.click спрацює на заблокованій кнопці - тест пройде, а користувач натиснути не зможе;
  • обробники, що реагують на keydown чи focus, отримують свої події;
  • вбудовані дії: user.selectOptions, user.upload, user.tab() (перевірка порядку фокусу), user.hover, user.clear, user.paste.

Правила роботи з userEvent 14:

  • створювати екземпляр через userEvent.setup() на початку тесту (до render);
  • усі методи асинхронні - обов'язково await;
  • з фальшивими таймерами - userEvent.setup({ advanceTimers: vi.advanceTimersByTime }), інакше тест «зависне» на внутрішніх затримках.

Коли fireEvent доречний:

  • подія, яку userEvent не підтримує (scroll, resize, власні події, події медіа);
  • точне відтворення однієї події для перевірки обробника.

Обидва вже обгорнуті в act, тож оновлення стану після події застосовуються до наступної перевірки.

Докладніше в документації: user-event: вступ

Vitest - тест-раннер на основі Vite: використовує ту саму конфігурацію (псевдоніми, плагіни, TypeScript, JSX), що й застосунок, тож окремо налаштовувати Babel чи трансформації не треба. API сумісний з Jest (describe, test, expect, vi.fn).

Встановлення:

npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom

@testing-library/dom - обов'язкова залежність, яку з версії 16 React Testing Library треба ставити явно.

Конфігурація:

// vite.config.ts (або vitest.config.ts)
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',        // DOM для компонентів у Node.js
    globals: true,               // describe/test/expect без імпорту
    setupFiles: ['./src/test/setup.ts'],
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';   // toBeInTheDocument, toHaveTextContent...

jsdom - реалізація DOM на JavaScript: є document, події, форми. Немає реальної розкладки (getBoundingClientRect повертає нулі), IntersectionObserver, matchMedia - їх доводиться підміняти. Альтернатива - happy-dom (швидша, менш повна).

Очищення між тестами: React Testing Library автоматично демонтує компоненти після кожного тесту, якщо раннер має глобальний afterEach (з globals: true). Без глобальних функцій потрібен ручний afterEach(cleanup).

Мінімальний тест:

import { render, screen } from '@testing-library/react';

test('показує привітання', () => {
  render(<Greeting name="Оля" />);
  expect(screen.getByRole('heading')).toHaveTextContent('Привіт, Оля');
});

Корисні налаштування:

  • TypeScript: додати "types": ["vitest/globals", "@testing-library/jest-dom"] у tsconfig, щоб редактор знав глобальні функції й матчери;
  • test.css: false (за замовчуванням CSS не обробляється) - тести не залежать від стилів;
  • vitest --ui - зручний інтерфейс у браузері, vitest --coverage - покриття.

Jest працює з тими самими бібліотеками, але потребує окремого налаштування трансформацій (Babel чи ts-jest) і модулів - у проєктах на Vite Vitest простіший.

Докладніше в документації: React Testing Library: налаштування

Багато змін в інтерфейсі відбуваються не одразу: після відповіді сервера, таймера, переходу (startTransition). Синхронна перевірка відразу після дії їх не побачить.

findBy... - чекає, поки елемент з'явиться (за замовчуванням до 1000 мс, перевіряючи кожні 50 мс):

test('завантажує вакансії', async () => {
  render(<VacancyList />);

  expect(screen.getByText('Завантаження...')).toBeInTheDocument();
  expect(await screen.findByRole('heading', { name: 'Laravel-розробник' })).toBeInTheDocument();
});

findBy = waitFor + getBy, і це найпростіший спосіб дочекатися появи елемента.

waitFor - повторює колбек, поки він не перестане кидати помилку:

await user.click(screen.getByRole('button', { name: 'Зберегти' }));
await waitFor(() => expect(saveMock).toHaveBeenCalledWith({ title: 'Нова' }));

Правила для waitFor:

  • одна перевірка всередині - якщо кілька, при падінні незрозуміло, яка не дочекалася;
  • без побічних ефектів у колбеку: він виконується багато разів (клік у waitFor клацне кілька разів);
  • для появи елемента - findBy, а не waitFor(() => getBy...);
  • для зникнення - waitForElementToBeRemoved(() => screen.queryByText('Завантаження...')).

Попередження «not wrapped in act(...)» означає: стан компонента оновився після того, як тест уже щось перевірив, і поза контролем тестових утиліт. Типові причини:

  • запит завершився після закінчення тесту - тест не дочекався кінцевого стану. Рішення - дочекатися через findBy того, що з'являється наприкінці;
  • таймер чи проміс оновлює стан, а тест не знає про це.

Не варто обгортати все в act вручну. render, userEvent, fireEvent, findBy, waitFor уже використовують act. Явний act потрібен рідко - наприклад, при виклику функції з renderHook чи прямому оновленні зовнішнього сховища.

Фальшиві таймери (vi.useFakeTimers()) прискорюють тести з затримками, але з userEvent потрібен параметр advanceTimers, а findBy/waitFor у сучасних версіях Testing Library самі просувають фальшиві таймери Jest; з Vitest це залежить від налаштування - простіше уникати фальшивих таймерів там, де можна.

Ознака поганого тесту - довільні затримки (await new Promise((r) => setTimeout(r, 500))): повільно й нестабільно.

Докладніше в документації: Testing Library: асинхронні методи

Тести компонентів, що завантажують дані, не повинні ходити в справжній API: повільно, нестабільно, залежить від стану бази. Підмінити fetch через vi.mock можна, але тоді тест прив'язаний до того, як код робить запит.

MSW (Mock Service Worker) перехоплює запити на рівні мережі: компонент викликає звичайний fetch чи axios, а відповідь надходить з обробника тесту.

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/vacancies', () =>
    HttpResponse.json([{ id: 1, title: 'Laravel-розробник' }]),
  ),
  http.post('/api/vacancies/:id/apply', async ({ params, request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: params.id, email: body.email }, { status: 201 });
  }),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Інший сценарій в окремому тесті - перевизначити обробник:

test('показує помилку сервера', async () => {
  server.use(http.get('/api/vacancies', () => new HttpResponse(null, { status: 500 })));

  render(<VacancyList />);
  expect(await screen.findByRole('alert')).toHaveTextContent('Не вдалося завантажити');
});

resetHandlers() після тесту повертає типові обробники - сценарії не «протікають» між тестами.

Переваги:

  • тест не знає про спосіб запиту - перехід з fetch на axios чи TanStack Query не ламає тести;
  • ті самі обробники працюють у браузері (через Service Worker) - для розробки фронтенду без готового бекенду, в Storybook і в браузерних тестах;
  • onUnhandledRequest: 'error' - тест падає на запиті, для якого немає обробника, замість тихого звернення в мережу.

Що варто знати:

  • відносні URL (/api/...) у середовищі jsdom розв'язуються відносно location тесту;
  • затримки й мережеві помилки - await delay(1000) і HttpResponse.error() для перевірки станів завантаження й обриву з'єднання;
  • перевіряти запит: тіло й заголовки читаються в обробнику (await request.json()), тож можна переконатися, що форма відправила правильні дані.

Докладніше в документації: Mock Service Worker

Хук не можна викликати поза компонентом. renderHook рендерить тестовий компонент, що викликає хук, і дає доступ до його результату.

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

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  return { count, increment: () => setCount((c) => c + 1) };
}

test('збільшує лічильник', () => {
  const { result } = renderHook(() => useCounter(5));

  expect(result.current.count).toBe(5);

  act(() => result.current.increment());

  expect(result.current.count).toBe(6);
});

Ключові моменти:

  • result.current - значення, повернене хуком при останньому рендері. Не можна зберегти const { count } = result.current на початку - значення застаріє;
  • act потрібен для виклику функцій, що оновлюють стан, - так оновлення застосується до наступної перевірки;
  • зміна аргументів - через rerender:
const { result, rerender } = renderHook(({ id }) => useUser(id), { initialProps: { id: 1 } });
rerender({ id: 2 });
  • демонтування - unmount(), щоб перевірити очищення (відписка, скасування запиту).

Асинхронні хуки - з waitFor:

const { result } = renderHook(() => useVacancies());
await waitFor(() => expect(result.current.status).toBe('success'));

Хуки, що потребують провайдерів (контекст, роутер, клієнт TanStack Query) - опція wrapper:

const wrapper = ({ children }) => (
  <QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useVacancies(), { wrapper });

Коли тестувати хук окремо, а коли через компонент:

  • окремо - хук зі складною логікою, що використовується в багатьох місцях (форматування, синхронізація з URL, машина станів);
  • через компонент - хук, що існує заради одного компонента. Тест компонента перевіряє поведінку, яку бачить користувач, і не ламається, якщо логіку переставити між хуком і компонентом.

Історія: раніше renderHook був в окремому пакеті @testing-library/react-hooks; для React 18+ він вбудований у @testing-library/react, а старий пакет не підтримується.

Докладніше в документації: React Testing Library: renderHook

Реальні компоненти рідко живуть самі по собі: вони читають тему, поточного користувача, маршрут, клієнт запитів. Без провайдерів у тесті вони падають або поводяться не так, як у застосунку.

Найпростіше - wrapper у render:

render(<Header />, {
  wrapper: ({ children }) => <AuthContext value={{ user: testUser, logout: vi.fn() }}>{children}</AuthContext>,
});

Для всього проєкту - власна функція render, яка обгортає компонент усім потрібним і дає змогу налаштувати кожен провайдер:

// src/test/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

type Options = RenderOptions & { user?: User | null; route?: string };

export function renderWithProviders(ui: ReactElement, { user = null, route = '/', ...options }: Options = {}) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

  function Providers({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthContext value={{ user, logout: vi.fn() }}>
          <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
        </AuthContext>
      </QueryClientProvider>
    );
  }

  return render(ui, { wrapper: Providers, ...options });
}

export * from '@testing-library/react';
import { renderWithProviders, screen } from '../test/render';

test('гість бачить кнопку входу', () => {
  renderWithProviders(<Header />, { user: null });
  expect(screen.getByRole('link', { name: 'Увійти' })).toBeInTheDocument();
});

Що варто врахувати:

  • свіжий стан на кожен тест: клієнт запитів, сховище (Zustand, Redux) створюються всередині функції, а не на рівні модуля - інакше кеш з одного тесту впливає на інший;
  • retry: false для TanStack Query - інакше тест помилки чекатиме повторних спроб;
  • маршрутизатор у пам'яті (MemoryRouter, createMemoryRouter) - без реального URL браузера; початковий маршрут задається параметром;
  • підміняти лише те, що поза межами тесту: справжній контекст з тестовими даними краще за мок useContext - тест перевіряє реальну інтеграцію;
  • перевірка поведінки при різному контексті (гість / адміністратор / інша мова) стає одним параметром.

renderHook приймає той самий wrapper - хуки тестуються з тими самими провайдерами.

Докладніше в документації: React Testing Library: власний render

У React 19 форма може передати функцію-дію в action, а useActionState дає результат дії й стан очікування:

function ApplyForm({ submit }: { submit: (email: string) => Promise<string | null> }) {
  const [error, formAction, isPending] = useActionState(async (_prev: string | null, formData: FormData) => {
    return submit(String(formData.get('email')));   // null - успіх, рядок - помилка
  }, null);

  return (
    <form action={formAction}>
      <label>
        Email
        <input name="email" type="email" />
      </label>
      <button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Надіслати'}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Тест - як дії користувача, з очікуванням результату:

test('показує помилку від сервера', async () => {
  const user = userEvent.setup();
  const submit = vi.fn().mockResolvedValue('Ця адреса вже подала заявку');
  render(<ApplyForm submit={submit} />);

  await user.type(screen.getByLabelText('Email'), 'olia@example.com');
  await user.click(screen.getByRole('button', { name: 'Надіслати' }));

  expect(await screen.findByRole('alert')).toHaveTextContent('Ця адреса вже подала заявку');
  expect(submit).toHaveBeenCalledWith('olia@example.com');
});

Що відрізняється від форм з onSubmit:

  • дія асинхронна й виконується в переході (transition) - результат з'являється не одразу після кліку, тому findBy/waitFor, а не синхронна перевірка;
  • поля некеровані - значення беруться з FormData, тож у тесті потрібен реальний name на полі й введення через userEvent;
  • після успішної дії React скидає некеровану форму - поля очищаються. Це варто перевірити, якщо поведінка важлива.

Стан очікування перевіряється, якщо дія не завершується одразу:

let resolve!: (v: string | null) => void;
const submit = vi.fn(() => new Promise<string | null>((r) => (resolve = r)));
render(<ApplyForm submit={submit} />);
await user.click(screen.getByRole('button'));

expect(await screen.findByRole('button', { name: 'Надсилаємо...' })).toBeDisabled();
resolve(null);
await waitFor(() => expect(screen.getByRole('button', { name: 'Надіслати' })).toBeEnabled());

Server Functions ('use server' у фреймворках) у тестах компонентів підміняють через параметри чи vi.mock модуля - сама серверна логіка тестується окремо, на сервері. Повний цикл форма → сервер → відповідь краще перевіряти e2e-тестом.

useFormStatus у дочірній кнопці перевіряється так само - через її вигляд (disabled, текст) під час очікування.

Докладніше в документації: useActionState

jsdom - імітація DOM у Node.js. Він швидкий і достатній для більшості тестів логіки й розмітки, але це не браузер:

  • немає розкладки: розміри, позиції, прокрутка - нулі;
  • немає реального CSS: display: none з класу, медіазапити, :hover не працюють;
  • відсутні чи спрощені IntersectionObserver, ResizeObserver, Canvas, Web Animations, matchMedia, буфер обміну, справжні фокус і клавіатурна навігація;
  • події генеруються JavaScript-ом, а не браузером.

Тести компонентів, що залежать від цього (віртуалізований список, перетягування, модальне вікно з фокусом, адаптивна верстка, анімації), або потребують купи моків, або перевіряють не те.

Vitest Browser Mode запускає ті самі тести в справжньому браузері (Chromium, Firefox, WebKit через Playwright):

// vitest.config.ts
import { playwright } from '@vitest/browser-playwright';

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: 'chromium' }],
    },
  },
});
import { render } from 'vitest-browser-react';
import { page } from 'vitest/browser';

test('меню відкривається з клавіатури', async () => {
  render(<Menu />);
  await page.getByRole('button', { name: 'Меню' }).click();
  await expect.element(page.getByRole('menu')).toBeVisible();
});

Події йдуть через протокол браузера (як від реального користувача), toBeVisible враховує справжній CSS, а expect.element автоматично чекає на виконання умови.

E2E-тести (Playwright) - окремий рівень: справжній застосунок з бекендом, перехід між сторінками, автентифікація, повний сценарій «знайти вакансію - відгукнутися». Повільніші й дорожчі в підтримці, але єдині перевіряють інтеграцію фронтенду, API й бази.

Як розподіляти:

  • jsdom (Testing Library) - більшість тестів компонентів: логіка, умовний рендер, форми, обробка відповідей API. Швидко;
  • Browser Mode - компоненти, що залежать від розкладки, CSS, фокусу, браузерних API;
  • E2E - кілька критичних сценаріїв: вхід, оплата, основний шлях користувача.

Ціна браузерних тестів: запуск браузера, повільніше виконання, потреба в браузерах на CI (npx playwright install). Тому їх не варто робити режимом за замовчуванням для всього.

У Laravel-проєктах для e2e є ще Pest з браузерними тестами (на Playwright) - сценарії пишуться на PHP і мають доступ до фабрик і бази.

Докладніше в документації: Vitest Browser Mode

Тести мають давати впевненість і не заважати змінювати код. Тести, що ламаються від кожного рефакторингу, але пропускають справжні помилки, - гірші за їх відсутність: команда звикає їх «оновлювати не дивлячись».

Чого не тестувати - деталі реалізації:

  • внутрішній стан (useState), назви змінних, приватні функції компонента;
  • порядок і кількість викликів хуків, кількість рендерів (крім спеціальних тестів продуктивності);
  • структуру розмітки й класи CSS - container.querySelector('.card > div:nth-child(2)') ламається від будь-якої зміни верстки;
  • поведінку бібліотек - що React рендерить, що роутер переходить, що TanStack Query кешує. Це вже протестовано;
  • дрібні компоненти без логіки (обгортка над <button> з класами) - їх перевіряють тести компонентів, що їх використовують.

Що тестувати: те, що бачить і робить користувач, і контракт компонента - як він реагує на props і дії, що відправляє на сервер, що показує при помилці, порожньому списку, завантаженні.

Знімкові тести (toMatchSnapshot) зберігають весь відрендерений HTML і порівнюють з ним при наступних запусках.

Чим вони небезпечні:

  • ламаються від будь-якої зміни - нового класу, перенесеного атрибута, зміни тексту. Більшість падінь - не баги;
  • «оновити всі знімки» (-u) стає рутиною, і справжня регресія проходить разом з іншими змінами;
  • великі знімки ніхто не читає на рев'ю - дифф на 300 рядків розмітки;
  • знімок фіксує поточну поведінку, а не правильну - якщо баг був при створенні знімка, тест його захищає.

Коли знімки доречні:

  • маленькі й сфокусовані - toMatchInlineSnapshot для результату функції форматування, повідомлення про помилку, згенерованого фрагмента;
  • серіалізовані дані, а не розмітка: структура запиту до API, конфігурація.

Замість знімка сторінки - кілька явних перевірок того, що важливо:

expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Вакансії');
expect(screen.getAllByRole('article')).toHaveLength(3);
expect(screen.getByRole('link', { name: 'Наступна сторінка' })).toHaveAttribute('href', '/jobs?page=2');

Візуальні регресійні тести (Playwright toHaveScreenshot, Chromatic) - окремий інструмент для перевірки вигляду, і тоді вони порівнюють зображення, а не HTML.

Покриття коду - корисний сигнал, а не мета: 100% покриття тестами деталей реалізації дає менше впевненості, ніж 70% поведінкових тестів.

Докладніше в документації: Testing Library: керівні принципи