Питання на співбесіді: Тестування
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
10 питань
Стандартний набір для тестування Vue-компонентів:
- Vitest - запускач тестів на основі Vite: та сама конфігурація, що й у проєкті, підтримка
.vue-файлів і TypeScript без додаткових налаштувань; - Vue Test Utils (
@vue/test-utils) - монтує компонент, дає змогу знаходити елементи, взаємодіяти з ними й перевіряти результат; - jsdom чи happy-dom - імітація DOM браузера в Node.js.
npm i -D vitest @vue/test-utils jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: { environment: 'jsdom' },
})
Тест:
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'
describe('Counter', () => {
it('starts from the given value and increments', async () => {
const wrapper = mount(Counter, { props: { start: 2 } })
expect(wrapper.get('[data-test="count"]').text()).toBe('2')
await wrapper.get('button').trigger('click')
expect(wrapper.get('[data-test="count"]').text()).toBe('3')
})
})
Ключові моменти:
mountрендерить компонент разом з усіма дочірніми;propsпередають значення як від батька; також єslotsіglobal(плагіни, заглушки, provide);triggerповертає Promise -awaitпотрібен, щоб Vue встиг оновити DOM;getкидає помилку, якщо елемент не знайдено (з зрозумілим повідомленням),findповертає порожню обгортку - зручно для перевірки відсутності:expect(wrapper.find('.error').exists()).toBe(false).
Атрибути data-test для пошуку елементів кращі за класи й структуру: стилі й розмітка змінюються, а тест - ні.
Що перевіряти: те, що бачить і робить користувач - текст, видимі елементи, події, виклики API, - а не внутрішні змінні компонента. Тоді рефакторинг не ламає тести.
Запуск: npx vitest (режим спостереження) чи npx vitest run у CI.
Введення в поле - setValue, клік чи інша подія - trigger. Обидва повертають Promise, який завершується після оновлення DOM:
const wrapper = mount(SearchForm)
await wrapper.get('input[name="query"]').setValue('laravel')
await wrapper.get('select').setValue('php')
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.get('form').trigger('submit.prevent')
setValue встановлює значення і генерує відповідну подію (input чи change), тож v-model спрацює як у браузері.
Клавіші й модифікатори:
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
await wrapper.get('input').trigger('keydown.enter')
Перевірка подій компонента - emitted():
await wrapper.get('[data-test="save"]').trigger('click')
expect(wrapper.emitted()).toHaveProperty('save')
expect(wrapper.emitted('save')).toHaveLength(1)
expect(wrapper.emitted('save')![0]).toEqual([{ id: 7, title: 'Новий' }])
emitted('save') - масив викликів, кожен виклик - масив аргументів. Тому для emit('changed', 3) результат [[3]], а не [3].
v-model на власному компоненті перевіряється через подію оновлення:
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['нове значення'])
Що варто пам'ятати:
awaitпісляtrigger/setValueобов'язковий, якщо далі перевіряється DOM. Без нього тест бачить старий стан;- запити й інші Promise всередині обробника можуть завершитися пізніше за наступний тік рендеру - тоді потрібен
flushPromises(); - вимкнені елементи:
triggerна кнопці зdisabledподію не генерує - це відповідає браузеру і зручно для перевірки, що заблокована кнопка нічого не робить; - події, яких не має бути, перевіряються так само:
expect(wrapper.emitted('save')).toBeUndefined().
Перевіряйте результат, а не виклик методу. Тест «після кліку з'явилось повідомлення й пішла подія» витримає рефакторинг, а «викликався метод onSave» - ні.
Props передають при монтуванні й змінюють пізніше через setProps:
const wrapper = mount(PriceTag, {
props: { amount: 1250, currency: 'UAH' },
})
expect(wrapper.text()).toContain('1 250')
await wrapper.setProps({ amount: 99 })
expect(wrapper.text()).toContain('99')
setProps повертає Promise - після await DOM уже оновлений. Так перевіряють, що компонент правильно реагує на зміну props, а не лише на початкові значення.
Слоти - рядками з розміткою, компонентами чи функціями рендеру:
const wrapper = mount(Card, {
slots: {
default: '<p>Основний вміст</p>',
header: '<h2>Заголовок</h2>',
footer: FooterActions,
},
})
expect(wrapper.get('header').text()).toBe('Заголовок')
expect(wrapper.html()).toContain('Основний вміст')
Scoped slots - шаблон отримує параметри слоту через params:
const wrapper = mount(DataList, {
props: { items: [{ id: 1, name: 'Оля' }] },
slots: {
item: '<span class="name">{{ params.item.name }}</span>',
},
})
expect(wrapper.get('.name').text()).toBe('Оля')
Так перевіряється контракт слоту: які дані компонент передає назовні.
Атрибути, що не є props (class, id, data-*), перевіряються на корені:
const wrapper = mount(BaseButton, { attrs: { class: 'w-full', 'data-test': 'submit' } })
expect(wrapper.classes()).toContain('w-full')
Що варто перевіряти:
- граничні значення props: порожній масив,
null, дуже довгий текст, нуль - саме там живуть баги; - відсутній слот: компонент має коректно рендеритися, якщо необов'язковий слот не передали (і, наприклад, не показувати порожню обгортку);
- заглушка за замовчуванням у слоті показується, коли вміст не передано.
Чого не варто: перевіряти, що prop «дійшов» у внутрішню змінну через wrapper.vm - це деталь реалізації. Перевіряйте вивід.
mount рендерить компонент повністю - з усіма дочірніми компонентами до самого низу дерева. shallowMount рендерить лише сам компонент, а всі дочірні замінює заглушками - порожніми елементами з назвою компонента:
const wrapper = shallowMount(OrderPage)
// <order-summary-stub order="[object Object]"></order-summary-stub>
// <payment-form-stub></payment-form-stub>
Аргументи за shallowMount:
- тест ізольований від дітей: зламаний
PaymentFormне валить тестOrderPage; - швидше, якщо діти важкі (графіки, редактори, власні запити).
Аргументи проти (і чому документація Vue радить mount за замовчуванням):
- тест перевіряє не те, що бачить користувач: кнопка всередині дочірнього компонента не існує, клік по ній неможливий;
- тест прив'язується до структури компонентів: перенесли розмітку в дочірній компонент - тест зламався, хоча поведінка та сама;
- багато «зелених» ізольованих тестів не гарантують, що компоненти працюють разом.
Точкові заглушки - найкращий компроміс: монтувати повністю, але замінити окремі проблемні компоненти:
const wrapper = mount(OrderPage, {
global: {
stubs: {
MapWidget: true, // важка карта - просто заглушка
RouterLink: RouterLinkStub, // без справжнього маршрутизатора
Teleport: true, // вміст телепорту рендериться на місці
PaymentForm: { template: '<div data-test="payment" />' }, // своя мінімальна версія
},
},
})
Коли заглушка доречна:
- компонент звертається до браузерних API, яких немає в jsdom (canvas, WebGL, карти);
- дочірній компонент робить власні запити, які в цьому тесті не цікаві;
- сторонні компоненти з анімаціями чи телепортами, що заважають знаходити елементи.
Перевірка, що дочірній компонент отримав правильні props - через findComponent:
expect(wrapper.findComponent(OrderSummary).props('total')).toBe(1250)
Корисно на межі з важким компонентом-заглушкою, але для решти випадків краще перевіряти результат у DOM.
Докладніше в документації: Vue Test Utils: заглушки й shallowMount
Vue оновлює DOM асинхронно: зміна стану ставить оновлення в чергу, і DOM змінюється в наступному «тіку». Тест, що перевіряє DOM одразу після зміни, бачить старий стан.
Два різні види очікування:
1. Оновлення DOM після зміни стану - await nextTick() або await на методах Test Utils:
await wrapper.get('button').trigger('click') // trigger повертає nextTick
expect(wrapper.text()).toContain('Відкрито')
wrapper.vm.open = true // пряма зміна стану
await nextTick()
trigger, setValue, setProps уже повертають Promise, що завершується після оновлення DOM - окремий nextTick після них не потрібен.
2. Завершення інших Promise (запити, таймери з Promise, асинхронні дії сторів) - flushPromises():
import { flushPromises, mount } from '@vue/test-utils'
vi.spyOn(api, 'fetchOrders').mockResolvedValue([{ id: 1, total: 100 }])
const wrapper = mount(OrdersList)
await flushPromises() // дочекатися всіх розв'язаних Promise і рендеру
expect(wrapper.findAll('[data-test="order"]')).toHaveLength(1)
flushPromises чекає, поки виконаються всі вже розв'язані Promise в черзі, - зокрема ланцюжки await усередині компонента після замоканого запиту.
Таймери (setTimeout, debounce) - фальшивий час Vitest:
vi.useFakeTimers()
await wrapper.get('input').setValue('lar')
vi.advanceTimersByTime(300) // «промотати» debounce
await flushPromises()
vi.useRealTimers()
Асинхронний setup (top-level await у <script setup>) вимагає <Suspense> - у тесті компонент обгортають у Suspense-обгортку і чекають flushPromises().
Типові помилки:
- тест «зелений» без очікувань: перевірка відбувається до завершення запиту, але випадково збігається з початковим станом (наприклад, «список порожній»);
flushPromisesзамість мокування: справжній мережевий запит він не дочекається - запити в тестах мають бути замокані;- реальні таймери роблять тест повільним і нестабільним - фальшиві таймери надійніші.
Докладніше в документації: Vue Test Utils: асинхронна поведінка
Справжні запити в unit-тестах - повільні, нестабільні й залежать від стану сервера. Їх замінюють, і є два рівні, на яких це роблять.
1. Мок модуля API (vi.mock) - замінити функції, що роблять запити:
import { vi } from 'vitest'
import * as api from '@/api/orders'
vi.mock('@/api/orders', () => ({
fetchOrders: vi.fn(),
}))
it('shows orders', async () => {
vi.mocked(api.fetchOrders).mockResolvedValue([{ id: 1, total: 100 }])
const wrapper = mount(OrdersList)
await flushPromises()
expect(wrapper.text()).toContain('100')
expect(api.fetchOrders).toHaveBeenCalledWith({ status: 'paid' })
})
vi.mock піднімається на початок файлу (hoisting) і діє на всі імпорти модуля в тесті.
- плюси: просто, швидко, видно аргументи виклику;
- мінуси: тест прив'язаний до того, як компонент отримує дані. Перейшли з
fetchOrdersнаuseQuery- треба переписувати моки; помилки у формуванні запиту (URL, заголовки, серіалізація) не перевіряються.
2. Перехоплення мережі (MSW - Mock Service Worker) - компонент робить справжній fetch, а MSW відповідає замість сервера:
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/orders', () => HttpResponse.json([{ id: 1, total: 100 }])),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('shows an error when the API fails', async () => {
server.use(http.get('/api/orders', () => new HttpResponse(null, { status: 500 })))
// ...
})
- плюси: перевіряється весь шлях - URL, параметри, обробка статусів; тест не залежить від того, яким клієнтом зроблено запит; ті самі обробники можна використати в браузері для розробки й у Storybook;
- мінуси: більше налаштувань.
Що обрати: для компонентів, що працюють з HTTP, MSW дає надійніші тести. vi.mock доречний для модулів, які не є HTTP (аналітика, локальне сховище, сторонні SDK), і для швидких точкових перевірок.
Обов'язково тестувати не лише успіх: помилку сервера, порожню відповідь, повільну відповідь (стан завантаження) і помилку валідації 422.
onUnhandledRequest: 'error' - щоб незамоканий запит падав тестом, а не мовчки йшов у мережу.
Composable - звичайна функція, що використовує реактивні API Vue. Як її тестувати, залежить від того, що вона використовує.
1. Лише реактивність (ref, computed, watch) - викликати напряму:
import { useCounter } from './useCounter'
it('increments and clamps to max', () => {
const { count, increment } = useCounter({ max: 2 })
increment()
increment()
increment()
expect(count.value).toBe(2)
})
Компонент не потрібен - функція працює й поза ним.
2. Хуки життєвого циклу чи inject (onMounted, onUnmounted, inject) - потрібен екземпляр компонента. Без нього Vue попередить, що хук викликано поза setup, і він не спрацює. Допоміжна функція монтує тимчасовий застосунок:
import { createApp } from 'vue'
function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
let result!: T
const app = createApp({
setup() {
result = composable()
return () => {}
},
})
app.mount(document.createElement('div'))
return [result, app]
}
it('removes the listener on unmount', () => {
const [{ width }, app] = withSetup(() => useWindowWidth())
// ...
app.unmount() // перевірити прибирання
})
Для inject перед монтуванням додають app.provide(Key, value).
Що перевіряти в composables:
- реакцію на зміну вхідних даних - якщо composable приймає ref чи геттер, змінити його й перевірити результат (з
await nextTick()дляwatch); - прибирання - обробники, таймери, підписки знімаються при розмонтуванні;
- асинхронні стани -
isLoading,error,dataпісля успішного й невдалого запиту (з замоканим API іflushPromises); - граничні випадки вхідних даних.
Коли краще тестувати через компонент: якщо composable тісно пов'язаний з конкретним компонентом і окремо не використовується - тест компонента покриє його природніше.
Composable, що приймає MaybeRefOrGetter, варто протестувати з усіма трьома формами аргументу: звичайним значенням, ref і геттером (toValue усередині).
Pinia. Пакет @pinia/testing дає createTestingPinia - тестову версію стора, що підключається як плагін:
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
const wrapper = mount(CartButton, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: { cart: { items: [{ id: 1, qty: 2 }] } },
}),
],
},
})
const cart = useCartStore() // той самий екземпляр, що й у компоненті
await wrapper.get('button').trigger('click')
expect(cart.add).toHaveBeenCalledWith(1)
Що важливо знати:
- дії замінено шпигунами за замовчуванням (
stubActions: true) - викликcart.add()реєструється, але не виконується: стан не зміниться. Тест перевіряє, що компонент викликав дію, а логіку дії тестують окремо; - щоб дії виконувалися -
stubActions: false; initialStateзадає стан за id стора;- геттери можна перевизначити прямо в тесті:
cart.total = 999(тестовий стор це дозволяє) - щоб перевірити відображення без побудови стану; - сам стор тестують без компонентів:
setActivePinia(createPinia())уbeforeEachі виклики дій напряму.
Vue Router. Два підходи:
1. Справжній маршрутизатор з пам'яттю - найближче до реальності:
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({ history: createMemoryHistory(), routes })
router.push('/orders/7')
await router.isReady()
const wrapper = mount(OrderPage, { global: { plugins: [router] } })
createMemoryHistory не чіпає URL середовища тестів; кожен тест створює новий маршрутизатор, щоб стан не протікав.
2. Моки - для компонентів, яким потрібні лише useRoute()/useRouter():
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({ params: { id: '7' } })),
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
Швидко, але тест не перевіряє справжню навігацію й захисників маршрутів.
RouterLinkStub з Test Utils замінює <RouterLink> без маршрутизатора й дає перевірити to.
Захисників маршрутів (beforeEach з перевіркою автентифікації) варто тестувати на справжньому маршрутизаторі: перейти на захищений маршрут і перевірити, де опинився користувач.
jsdom і happy-dom - імітація DOM у Node.js. Вони швидкі, але не є браузером: немає розкладки (розміри елементів - нулі), IntersectionObserver, ResizeObserver, canvas, справжнього фокуса, прокрутки, CSS-анімацій і :hover. Тести компонентів, що від цього залежать, або неможливі, або перевіряють моки замість поведінки.
Три рівні тестування фронтенду:
1. Компонентні тести в jsdom (Vitest + Vue Test Utils) - більшість тестів. Логіка компонента, рендер за props, події, взаємодія з мокованим API. Мілісекунди на тест.
2. Компонентні тести в браузері - Vitest Browser Mode (через Playwright чи WebdriverIO): ті самі тести компонентів, але в справжньому Chromium/Firefox/WebKit:
// vitest.config.ts (фрагмент); playwright() - з пакета @vitest/browser-playwright
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
}
import { render } from 'vitest-browser-vue'
import { page } from 'vitest/browser'
it('opens the dropdown', async () => {
render(Dropdown, { props: { items } })
await page.getByRole('button', { name: 'Меню' }).click()
await expect.element(page.getByRole('menu')).toBeVisible()
})
Доречні для: віртуалізованих списків, перетягування, позиціонування спливаючих підказок, фокуса й навігації з клавіатури, роботи з canvas і медіа. Повільніші за jsdom, тож для них - лише те, що справді потребує браузера.
3. E2E-тести (Playwright, Cypress) - весь застосунок із бекендом: вхід, оформлення замовлення, оплата в тестовому режимі. Перевіряють, що частини працюють разом. Повільні й крихкіші - тому їх мало, лише для критичних сценаріїв.
Розподіл, що працює: багато швидких компонентних тестів, менше браузерних - для поведінки, яку jsdom не відтворює, і кілька e2e на ключові шляхи користувача.
Пошук елементів за роллю й текстом (getByRole('button', { name: 'Зберегти' })) - спільна практика для всіх рівнів: так тести збігаються з тим, як бачить сторінку користувач і зчитувач екрана, і заразом перевіряють доступність.
У Laravel-проєкті e2e-сценарії зручно писати браузерними тестами Pest (на Playwright) - разом з фабриками й станом бази.
Тест має ламатися, коли ламається поведінка, і не ламатися, коли код переписали без зміни поведінки. Тести, що перевіряють деталі реалізації, роблять навпаки.
Деталі реалізації, яких не варто торкатися:
- внутрішній стан через
wrapper.vm:expect(wrapper.vm.isOpen).toBe(true). Перейменували змінну чи перенесли стан у composable - тест впав, хоча все працює. Перевіряйте, що меню видно; - виклики приватних методів: «після кліку викликався
handleClick» - а не «після кліку з'явилось повідомлення»; - структура дочірніх компонентів і CSS-класи, що відповідають за вигляд;
- кількість рендерів і порядок внутрішніх викликів, якщо це не вимога продуктивності.
Що тестувати:
- вхід: props, введення й дії користувача, відповіді API, стан сторів;
- вихід: що відрендерено, які події випромінено, які запити й дії стора викликано, куди перейшов маршрутизатор.
Тести-знімки (snapshots) зберігають HTML компонента у файл і порівнюють з ним при наступних запусках:
expect(wrapper.html()).toMatchSnapshot()
Чим вони небезпечні:
- падають від будь-якої зміни - додали клас, змінили текст, поміняли порядок атрибутів. Після кількох таких падінь команда звикає оновлювати знімки не дивлячись (
vitest -u) - і знімок більше нічого не перевіряє; - не кажуть, що саме важливо: у знімку на 200 рядків незрозуміло, яка частина - вимога, а яка - випадковість;
- великі знімки ніхто не рецензує в пул-реквестах.
Коли знімки доречні:
- маленькі й стабільні результати: відформатований рядок, згенерований фрагмент розмітки;
- вбудовані знімки (
toMatchInlineSnapshot) - лежать прямо в тесті й видні при рецензії; - як тимчасова страховка перед великим рефакторингом.
Ще кілька пасток:
- покриття заради покриття: 100% рядків не означає, що перевірено важливе. Краще 70% з тестами граничних випадків і помилок;
- тестування бібліотек: не потрібно перевіряти, що
v-modelчи Vue Router працюють - це вже протестовано; - дублювання логіки в тесті: обчислювати очікуване значення тим самим алгоритмом, що й код, - тест завжди «зелений», навіть якщо алгоритм хибний. Очікування задають явними значеннями.