Перейти к содержимому

23. Тестирование с Vitest

Тестирование — это не просто «хорошая практика», это способ уверенно рефакторить, документировать поведение и предотвращать регрессии. В экосистеме Vue 3 связка Vitest + @vue/test-utils даёт первоклассный опыт разработки с поддержкой TypeScript, горячей перезагрузкой тестов и молниеносной скоростью.


Окно терминала
npm install -D vitest @vue/test-utils @vitejs/plugin-vue jsdom

vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom', // симулируем браузер
globals: true, // глобальные describe/it/expect
setupFiles: ['./tests/setup.ts']
}
})

tests/setup.ts:

import { config } from '@vue/test-utils'
import { createPinia } from 'pinia'
// Глобальные плагины для всех тестов
config.global.plugins = [createPinia()]

import { mount, shallowMount } from '@vue/test-utils'
import ParentComponent from './ParentComponent.vue'
// mount: рендерит ВСЕ дочерние компоненты
// Используй для интеграционных тестов
const wrapper = mount(ParentComponent)
// shallowMount: заглушает (stub) дочерние компоненты
// Используй для изолированного unit-тестирования
const shallow = shallowMount(ParentComponent)

Разница наглядно:

// Компонент:
// <template>
// <div>
// <ChildA />
// <ChildB :data="items" />
// </div>
// </template>
// mount — ChildA и ChildB реально рендерятся
const full = mount(ParentComponent)
expect(full.findComponent(ChildA).exists()).toBe(true)
// shallowMount — ChildA и ChildB заменяются на <child-a-stub>
const stubbed = shallowMount(ParentComponent)
expect(stubbed.find('child-a-stub').exists()).toBe(true)

Всегда используй await при изменении реактивного состояния:

import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Counter from './Counter.vue'
describe('Counter', () => {
it('увеличивает счётчик при клике', async () => {
const wrapper = mount(Counter)
// Начальное состояние
expect(wrapper.find('.count').text()).toBe('0')
// Триггерим клик
await wrapper.find('button').trigger('click')
// Ждём обновления DOM
expect(wrapper.find('.count').text()).toBe('1')
})
it('принимает начальное значение через props', async () => {
const wrapper = mount(Counter, {
props: { initial: 10 }
})
expect(wrapper.find('.count').text()).toBe('10')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.count').text()).toBe('11')
})
it('реагирует на изменение props', async () => {
const wrapper = mount(Counter, { props: { modelValue: 5 } })
expect(wrapper.text()).toContain('5')
await wrapper.setProps({ modelValue: 20 })
expect(wrapper.text()).toContain('20')
})
})

import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
describe('SearchInput', () => {
it('эмитит search при submit формы', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('input').setValue('vue 3')
await wrapper.find('form').trigger('submit')
// Проверяем что событие было испущено
expect(wrapper.emitted('search')).toBeTruthy()
// Проверяем аргументы события
expect(wrapper.emitted('search')?.[0]).toEqual(['vue 3'])
})
it('эмитит update:modelValue при вводе', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('input').setValue('тест')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted?.[emitted.length - 1]).toEqual(['тест'])
})
it('не эмитит search если поле пустое', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('search')).toBeFalsy()
})
})

import { mount } from '@vue/test-utils'
import Card from './Card.vue'
describe('Card со слотами', () => {
it('рендерит default slot', () => {
const wrapper = mount(Card, {
slots: {
default: '<p>Контент карточки</p>'
}
})
expect(wrapper.find('p').text()).toBe('Контент карточки')
})
it('рендерит named slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h2>Заголовок</h2>',
default: 'Основной контент',
footer: '<button>Кнопка</button>'
}
})
expect(wrapper.find('h2').text()).toBe('Заголовок')
expect(wrapper.find('button').text()).toBe('Кнопка')
})
it('scoped slot передаёт данные', () => {
// Компонент: <slot :items="items" :count="count" />
const wrapper = mount(DataList, {
slots: {
default: `
<template #default="{ items, count }">
<span data-testid="count">{{ count }}</span>
<span v-for="item in items" :key="item.id">{{ item.name }}</span>
</template>
`
},
props: {
items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }]
}
})
expect(wrapper.find('[data-testid="count"]').text()).toBe('2')
})
})

