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

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

Докладніше в документації: 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