Тестирование компонентов

Пишем автотесты для Vue-компонентов: монтируем компонент в памяти, кликаем, вводим текст и проверяем, что он ведёт себя как задумано.

Тест компонента монтирует его в изолированной среде, имитирует действия пользователя (клик, ввод) и проверяет результат — без реального браузера и ручного клика, автоматически на каждый коммит.

Ручная проверка не масштабируется: после каждой правки заново кликать все формы невозможно. Тесты делают это за вас и ловят регрессии — когда новая фича незаметно ломает старую. Связка Vitest (быстрый тест-раннер, родной для Vite-проектов) и Vue Test Utils (официальная библиотека для монтирования компонентов) — стандарт для Vue 3. Этот урок — про то, что тестировать, как монтировать и как работать с асинхронностью.

Что вообще тестировать

Главный принцип: тестируйте поведение, а не реализацию. Пользователю всё равно, как у вас называется внутренняя переменная; ему важно, что после клика по кнопке счётчик показал на единицу больше. Поэтому хороший тест работает с компонентом как пользователь: смотрит на отрендеренный текст, нажимает кнопки, вводит данные — и проверяет видимый результат.

  • Стоит тестировать: что компонент рендерит из props, как реагирует на клики/ввод, какие события эмитит, как показывает разные ветки (загрузка/ошибка/пусто).
  • Не стоит: внутренние имена переменных, точную разметку до тега, сам Vue (он уже протестирован).

Монтирование компонента

Тест начинается с монтирования. Функция mount создаёт экземпляр компонента в памяти (через jsdom — эмуляцию DOM в Node) и возвращает «обёртку» wrapper с методами для поиска элементов и проверок. Через опцию props вы передаёте входные данные.

import { mount } from "@vue/test-utils"
import { describe, it, expect } from "vitest"
import Greeting from "./Greeting.vue"

describe("Greeting", () => {
  it("показывает имя из props", () => {
    const wrapper = mount(Greeting, {
      props: { name: "Аня" }
    })
    // проверяем видимый текст, а не внутренности
    expect(wrapper.text()).toContain("Привет, Аня")
  })
})

Здесь wrapper.text() возвращает весь видимый текст компонента. Есть и точечные поиски: wrapper.find('button') найдёт элемент по селектору, wrapper.get('.title') — то же, но бросит ошибку, если не найдено. Для устойчивых тестов часто ищут по data-test-атрибуту: find('[data-test="submit"]') — он не привязан к классам и тексту, которые меняются от дизайна.

Эмуляция событий

Действия пользователя имитируют методом trigger (клик, ввод клавиши) и setValue (для полей ввода). Важнейшая деталь: после действия DOM обновляется асинхронно (помним из урока про virtual DOM — обновления идут через очередь), поэтому методы возвращают Promise, и его надо await.

import { mount } from "@vue/test-utils"
import { it, expect } from "vitest"
import Counter from "./Counter.vue"

it("увеличивает счётчик по клику", async () => {
  const wrapper = mount(Counter)
  expect(wrapper.text()).toContain("0")

  await wrapper.find("button").trigger("click")  // ждём обновления DOM

  expect(wrapper.text()).toContain("1")
})

Забыли await — проверите старый DOM и получите ложный провал теста. Это самая частая ошибка новичков в тестировании Vue: тест «мигает» (то проходит, то нет), потому что проверка обгоняет обновление.

Проверка испускаемых событий

Если компонент эмитит событие наружу (через emit), тест ловит его методом emitted(). Это проверяет тот самый контракт «ребёнок шлёт родителю такое-то событие с таким payload».

import { mount } from "@vue/test-utils"
import { it, expect } from "vitest"
import SearchBox from "./SearchBox.vue"

it("эмитит search с введённым текстом", async () => {
  const wrapper = mount(SearchBox)

  await wrapper.find("input").setValue("vue")
  await wrapper.find("form").trigger("submit")

  // emitted().search — массив вызовов; берём первый и его аргументы
  expect(wrapper.emitted().search[0]).toEqual(["vue"])
})

wrapper.emitted() возвращает объект, где ключ — имя события, а значение — массив всех его вызовов (каждый вызов — массив аргументов). Так проверяют и факт события, и его payload.

Асинхронность: данные, таймеры, ожидания

Компоненты часто грузят данные. В тестах реальную сеть не дёргают — её мокают (подменяют заглушкой), чтобы тест был быстрым и стабильным. Vitest даёт vi.fn() для функций-заглушек и vi.mock() для подмены модулей. После того как «данные пришли», DOM снова обновляется асинхронно, и нужно дождаться этого через await wrapper.vm.$nextTick() или просто await flushPromises().

