Vitest, Vue Test Utils: mount/shallowMount, моки props и emits, тестирование composables и асинхронных операций
Тестирование фронтенда — это не про стопроцентное покрытие кода. Это про уверенность: вы можете рефакторить, добавлять фичи и быть уверены, что ничего не сломалось. Хорошие тесты описывают поведение компонента с точки зрения пользователя, а не детали реализации.
Три уровня тестирования фронтенда:
Unit-тесты — изолированное тестирование composables, утилитарных функций, хранилищ Pinia. Быстрые (миллисекунды), много таких тестов. Проверяют логику без UI.
Component-тесты — монтирование компонента в виртуальный DOM, проверка рендеринга, взаимодействий, событий. Медленнее unit-тестов, но проверяют интеграцию логики с шаблоном.
E2E-тесты — запуск реального браузера, тестирование полных сценариев. Самые медленные и хрупкие, но проверяют всё вместе включая бэкенд.
Правило пирамиды: много unit, меньше component, совсем мало e2e.
Vitest — это тест-раннер от создателей Vite. Он использует ту же конфигурацию Vite, понимает Vue SFC, поддерживает TypeScript без дополнительной настройки:
npm install -D vitest @vue/test-utils jsdom @vitejs/plugin-vueКонфигурация vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom', // Имитация браузерного DOM
globals: true, // describe, it, expect без импорта
setupFiles: ['./src/test/setup.ts'],
},
})Файл src/test/setup.ts для глобальных настроек:
import { config } from '@vue/test-utils'
// Глобальные стабы для компонентов, которые не нужно тестировать
config.global.stubs = {
RouterLink: true,
RouterView: true,
}Добавьте скрипты в package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}Composables тестировать проще всего — это обычные функции. Вам нужно только правильно инициализировать контекст Vue:
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial: number = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => { count.value = initial }
return { count, increment, decrement, reset }
}// composables/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('инициализируется с начальным значением', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('инициализируется нулём по умолчанию', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('увеличивает счётчик', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('уменьшает счётчик', () => {
const { count, decrement } = useCounter(3)
decrement()
expect(count.value).toBe(2)
})
it('сбрасывает к начальному значению', () => {
const { count, increment, reset } = useCounter(10)
increment()
increment()
reset()
expect(count.value).toBe(10)
})
})mount монтирует компонент со всеми дочерними компонентами. shallowMount заменяет дочерние компоненты заглушками.
Когда использовать mount:
Когда использовать shallowMount:
<!-- Button.vue -->
<script setup lang="ts">
defineProps<{ label: string; disabled?: boolean }>()
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<button
:disabled="disabled"
class="btn"
@click="emit('click')"
>
{{ label }}
</button>
</template>// Button.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button', () => {
it('отображает label', () => {
const wrapper = mount(Button, {
props: { label: 'Нажми меня' }
})
expect(wrapper.text()).toContain('Нажми меня')
})
it('заблокирован когда disabled=true', () => {
const wrapper = mount(Button, {
props: { label: 'OK', disabled: true }
})
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('не заблокирован по умолчанию', () => {
const wrapper = mount(Button, {
props: { label: 'OK' }
})
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
})Vue Test Utils предоставляет API для взаимодействия с виртуальным DOM:
const wrapper = mount(MyComponent)
// Поиск элементов
wrapper.find('.submit-btn') // первый элемент (возвращает DOMWrapper)
wrapper.findAll('li') // все совпадения
wrapper.findComponent(ChildComponent) // поиск Vue компонента
// Проверки состояния
wrapper.text() // весь текстовый контент
wrapper.html() // HTML строка
wrapper.exists() // true/false существует ли элемент
wrapper.isVisible() // учитывает display:none и visibility
// Атрибуты и классы
wrapper.find('input').attributes('placeholder')
wrapper.find('.card').classes() // массив классов
wrapper.find('.card').classes('active') // true/false наличие классаТестирование кликов, ввода и пользовательских событий — ключевая часть component-тестов:
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import SearchInput from './SearchInput.vue'
describe('SearchInput', () => {
it('эмитит событие search при нажатии кнопки', async () => {
const wrapper = mount(SearchInput)
// Симуляция ввода
await wrapper.find('input').setValue('vue router')
// Клик по кнопке
await wrapper.find('[data-test="search-btn"]').trigger('click')
// Проверка эмитированных событий
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['vue router'])
})
it('не эмитит при пустом поле', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('[data-test="search-btn"]').trigger('click')
expect(wrapper.emitted('search')).toBeFalsy()
})
})Важно: все действия с DOM (trigger, setValue) возвращают Promise — всегда используйте await. Иначе проверки выполнятся до того как Vue обновит DOM.
Слоты позволяют передать контент в компонент. Тестирование слотов в Vue Test Utils:
import { mount } from '@vue/test-utils'
import Card from './Card.vue'
it('рендерит default slot', () => {
const wrapper = mount(Card, {
slots: {
default: '<p class="slot-content">Контент карточки</p>'
}
})
expect(wrapper.find('.slot-content').exists()).toBe(true)
expect(wrapper.find('.slot-content').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.text()).toContain('Основной текст')
})Иногда компонент использует fetch, localStorage или внешние модули. vi.mock() позволяет заменить их на контролируемые заглушки:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
// Мокируем весь модуль
vi.mock('@/api/users', () => ({
fetchUser: vi.fn()
}))
import { fetchUser } from '@/api/users'
describe('UserProfile', () => {
beforeEach(() => {
// Сбрасываем состояние мока перед каждым тестом
vi.clearAllMocks()
})
it('показывает данные пользователя после загрузки', async () => {
// Настраиваем что вернёт мок
vi.mocked(fetchUser).mockResolvedValueOnce({
id: 1,
name: 'Иван Петров',
email: 'ivan@example.com'
})
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
// Ждём завершения всех промисов
await flushPromises()
expect(wrapper.find('[data-test="user-name"]').text()).toBe('Иван Петров')
})
it('показывает ошибку при неудачной загрузке', async () => {
vi.mocked(fetchUser).mockRejectedValueOnce(new Error('Сервер недоступен'))
const wrapper = mount(UserProfile, { props: { userId: 1 } })
await flushPromises()
expect(wrapper.find('[data-test="error-message"]').exists()).toBe(true)
})
})Pinia требует установки перед использованием в тестах. setActivePinia создаёт изолированный экземпляр для каждого теста:
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'
describe('useCartStore', () => {
beforeEach(() => {
// Создаём свежую Pinia для каждого теста
setActivePinia(createPinia())
})
it('добавляет товар в корзину', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Книга', price: 500 })
expect(cart.items).toHaveLength(1)
expect(cart.totalPrice).toBe(500)
})
it('считает сумму нескольких товаров', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Книга', price: 500 })
cart.addItem({ id: 2, name: 'Курс', price: 1500 })
expect(cart.totalPrice).toBe(2000)
})
})При тестировании компонентов, которые используют Pinia, передайте плагин через global.plugins:
const wrapper = mount(CartSummary, {
global: {
plugins: [createPinia()]
}
})flushPromises из @vue/test-utils ожидает завершения всех очередей промисов. Это критично для тестирования компонентов с async/await и onMounted:
import { flushPromises } from '@vue/test-utils'
it('загружает список после монтирования', async () => {
const wrapper = mount(PostList)
// Компонент только что смонтирован, onMounted ещё выполняется
expect(wrapper.find('[data-test="loading"]').exists()).toBe(true)
// Ждём все промисы
await flushPromises()
// Теперь данные загружены
expect(wrapper.find('[data-test="loading"]').exists()).toBe(false)
expect(wrapper.findAll('[data-test="post-item"]')).toHaveLength(3)
})Покрытие показывает какие строки кода выполнялись в тестах. Установка @vitest/coverage-v8:
npm install -D @vitest/coverage-v8Конфигурация в vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
exclude: ['src/test/**', '**/*.test.ts', 'src/main.ts']
}
}
})Запуск: npm run test:coverage. Отчёт в coverage/index.html показывает непокрытые строки. Но помните: 100% покрытие — не цель. Тест который просто "проходит через" код без проверок создаёт ложное ощущение безопасности.
Playwright — современный инструмент для e2e тестов. Поддерживает Chromium, Firefox, WebKit:
npm init playwright@latestПервый e2e тест:
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
test('пользователь может войти в систему', async ({ page }) => {
await page.goto('/login')
// Заполняем форму
await page.fill('[data-test="email-input"]', 'user@example.com')
await page.fill('[data-test="password-input"]', 'password123')
// Нажимаем кнопку
await page.click('[data-test="submit-btn"]')
// Проверяем результат
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('[data-test="user-menu"]')).toBeVisible()
})
test('показывает ошибку при неверном пароле', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-test="email-input"]', 'user@example.com')
await page.fill('[data-test="password-input"]', 'wrongpass')
await page.click('[data-test="submit-btn"]')
await expect(page.locator('.error-message')).toContainText('Неверный пароль')
})Запуск: npx playwright test. Интерактивный режим: npx playwright test --ui.
Важный паттерн: используйте атрибуты data-test="..." для тестовых селекторов. Не привязывайтесь к CSS-классам — они меняются при рефакторинге стилей.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.