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

Senior: питання на співбесіді з теми «Тестування»

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

3 питання

У 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: керівні принципи