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

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

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

10 питань

Фронтенд-код ламається так само, як серверний: форма перестає відправлятися, фільтр показує не ті дані, кнопка «Оплатити» неактивна. Без тестів про це дізнаються користувачі, а кожен рефакторинг стає ризиком.

Рівні тестів:

Рівень Що перевіряє Інструменти Швидкість
unit одна функція чи модуль окремо: форматування ціни, валідація, розрахунок кошика Vitest, Jest мілісекунди
компонентні / інтеграційні кілька частин разом у DOM: компонент з формою, відправка, повідомлення про помилку Vitest + Testing Library, jsdom чи справжній браузер десятки мілісекунд
end-to-end (E2E) увесь застосунок у справжньому браузері з бекендом: вхід, оформлення замовлення Playwright, Cypress, Pest browser tests секунди

Піраміда радить багато дешевих unit-тестів, менше інтеграційних і кілька E2E: що вище рівень, то тест повільніший, крихкіший і дорожчий у підтримці.

«Тестовий трофей» (Kent C. Dodds) - альтернативний погляд для фронтенду: найбільше користі дають інтеграційні тести, бо unit-тести окремих функцій інтерфейсу часто перевіряють деталі реалізації, а не те, що бачить користувач. Основа трофея - статичний аналіз: TypeScript і ESLint ловлять цілий клас помилок без жодного тесту.

Що тестувати насамперед:

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

Що не тестувати:

  • бібліотеки й фреймворк - вони протестовані авторами;
  • точну розмітку й стилі - такі тести падають від кожної зміни дизайну;
  • приватні деталі реалізації, які користувач не бачить.

Для Laravel-проєкту: бекенд покривають Pest-тестами, а для фронтенду часто достатньо unit-тестів складної логіки в Vitest і браузерних тестів основних сторінок - вони перевіряють і JavaScript, і серверну частину одночасно.

Докладніше в документації: Martin Fowler: The Practical Test Pyramid

Vitest - тестовий фреймворк, побудований на Vite. Він використовує ту саму конфігурацію, плагіни й псевдоніми шляхів, що й збирання застосунку, тож тести бачать код так само, як браузер.

npm install -D vitest
// resources/js/utils/price.js
export function formatPrice(cents) {
  return `${(cents / 100).toFixed(2)} грн`;
}
// resources/js/utils/price.test.js
import { describe, it, expect } from 'vitest';
import { formatPrice } from './price';

describe('formatPrice', () => {
  it('перетворює копійки на гривні', () => {
    expect(formatPrice(12345)).toBe('123.45 грн');
  });

  it('показує нуль', () => {
    expect(formatPrice(0)).toBe('0.00 грн');
  });
});
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Запуск:

  • npx vitest - режим спостереження: при зміні файлу перезапускаються лише пов'язані з ним тести;
  • npx vitest run - один прогін, для CI;
  • npx vitest price - лише файли, у назві яких є price.

Основні перевірки:

Матчер Для чого
toBe примітиви, порівняння за ===
toEqual об'єкти й масиви за вмістом
toStrictEqual те саме, але враховує undefined-поля і класи
toContain, toHaveLength масиви й рядки
toThrow функція кидає помилку
toMatchObject об'єкт містить указані поля

Типова помилка - expect({ a: 1 }).toBe({ a: 1 }): це різні об'єкти, тест падає. Для об'єктів - toEqual.

Чому Vitest, а не Jest, у проєкті на Vite:

  • одна конфігурація: vite.config.js з псевдонімом @/ уже працює в тестах, не треба дублювати її для Jest;
  • ES-модулі й TypeScript без Babel і додаткових трансформерів;
  • швидкий режим спостереження завдяки графу модулів Vite;
  • API сумісний з Jest (describe, it, expect, моки) - перехід зазвичай механічний.

Середовище: за замовчуванням тести виконуються в Node.js. Для коду, що працює з DOM, потрібне браузерне середовище (jsdom, happy-dom чи справжній браузер).

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

Головна небезпека в асинхронних тестах - тест завершується раніше, ніж перевірка. Тест, у якому жодна перевірка не виконалася, вважається пройденим.

Неправильно:

it('завантажує користувача', () => {
  fetchUser(1).then((user) => {
    expect(user.name).toBe('Олена');   // виконається після завершення тесту
  });
});

Тест «зелений» навіть тоді, коли fetchUser повертає зовсім іншого користувача.

Правильно - async/await:

