ДокументацияVueТестирование Vue — Vitest + Vue Test Utils
Средний 14 мин чтения

Тестирование Vue — Vitest + Vue Test Utils

Тестирование Vue-компонентов с Vitest и Vue Test Utils. Unit-тесты, монтирование, взаимодействие, mock-и, тестирование composables и Pinia stores.

Vue 3VitesttestingVue Test Utilsunit tests

Настройка

Установка

npm install -D vitest @vue/test-utils @testing-library/vue happy-dom

vitest.config.ts

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'happy-dom',
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

package.json

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Vue Test Utils — основы

mount

mount создаёт полноценный экземпляр компонента:

import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

test('счётчик увеличивается', async () => {
  const wrapper = mount(Counter)

  expect(wrapper.text()).toContain('0')

  await wrapper.find('button').trigger('click')

  expect(wrapper.text()).toContain('1')
})

shallowMount

shallowMount рендерит только сам компонент, заменяя дочерние заглушками:

import { shallowMount } from '@vue/test-utils'
import Dashboard from './Dashboard.vue'

test('рендерит Dashboard без дочерних компонентов', () => {
  const wrapper = shallowMount(Dashboard)

  expect(wrapper.findComponent(HeavyChart).exists()).toBe(true)
})

Полезно для изолированного тестирования одного компонента.

Props

test('отображает title из props', () => {
  const wrapper = mount(TitleBar, {
    props: {
      title: 'Привет',
    },
  })

  expect(wrapper.text()).toContain('Привет')
})

Обновление props:

await wrapper.setProps({ title: 'Новый заголовок' })
expect(wrapper.text()).toContain('Новый заголовок')

Emits

test('эмитит событие при клике', async () => {
  const wrapper = mount(Button, {
    props: {
      label: 'Кликни',
    },
  })

  await wrapper.find('button').trigger('click')

  expect(wrapper.emitted()).toHaveProperty('click')
  expect(wrapper.emitted('click')).toHaveLength(1)
})

Эмит с payload:

await wrapper.find('input').setValue('hello')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['hello'])

Slots

test('рендерит slot', () => {
  const wrapper = mount(Card, {
    slots: {
      default: 'Контент карточки',
      header: '<h2>Заголовок</h2>',
    },
  })

  expect(wrapper.text()).toContain('Контент карточки')
  expect(wrapper.html()).toContain('<h2>Заголовок</h2>')
})

Find и findAll

const button = wrapper.find('button')
const items = wrapper.findAll('li')

expect(items).toHaveLength(3)
expect(items[0].text()).toBe('Первый')

По data-testid:

const input = wrapper.find('[data-testid="email-input"]')

Trigger

await wrapper.find('button').trigger('click')
await wrapper.find('input').trigger('input')
await wrapper.find('form').trigger('submit.prevent')

Клавиатура:

await wrapper.find('input').trigger('keydown.enter')
await wrapper.find('input').trigger('keydown', { key: 'Escape' })

setValue

await wrapper.find('input').setValue('новое значение')
await wrapper.find('select').setValue('option1')
await wrapper.find('input[type="checkbox"]').setValue(true)

Тестирование composables

Composable без DOM можно тестировать как обычную функцию:

import { useCounter } from './useCounter'

test('useCounter', () => {
  const { count, doubled, increment } = useCounter(5)

  expect(count.value).toBe(5)
  expect(doubled.value).toBe(10)

  increment()
  expect(count.value).toBe(6)
  expect(doubled.value).toBe(12)
})

Composable с DOM (onMounted, addEventListener) оборачиваем в компонент:

import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useMouse } from './useMouse'

test('useMouse отслеживает позицию', async () => {
  const TestComponent = defineComponent({
    setup() {
      const { x, y } = useMouse()
      return { x, y }
    },
    template: '<div>{{ x }}, {{ y }}</div>',
  })

  const wrapper = mount(TestComponent)

  await wrapper.trigger('mousemove', { clientX: 100, clientY: 200 })

  expect(wrapper.text()).toContain('100')
})

Тестирование Pinia

createTestingPinia

npm install -D @pinia/testing
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import Cart from './Cart.vue'

test('корзина отображает товары', () => {
  const wrapper = mount(Cart, {
    global: {
      plugins: [
        createTestingPinia({
          initialState: {
            cart: {
              items: [
                { id: 1, name: 'Товар', price: 100, quantity: 2 },
              ],
            },
          },
        }),
      ],
    },
  })

  expect(wrapper.text()).toContain('Товар')
  expect(wrapper.text()).toContain('200')
})

Мокирование actions

test('checkout вызывает store action', async () => {
  const wrapper = mount(Cart, {
    global: {
      plugins: [createTestingPinina()],
    },
  })

  const store = useCartStore()

  await wrapper.find('[data-testid="checkout"]').trigger('click')

  expect(store.checkout).toHaveBeenCalledTimes(1)
})

С createTestingPinia все actions автоматически заменяются на stubs (spy-функции).

Тестирование роутера

import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'

test('навигация на страницу about', async () => {
  const router = createRouter({
    history: createMemoryHistory(),
    routes: [
      { path: '/', component: { template: '<div>Home</div>' } },
      { path: '/about', component: { template: '<div>About</div>' } },
    ],
  })

  await router.push('/about')
  await router.isReady()

  const wrapper = mount(NavBar, {
    global: {
      plugins: [router],
    },
  })

  expect(wrapper.text()).toContain('About')
})

Mock-и

Мокирование API

import { vi } from 'vitest'

test('загрузка пользователей', async () => {
  const mockUsers = [
    { id: 1, name: 'Анна' },
    { id: 2, name: 'Иван' },
  ]

  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(mockUsers),
  })

  const wrapper = mount(UserList)
  await flushPromises()

  expect(wrapper.text()).toContain('Анна')
  expect(wrapper.text()).toContain('Иван')
})

Мокирование модулей

vi.mock('@/api/users', () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: 'Анна' },
  ]),
}))

Мокирование composables

vi.mock('@/composables/useAuth', () => ({
  useAuth: () => ({
    user: ref({ name: 'Тестовый пользователь' }),
    isLoggedIn: ref(true),
  }),
}))

Snapshot-тесты

test('рендер UserCard', () => {
  const wrapper = mount(UserCard, {
    props: {
      user: { id: 1, name: 'Анна', email: 'anna@mail.ru' },
    },
  })

  expect(wrapper.html()).toMatchSnapshot()
})

При первом запуске Vitest создаст файл __snapshots__/UserCard.test.ts.snap. При последующих — будет сравнивать с ним. Если изменилось — обновите: vitest -u.

Структура тестов

src/
├── components/
│   ├── Button.vue
│   └── __tests__/
│       └── Button.test.ts
├── composables/
│   ├── useCounter.ts
│   └── __tests__/
│       └── useCounter.test.ts
└── stores/
    ├── cart.ts
    └── __tests__/
        └── cart.test.ts

Или рядом с файлом:

src/
├── components/
│   ├── Button.vue
│   └── Button.test.ts

Итог

Vitest + Vue Test Utils — стандартный набор для тестирования Vue. mount для полного рендера, shallowMount для изоляции. Тестируйте props, emits, slots, interactions. Composables без DOM можно тестировать напрямую. createTestingPinia мокирует stores. vi.mock подменяет модули. Snapshot-тесты контролируют HTML-вывод.