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, текст) під час очікування.
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 і мають доступ до фабрик і бази.
Тести мають давати впевненість і не заважати змінювати код. Тести, що ламаються від кожного рефакторингу, але пропускають справжні помилки, - гірші за їх відсутність: команда звикає їх «оновлювати не дивлячись».
Чого не тестувати - деталі реалізації:
- внутрішній стан (
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: керівні принципи