Как тестировать Vue-компоненты с Vue Test Utils и Vitest?
Вопрос
Как устроено тестирование Vue-компонентов с помощью Vue Test Utils и Vitest — монтирование, симуляция событий, проверка рендера, разница между shallowMount и mount?
Короткий ответ
Vue Test Utils — официальная библиотека для монтирования Vue-компонентов в тестах в изолированном окружении (обычно с Vitest как test runner'ом). Компонент монтируется через mount() (с рендером всех дочерних компонентов) или shallowMount() (дочерние компоненты заменяются заглушками-стабами), после чего можно симулировать пользовательские действия (trigger, setValue) и проверять итоговый DOM или эмитированные события.
Подробный ответ
Монтирование. mount(Component, options) создаёт «обёртку» (VueWrapper) вокруг реального экземпляра компонента, отрендеренного в виртуальный DOM (через happy-dom/jsdom). Через options передаются props, slots, global.plugins (для подключения Pinia, роутера и т.д. в тестовом окружении).
mount vs shallowMount.
mount— рендерит компонент вместе со всеми дочерними компонентами по-настоящему. Подходит для интеграционных тестов, где важно поведение всего дерева.shallowMount— рендерит только сам компонент, а все дочерние компоненты заменяет заглушками-стабами (пустыми тегами с тем же именем). Это изолирует тест от деталей реализации дочерних компонентов — полезно для юнит-тестов, когда интересует только логика конкретного компонента, а не его детей (и когда дети сами по себе тяжёлые или требуют своей настройки).
Симуляция событий. wrapper.find('button').trigger('click') эмулирует DOM-событие; для полей ввода — wrapper.find('input').setValue('текст'), что также триггерит input/change и обновляет v-model. Оба метода асинхронны и должны быть awaited, чтобы Vue успел прогнать цикл обновления DOM (await nextTick() под капотом).
Проверка результата.
wrapper.text()/wrapper.html()— проверка отрендеренного содержимого.wrapper.find(selector).exists()— проверка наличия элемента.wrapper.emitted('event-name')— проверка, какие события и с какими аргументами эмитировал компонент (для юнит-теста дочерних компонентов, не завязанного на реального родителя).wrapper.props()/wrapper.setProps({...})— чтение и изменение пропов компонента в рамках теста.
Обычно тестируют поведение, а не детали реализации: не «вызвался ли метод X», а «после клика на экране появился текст Y» или «эмитировалось событие с таким-то payload'ом» — это делает тесты устойчивыми к рефакторингу внутренней структуры компонента.
Пример
<!-- Counter.vue -->
<script setup>
import { ref } from 'vue'
const props = defineProps({ initial: { type: Number, default: 0 } })
const emit = defineEmits(['limit-reached'])
const count = ref(props.initial)
function increment() {
count.value++
if (count.value >= 5) emit('limit-reached')
}
</script>
<template>
<button @click="increment">Счётчик: {{ count }}</button>
</template>
// Counter.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('увеличивает значение по клику', async () => {
const wrapper = mount(Counter, { props: { initial: 0 } })
expect(wrapper.text()).toContain('Счётчик: 0')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Счётчик: 1')
})
it('эмитит limit-reached при достижении 5', async () => {
const wrapper = mount(Counter, { props: { initial: 4 } })
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('limit-reached')).toHaveLength(1)
})
})
Дополнительные вопросы
- Когда стоит выбрать
shallowMountвместоmount, и какие риски у полногоmountдерева компонентов? - Как протестировать компонент, использующий Pinia store или Vue Router?
- Чем
wrapper.emitted()полезнее, чем проверка внутреннего состояния компонента напрямую?