import { mount, flushPromises } from "@vue/test-utils"
import { it, expect, vi } from "vitest"
import UserCard from "./UserCard.vue"

it("показывает имя после загрузки", async () => {
  // подменяем загрузчик заглушкой, возвращающей готовые данные
  const loadUser = vi.fn().mockResolvedValue({ name: "Иван" })
  const wrapper = mount(UserCard, { props: { loadUser } })

  await flushPromises()           // ждём, пока промисы и обновления DOM осядут

  expect(loadUser).toHaveBeenCalledOnce()
  expect(wrapper.text()).toContain("Иван")
})

flushPromises() дожидается завершения всех «висящих» промисов и связанных обновлений — это самый надёжный способ протестировать загрузку. А toHaveBeenCalledOnce() проверяет, что заглушку дёрнули ровно один раз: заодно ловим случайные двойные запросы.

Как это работает под капотом

Реального браузера в тестах нет. Vitest по умолчанию поднимает jsdom — реализацию DOM API на чистом JavaScript внутри Node. Для Vue этого достаточно: он работает с DOM через стандартные методы, а jsdom их предоставляет, поэтому компонент честно «монтируется», создаёт узлы и реагирует на события. Чего jsdom не делает — это реальная отрисовка и геометрия: размеры, скролл и анимации он не считает, для такого нужны браузерные тесты (Playwright/Cypress).

Асинхронность тестов — прямое следствие очереди обновлений Vue из первого урока раздела. trigger и setValue ставят изменение в очередь и возвращают Promise, который резолвится после её прогона. Поэтому await в тесте — это не формальность, а синхронизация с тем самым асинхронным циклом обновления, который делает Vue быстрым.

Частые ошибки

  • Забытый await перед trigger/setValue. Проверка обгоняет обновление DOM — тест «мигает». Почти все асинхронные методы wrapper надо ждать.
  • Тестирование реализации. Проверка внутренних переменных вместо видимого результата делает тесты хрупкими: безобидный рефакторинг их ломает.
  • Реальные сетевые запросы в тестах. Медленно и нестабильно (сеть падает, данные меняются). Мокайте через vi.fn()/vi.mock().
  • Поиск по тексту и классам, которые меняет дизайн. Привязывайтесь к data-test-атрибутам — они стабильнее.
  • Ожидание реальной геометрии (размеры, скролл) от jsdom. Он их не считает; для визуальных проверок нужны браузерные E2E-тесты.

Итоги

  • Тестируйте поведение: рендер из props, реакцию на клики/ввод, испускаемые события — а не внутреннюю реализацию.
  • mount монтирует компонент в jsdom и возвращает wrapper с text(), find(), get() для проверок.
  • Действия имитируют trigger и setValue; их результат асинхронный, поэтому обязателен await.
  • События проверяют через wrapper.emitted(), а асинхронную загрузку — мокая зависимости (vi.fn()) и дожидаясь flushPromises().
  • Тесты идут в jsdom без реального браузера; для геометрии и визуала нужны E2E-инструменты вроде Playwright.
Проверьте себя
1. Почему перед проверкой результата нужно ставить await перед wrapper.find('button').trigger('click')?
Atrigger всегда выбрасывает исключение без await
BDOM во Vue обновляется асинхронно через очередь; trigger возвращает Promise, и без await проверка обгонит обновление и тест начнёт мигать
CБез await клик попадёт в соседний элемент
Dawait ускоряет работу jsdom
2. Как в тесте проверить, что компонент испустил событие search с аргументом 'vue'?
AПрочитать внутреннюю переменную компонента через wrapper.vm.search
Bwrapper.emitted().search[0] вернёт аргументы первого вызова события — сравнить с ['vue']
CСобытие в тестах поймать нельзя, нужен реальный браузер
DВызвать wrapper.trigger('search') и проверить возврат
3. Почему сетевые запросы в тестах компонентов мокают, а не выполняют по-настоящему?
AVitest технически не умеет делать сетевые запросы
BРеальная сеть медленная и нестабильная (падает, данные меняются); заглушки через vi.fn()/vi.mock() делают тест быстрым и детерминированным
CМок автоматически рендерит компонент без mount
DБез мока компонент не смонтируется в jsdom
4. Что Vitest использует вместо реального браузера и в чём ограничение этого подхода?
AРеальный Chrome в фоне; ограничений нет
Bjsdom — реализацию DOM API на JavaScript в Node; он не считает реальную геометрию (размеры, скролл, анимации), для этого нужны E2E-тесты
CСкриншоты страницы; ограничение — нельзя кликать
DТолько статический анализ шаблона без монтирования