it('завантажує користувача', async () => {
  const user = await fetchUser(1);

  expect(user.name).toBe('Олена');
});

Тестовий фреймворк чекає на Promise, який повертає функція тесту.

Перевірка відхиленого Promise:

it('кидає помилку для неіснуючого користувача', async () => {
  await expect(fetchUser(999)).rejects.toThrow('Not found');
});

it('повертає дані', async () => {
  await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});

await перед expect(...).rejects обов'язковий - без нього перевірка знову не встигне виконатися.

Варіант з try/catch потребує захисту:

it('кидає помилку', async () => {
  expect.assertions(1);   // тест упаде, якщо перевірок буде не рівно одна

  try {
    await fetchUser(999);
  } catch (error) {
    expect(error.message).toBe('Not found');
  }
});

Без expect.assertions(1) тест пройде, якщо fetchUser помилково не кине винятку - блок catch просто не виконається.

Таймери й затримки не варто чекати по-справжньому (await new Promise(r => setTimeout(r, 3000))): тест стане повільним і нестабільним. Для цього є фейкові таймери.

Перевірка, що все дочекалося:

  • expect.hasAssertions() - хоча б одна перевірка виконалася;
  • vi.waitFor(() => expect(...)) - повторює перевірку, доки вона не пройде чи не мине час, коли результат з'являється не одразу.

Порада: напишіть тест, переконайтеся, що він падає, якщо зламати код. Тест, який ніколи не падав, міг нічого не перевіряти.

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

Покриття (coverage) - частка коду, яка виконалася під час тестів.

npm install -D @vitest/coverage-v8
npx vitest run --coverage
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
price.js      |     100 |       75 |     100 |     100 | 12
cart.js       |   62.50 |       50 |   66.66 |   62.50 | 18-24,31

Метрики:

Метрика Що рахує
Statements / Lines виконані інструкції чи рядки
Functions функції, які хоч раз викликали
Branches гілки умов: обидві частини if/else, ? :, ??, &&

Branches - найкорисніша: 100% рядків може бути навіть тоді, коли гілка else жодного разу не перевірялася.

Провайдери у Vitest: v8 (за замовчуванням, використовує вбудоване покриття рушія, швидкий) і istanbul (інструментує код, працює в будь-якому середовищі).

Що покриття НЕ показує:

  • чи є перевірки: тест, що викликає функцію без жодного expect, дає 100% покриття і нічого не перевіряє;
  • чи перевірки правильні: код виконано, але очікуваний результат у тесті міг бути скопійований з помилкової реалізації;
  • граничні випадки: рядок price * qty покрито, але ніхто не перевірив від'ємну кількість, нуль, дробові значення;
  • поєднання умов: обидві гілки двох if покрито окремо, але не всі чотири комбінації;
  • інтеграцію: кожен модуль покрито, а разом вони не працюють.

Як користуватися з розумом:

  • дивитися на непокриті рядки, а не на відсоток: звіт показує, яку логіку забули перевірити;
  • поріг у CI (coverage.thresholds) - захист від падіння покриття, але не ціль. Вимога «90% будь-якою ціною» породжує тести без сенсу;
  • виключити згенерований код, конфігурації, типи (coverage.exclude), щоб вони не спотворювали картину.

Що краще оцінює якість тестів - мутаційне тестування (Stryker для JavaScript, Pest Mutate для PHP): інструмент навмисно змінює код (> на >=, + на -) і перевіряє, чи впаде хоч один тест. Якщо мутація «вижила» - тести цей код насправді не перевіряють.

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

vi.fn() - функція-шпигун: запам'ятовує виклики й може повертати задане значення.

const onSave = vi.fn();
saveForm({ name: 'Олена' }, onSave);

expect(onSave).toHaveBeenCalledOnce();
expect(onSave).toHaveBeenCalledWith({ name: 'Олена' });

vi.spyOn() - стежити за методом існуючого об'єкта чи підмінити його:

const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ...
expect(spy).toHaveBeenCalled();

vi.mock() - замінити весь модуль:

import { getRates } from './api';
import { convert } from './converter';

vi.mock('./api', () => ({
  getRates: vi.fn().mockResolvedValue({ USD: 41.5 }),
}));

it('конвертує за курсом', async () => {
  expect(await convert(100, 'USD')).toBe(4150);
});

vi.mock піднімається на початок файлу - він виконується до імпортів, навіть якщо записаний нижче. Тому всередині фабрики не можна використовувати змінні з файлу: для цього є vi.hoisted().

