Как тестировать Vue-компоненты с Vue Test Utils и Vitest?

MiddleVue.js #vue #testing

Вопрос

Как устроено тестирование 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() полезнее, чем проверка внутреннего состояния компонента напрямую?