23. Тестирование с Vitest
🧪 Урок 24 — Тестирование с Vitest и @vue/test-utils
Заголовок раздела «🧪 Урок 24 — Тестирование с Vitest и @vue/test-utils»Тестирование — это не просто «хорошая практика», это способ уверенно рефакторить, документировать поведение и предотвращать регрессии. В экосистеме Vue 3 связка Vitest + @vue/test-utils даёт первоклассный опыт разработки с поддержкой TypeScript, горячей перезагрузкой тестов и молниеносной скоростью.
⚙️ Установка и настройка
Заголовок раздела «⚙️ Установка и настройка»npm install -D vitest @vue/test-utils @vitejs/plugin-vue jsdomvite.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()]🔩 mount vs shallowMount
Заголовок раздела «🔩 mount vs shallowMount»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') })})📡 Тестирование emit событий
Заголовок раздела «📡 Тестирование emit событий»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') })})🍍 Мокирование Pinia stores
Заголовок раздела «🍍 Мокирование Pinia stores»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) })})🛣️ Мокирование Vue Router
Заголовок раздела «🛣️ Мокирование Vue Router»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') })})📸 Snapshot тесты
Заголовок раздела «📸 Snapshot тесты»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('Новая задача')})