fetch - підмінити глобальну функцію:

vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ id: 1 }),
}));

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

Фейкові таймери - не чекати по-справжньому:

it('зберігає чернетку через 2 секунди', () => {
  vi.useFakeTimers();
  const save = vi.fn();
  const autosave = debounce(save, 2000);

  autosave('текст');
  vi.advanceTimersByTime(1999);
  expect(save).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledWith('текст');

  vi.useRealTimers();
});

vi.setSystemTime(new Date('2026-10-04')) фіксує «поточну» дату - для коду, що залежить від Date.now().

Прибирати за собою: моки, що «протікають» між тестами, - головна причина тестів, які падають лише в певному порядку:

afterEach(() => {
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

Або в конфігурації: restoreMocks: true.

Міра: мокайте межі системи - мережу, час, сховище, сторонні SDK. Якщо в тесті замоковано все, він перевіряє лише те, що моки викликаються, а не те, що код працює.

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

Код, що працює з DOM (Alpine-компоненти, скрипти на сторінках Blade, веб-компоненти), потребує документа. У Node.js його немає - тому тест запускають у середовищі, що імітує браузер.

Середовища Vitest:

Середовище Що це Особливості
node без DOM (за замовчуванням) для чистої логіки
jsdom реалізація DOM на JavaScript найповніша сумісність, повільніший
happy-dom легша реалізація DOM швидший, але деякі API відсутні чи поводяться інакше
Browser Mode справжній браузер через Playwright реальний рендеринг, макет, події
// vitest.config.js
export default defineConfig({
  test: { environment: 'jsdom' },
});

Чи лише для одного файлу - коментарем на початку: // @vitest-environment happy-dom.

Що jsdom і happy-dom не вміють: макет і розміри (getBoundingClientRect повертає нулі), прокрутку, IntersectionObserver, CSS-анімації, справжню навігацію. Тести, що від цього залежать, - для браузера.

DOM Testing Library - пошук елементів так, як їх бачить користувач:

import { screen, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { mountSubscribeForm } from './subscribe';

it('показує помилку для неправильної пошти', async () => {
  document.body.innerHTML = '<div id="app"></div>';
  mountSubscribeForm(document.getElementById('app'));

  const user = userEvent.setup();
  await user.type(screen.getByLabelText('Email'), 'not-an-email');
  await user.click(screen.getByRole('button', { name: 'Підписатися' }));

  expect(await screen.findByRole('alert')).toHaveTextContent('Неправильна адреса');
});

Пріоритет запитів:

  1. getByRole з name - як елемент бачать допоміжні технології;
  2. getByLabelText - поля форм;
  3. getByText - звичайний текст;
  4. getByTestId - останній варіант, коли нічого іншого немає.

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

getBy / queryBy / findBy: getBy кидає помилку, якщо елемента немає; queryBy повертає null (для перевірки відсутності); findBy - асинхронний, чекає появи елемента.

@testing-library/jest-dom додає зручні матчери: toBeVisible, toBeDisabled, toHaveValue, toHaveTextContent (працює і з Vitest).

Не тестуйте розмітку: перевірки на кшталт «третій div має клас error» ламаються від кожної зміни верстки, хоча поведінка не змінилася.

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

Playwright керує справжніми браузерами (Chromium, Firefox, WebKit) і перевіряє застосунок так, як ним користується людина.

import { test, expect } from '@playwright/test';

test('користувач оформлює замовлення', async ({ page }) => {
  await page.goto('/products/42');
  await page.getByRole('button', { name: 'Додати в кошик' }).click();
  await page.getByRole('link', { name: 'Кошик' }).click();

  await expect(page.getByRole('heading', { name: 'Кошик' })).toBeVisible();
  await expect(page.getByTestId('cart-total')).toHaveText('1 999,00 ₴');
});

Локатори описують, як знайти елемент, а не сам елемент: пошук повторюється щоразу, коли до локатора звертаються. Пріоритет той самий, що в Testing Library: getByRole, getByLabel, getByText, і лише потім getByTestId. CSS-селектори на кшталт .btn-primary > span ламаються від зміни верстки.

Автоочікування - головна причина стабільності. Перед click() Playwright сам чекає, доки елемент буде прикріплений до DOM, видимий, стабільний (без анімації), увімкнений і не перекритий іншим елементом. Перевірки expect(locator).toHaveText() повторюються, доки не пройдуть чи не мине тайм-аут.

Звідки беруться нестабільні (flaky) тести:

Причина Як виправити
page.waitForTimeout(2000) чекати на стан: await expect(locator).toBeVisible()
перевірка значення один раз: expect(await el.textContent()).toBe(...) перевірка, що повторюється: await expect(el).toHaveText(...)
тести залежать один від одного чи від спільних даних кожен тест створює свої дані, ізольований стан входу
реальні сторонні сервіси (платежі, карти) підміна через page.route()
анімації й час вимкнути анімації, фіксувати час через page.clock

Повтор входу в кожному тесті повільний - стан автентифікації зберігають один раз (storageState) і перевикористовують.

Діагностика: trace: 'on-first-retry' у конфігурації записує трасу (знімки DOM, мережу, консоль для кожного кроку) при повторі. npx playwright show-trace показує, що саме сталося в CI.

retries у CI допомагає пережити рідкісні збої, але тест, що проходить лише з другої спроби, - сигнал, а не норма: такі тести варто відстежувати й виправляти.

Скільки E2E-тестів писати: небагато - для критичних сценаріїв. Вони найповільніші й найдорожчі в підтримці; решту поведінки дешевше перевіряти на нижчих рівнях.

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

Знімкові тести (snapshot) зберігають результат при першому запуску у файл, а при наступних - порівнюють з ним.

it('серіалізує замовлення для API', () => {
  expect(toApiPayload(order)).toMatchSnapshot();
});

it('форматує адресу', () => {
  expect(formatAddress(address)).toMatchInlineSnapshot(`"м. Київ, вул. Хрещатик, 1"`);
});

Оновлення знімків після навмисної зміни: npx vitest -u.

Коли знімки корисні:

  • великі структури даних, які складно перевірити вручну: payload для API, згенерована конфігурація, результат парсера;
  • виявлення неочікуваних змін у серіалізації: додали поле - знімок це покаже;
  • inline-знімки для коротких значень - видно прямо в тесті, рев'ю бачить очікування.

Коли шкодять:

  • знімки всієї розмітки компонента змінюються від кожної правки верстки чи класу. Розробник звикає запускати -u не дивлячись - і знімок перестає щось перевіряти;
  • великі знімки ніхто не читає на рев'ю: diff на 300 рядків приймається «на віру»;
  • нестабільні дані (дати, випадкові id) змушують фіксувати чи маскувати значення, інакше тест падає щоразу;
  • знімок замість думки: тест не каже, що важливо в результаті. Явна перевірка expect(payload.total).toBe(1999) документує намір, знімок - ні.

Візуальна регресія порівнює знімки екрана:

await expect(page).toHaveScreenshot('checkout.png', {
  maxDiffPixelRatio: 0.01,
  mask: [page.getByTestId('current-date')],
});

Ловить те, що інші тести не бачать: зламаний CSS, зсунуту верстку, зниклі іконки, проблеми темної теми.

Складнощі візуальних тестів:

  • рендеринг шрифтів і згладжування відрізняються між ОС і навіть версіями браузера - знімки, зроблені на Mac, не збігаються з Linux у CI. Еталонні знімки генерують у тому самому середовищі (Docker-образ Playwright), де запускаються тести;
  • динамічний вміст (дати, реклама, аватари, анімації) - маскувати й вимикати;
  • поріг розбіжності - надто суворий дає хибні падіння, надто м'який пропускає реальні зміни;
  • зберігання еталонів у репозиторії збільшує його розмір.

Практичний підхід: небагато візуальних тестів для ключових сторінок і компонентів дизайн-системи, явні перевірки поведінки - для логіки, знімки даних - для великих структур з обов'язковим переглядом diff на рев'ю.

Докладніше в документації: Playwright: візуальні порівняння

У Laravel-проєкті фронтенд (Blade з Alpine, Livewire, Inertia з Vue чи React) тісно пов'язаний з бекендом. Браузерний тест має перевіряти обидві частини разом - і питання в тому, з якого боку його писати.

Pest browser tests (плагін pestphp/pest-plugin-browser, працює на Playwright):

it('користувач підписується на розсилку', function () {
    $page = visit('/');

    $page->fill('email', 'olena@example.com')
        ->click('Підписатися')
        ->assertSee('Дякуємо за підписку')
        ->assertNoJavaScriptErrors();

    expect(Subscriber::where('email', 'olena@example.com')->exists())->toBeTrue();
});

it('сторінки відкриваються без помилок', function () {
    visit(['/', '/blog', '/jobs'])->assertNoSmoke();
});
  • той самий процес і база, що в PHP-тестах: фабрики, RefreshDatabase, actingAs(), Mail::fake() працюють як у звичайних feature-тестах;
  • перевірка бази поруч з перевіркою інтерфейсу - без API для підготовки даних;
  • smoke-тести (assertNoSmoke, assertNoJavaScriptErrors) дешево ловлять зламані сторінки й помилки в консолі;
  • inDarkMode(), мобільні пристрої, знімки екрана для візуальних порівнянь.

Playwright напряму (тести на JavaScript/TypeScript):

  • окремий застосунок запущений як справжній сервер (webServer у конфігурації), тести бачать його лише через HTTP;
  • підготовка даних - через сидери, спеціальні тестові ендпойнти чи API, що ускладнює ізоляцію;
  • повний API Playwright: перехоплення мережі, кілька вкладок і контекстів, трасування, компонентні тести;
  • природно для команди, що пише фронтенд на TypeScript, і для SPA, де бекенд - окремий сервіс.

Як обрати:

Ситуація Що зручніше
Blade, Livewire, Inertia в одному репозиторії з Laravel Pest browser tests
команда пише переважно на PHP Pest
окремий SPA-фронтенд, бекенд лише API Playwright
складна клієнтська логіка: офлайн, кілька вкладок, мережеві умови Playwright

У CI:

  • браузери треба встановити (npx playwright install --with-deps chromium) і закешувати;
  • ассети зібрати (npm run build), інакше сторінки без JavaScript і CSS;
  • паралельний запуск і шардування для довгих наборів;
  • знімки екрана й траси як артефакти збирання - щоб розбиратися з падіннями без локального відтворення.

Розподіл рівнів: браузерні тести - для критичних сценаріїв і smoke-перевірок сторінок, Livewire- і feature-тести - для логіки компонентів і контролерів, Vitest - для складної клієнтської логіки окремо від браузера.

Докладніше в документації: Pest: браузерне тестування

На старті будь-які тести швидкі. Через рік набір з тисячі тестів може йти 15 хвилин, падати випадково й гальмувати кожен pull request. Структура з самого початку визначає, чи станеться це.

1. Правильний рівень для кожної перевірки:

  • логіка (розрахунки, перетворення, валідація) - винесена в чисті функції й перевіряється unit-тестами в середовищі node, без DOM;
  • поведінка компонентів - інтеграційні тести з Testing Library;
  • критичні сценарії - кілька E2E.

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

2. Середовище за потребою: jsdom у всіх файлах сповільнює й ті тести, яким DOM не потрібен. Середовище задають для конкретних файлів чи проєктів:

// vitest.config.js
export default defineConfig({
  test: {
    projects: [
      { test: { name: 'unit', environment: 'node', include: ['**/*.unit.test.js'] } },
      { test: { name: 'dom', environment: 'happy-dom', include: ['**/*.dom.test.js'] } },
    ],
  },
});

3. Ізоляція й детермінованість:

  • кожен тест готує свої дані й не залежить від порядку запуску;
  • моки й глобальні заглушки відновлюються після кожного тесту (restoreMocks, unstubGlobals у конфігурації);
  • час, випадкові значення й мережа - під контролем тесту (фейкові таймери, vi.setSystemTime, MSW).

4. Швидкий зворотний зв'язок:

  • vitest --changed - лише тести, пов'язані зі зміненими файлами;
  • у CI - паралельний запуск і шардування: vitest run --shard=1/4;
  • E2E - окремою задачею CI, що не блокує швидкі перевірки.

5. Боротьба з нестабільними тестами: тест, що падає випадково, швидко вчить команду ігнорувати червоний CI. Нестабільний тест або виправляють одразу, або тимчасово виключають (з задачею на виправлення), але не перезапускають мовчки.

6. Тести як код:

  • спільні фабрики тестових даних замість копіювання об'єктів у кожному тесті;
  • допоміжні функції для монтування з провайдерами;
  • назви тестів описують поведінку: «показує помилку для порожньої пошти», а не «test 3».

7. Що прибирати: тести, що перевіряють реалізацію (внутрішні виклики, точну розмітку), знімки, які оновлюють не дивлячись, і дублікати тієї самої перевірки на різних рівнях.

Метрики здоров'я набору: час прогону в CI, частка нестабільних тестів, кількість тестів, змінених при рефакторингу без зміни поведінки. Остання - найкращий індикатор того, що тести перевіряють деталі реалізації.

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