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

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

Питання з реальних співбесід з відповідями: 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.

Докладніше в документації: Vue Test Utils: початок

Введення в поле - 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» - ні.

Докладніше в документації: Vue Test Utils: обробка подій

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 - це деталь реалізації. Перевіряйте вивід.

Докладніше в документації: Vue Test Utils: передача даних

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' - щоб незамоканий запит падав тестом, а не мовчки йшов у мережу.

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

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 усередині).

Докладніше в документації: Тестування: composables

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

Докладніше в документації: Pinia: тестування

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) - разом з фабриками й станом бази.

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

Тест має ламатися, коли ламається поведінка, і не ламатися, коли код переписали без зміни поведінки. Тести, що перевіряють деталі реалізації, роблять навпаки.

Деталі реалізації, яких не варто торкатися:

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

Докладніше в документації: Тестування у Vue