Middle: питання на співбесіді з теми «Тестування»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
3 питання
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. Якщо в тесті замоковано все, він перевіряє лише те, що моки викликаються, а не те, що код працює.
Код, що працює з 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('Неправильна адреса');
});
Пріоритет запитів:
getByRoleзname- як елемент бачать допоміжні технології;getByLabelText- поля форм;getByText- звичайний текст;getByTestId- останній варіант, коли нічого іншого немає.
Пошук за роллю водночас перевіряє доступність: якщо кнопку не знайти за роллю й назвою, її не знайде і програма читання з екрана.
getBy / queryBy / findBy: getBy кидає помилку, якщо елемента немає; queryBy повертає null (для перевірки відсутності); findBy - асинхронний, чекає появи елемента.
@testing-library/jest-dom додає зручні матчери: toBeVisible, toBeDisabled, toHaveValue, toHaveTextContent (працює і з Vitest).
Не тестуйте розмітку: перевірки на кшталт «третій div має клас error» ламаються від кожної зміни верстки, хоча поведінка не змінилася.
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-тестів писати: небагато - для критичних сценаріїв. Вони найповільніші й найдорожчі в підтримці; решту поведінки дешевше перевіряти на нижчих рівнях.