Middle: питання на співбесіді з теми «Тестування»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
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 усередині).