import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useUserStore } from '@/stores/user'
import UserProfile from './UserProfile.vue'
describe('UserProfile', () => {
it('показывает имя пользователя из store', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
// Предзаполняем store начальными данными
initialState: {
user: {
currentUser: { name: 'Алексей', role: 'admin' },
isLoggedIn: true
}
}
})
]
}
})
expect(wrapper.text()).toContain('Алексей')
expect(wrapper.text()).toContain('admin')
})
it('вызывает action при клике на кнопку логаута', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
const store = useUserStore()
await wrapper.find('[data-testid="logout-btn"]').trigger('click')
expect(store.logout).toHaveBeenCalledOnce()
})
it('показывает скелетон пока загружаются данные', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: { loading: true, currentUser: null }
}
})
]
}
})
expect(wrapper.find('.skeleton').exists()).toBe(true)
})
})

import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { vi } from 'vitest'
import NavBar from './NavBar.vue'
describe('NavBar', () => {
let router: ReturnType<typeof createRouter>
beforeEach(() => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/dashboard', component: { template: '<div>Dashboard</div>' } }
]
})
})
it('подсвечивает активный маршрут', async () => {
await router.push('/dashboard')
const wrapper = mount(NavBar, {
global: { plugins: [router] }
})
await router.isReady()
expect(
wrapper.find('[href="/dashboard"]').classes()
).toContain('active')
})
it('навигирует при клике', async () => {
const wrapper = mount(NavBar, {
global: { plugins: [router] }
})
await wrapper.find('[data-testid="nav-dashboard"]').trigger('click')
await router.isReady()
expect(router.currentRoute.value.path).toBe('/dashboard')
})
})

import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import Button from './Button.vue'
describe('Button snapshots', () => {
it('соответствует снапшоту (primary)', () => {
const wrapper = mount(Button, {
props: { variant: 'primary', label: 'Сохранить' }
})
expect(wrapper.html()).toMatchSnapshot()
})
it('соответствует снапшоту (disabled)', () => {
const wrapper = mount(Button, {
props: { variant: 'primary', disabled: true, label: 'Сохранить' }
})
expect(wrapper.html()).toMatchSnapshot()
})
})
// Обновление снапшотов: vitest --update-snapshots

Окно терминала
npx vitest
# Watch режим (пересборка при изменениях)
npx vitest --watch
# Запустить конкретный файл
npx vitest Counter.test.ts
# С покрытием кода
npx vitest --coverage
# UI режим (красивый интерфейс)
npx vitest --ui

Называй тесты описательно через it('делает X, когда Y'):

// ❌ Плохо
it('тест кнопки')
it('работает')
// ✅ Хорошо
it('эмитит submit с данными формы при нажатии кнопки')
it('показывает сообщение об ошибке если поле пустое')

Используй data-testid для поиска элементов:

<!-- В компоненте -->
<button data-testid="submit-btn">Отправить</button>
// В тесте — устойчиво к изменению классов и текста
wrapper.find('[data-testid="submit-btn"]')

Организуй тесты по паттерну AAA (Arrange, Act, Assert):

it('обновляет список после добавления элемента', async () => {
// Arrange — подготовка
const wrapper = mount(TodoList)
expect(wrapper.findAll('.todo-item')).toHaveLength(0)
// Act — действие
await wrapper.find('input').setValue('Новая задача')
await wrapper.find('form').trigger('submit')
// Assert — проверка
expect(wrapper.findAll('.todo-item')).toHaveLength(1)
expect(wrapper.find('.todo-item').text()).toBe('Новая задача')
})