Навигация

Тестирование Vue

Тестирование компонентов позволяет уверенно рефакторить интерфейс и выявлять регрессии до попадания кода в прод. В статье разбираем Vue Test Utils — официальную библиотеку для монтирования и взаимодействия с Vue-компонентами в изолированном окружении. Вы научитесь настраивать Vitest, писать тесты пропсов, событий, слотов, асинхронной логики, Pinia и Vue Router, а также избегать типичных хрупких проверок.

Содержание

  1. Что такое Vue Test Utils
  2. Философия: тестируйте как пользователь
  3. Установка и настройка
  4. mount — рендеринг компонентов
  5. find / findAll — поиск элементов
  6. trigger — события
  7. Тестирование пропсов
  8. Тестирование emits
  9. Тестирование слотов
  10. Тестирование v-model
  11. Тестирование асинхронных компонентов
  12. Тестирование composables
  13. Тестирование с Pinia
  14. Тестирование с Vue Router
  15. Тестирование provide / inject
  16. Кастомные mount-обёртки
  17. Лучшие практики
  18. Антипаттерны
  19. Ключевые тезисы для интервью
  20. Заключение
  21. Полезные ссылки

Что такое Vue Test Utils

Vue Test Utils (VTU) — официальная библиотека для тестирования Vue-компонентов. Она предоставляет API для монтирования компонентов в изолированном окружении, поиска элементов, имитации событий и взаимодействия с инстансом компонента.

Ключевые отличия от React Testing Library

ХарактеристикаVue Test UtilsReact Testing Library
ФилософияТестировать поведение, но иметь доступ к инстансуТестировать только поведение пользователя
Доступ к инстансу✅ Да (wrapper.vm)❌ Нет
Поиск элементовfind, findAll, findComponentscreen.getBy*, screen.queryBy*
СобытияtriggeruserEvent, fireEvent
Компоненты-обёрткиglobal.plugins, global.providewrapper в renderHook, кастомный render
Доступ к emitswrapper.emitted()Моки колбэков

💡 Vue Test Utils даёт больше доступа к внутреннему состоянию компонента, чем React Testing Library. Это мощный инструмент, но им нужно пользоваться осторожно: тесты, завязанные на wrapper.vm, становятся хрупкими при рефакторинге.


Философия: тестируйте как пользователь

Главный принцип тестирования компонентов одинаков для Vue и React: тестируйте то, что видит и делает пользователь, а не внутреннее устройство компонента.

<!-- Counter.vue -->
<template>
  <button @click="increment">Count: {{ count }}</button>
</template>

<script setup>
import { ref } from "vue";

const count = ref(0);

function increment() {
  count.value++;
}
</script>
// ❌ Тестирует внутреннее состояние — хрупкий тест
it("increments internal count", () => {
  const wrapper = mount(Counter);
  wrapper.vm.count++;
  expect(wrapper.vm.count).toBe(1);
});

// ✅ Тестирует поведение пользователя — устойчивый тест
it("increments count on button click", async () => {
  const wrapper = mount(Counter);
  await wrapper.find("button").trigger("click");
  expect(wrapper.find("button").text()).toBe("Count: 1");
});

Что проверяет пользователь

Пользователь не знает о ref, reactive, computed, emits. Он видит:

  • Текст на странице
  • Кнопки и интерактивные элементы
  • Поля ввода и их значения
  • Изменения после взаимодействия

Тесты должны проверять то же самое.


Установка и настройка

Для тестирования Vue-компонентов нужны три пакета: сам Vue Test Utils, Vitest как тестовый раннер и DOM-окружение (jsdom или happy-dom).

npm install -D @vue/test-utils vitest jsdom

Конфигурация Vitest для Vue

Vitest использует Vite, поэтому для .vue файлов нужен @vitejs/plugin-vue. Окружение jsdom эмулирует браузерный DOM в Node.js.

// vitest.config.ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: "jsdom",
    globals: true,
  },
});

happy-dom vs jsdom

Характеристикаjsdomhappy-dom
СкоростьМедленнееБыстрее
СовместимостьВышеНиже, но достаточна для большинства тестов
РесурсыБольшеМеньше
Когда использоватьСложные DOM-операции, сторонние библиотекиОбычные компонентные тесты
npm install -D happy-dom
// vitest.config.ts
export default defineConfig({
  plugins: [vue()],
  test: {
    environment: "happy-dom",
  },
});

mount — рендеринг компонентов

mount — главная функция Vue Test Utils. Она создаёт инстанс компонента, рендерит его в виртуальный DOM и возвращает wrapper — объект с методами для поиска элементов и доступа к компоненту.

Базовый mount

import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";

it("renders counter", () => {
  const wrapper = mount(Counter);
  expect(wrapper.find("button").exists()).toBe(true);
});

Что возвращает mount

const wrapper = mount(MyComponent, {
  props: { title: "Hello" },
});

wrapper.element;      // Корневой DOM-элемент
wrapper.html();       // HTML-строка компонента
wrapper.text();       // Текстовое содержимое
wrapper.vm;           // Инстанс компонента (осторожно)
wrapper.find("...");  // Поиск элемента
wrapper.findComponent(Component); // Поиск дочернего компонента
wrapper.emitted();    // События, которые эмитнул компонент
wrapper.unmount();    // Размонтировать компонент

Передача пропсов

const wrapper = mount(Greeting, {
  props: {
    name: "Alice",
  },
});

Глобальные плагины и провайдеры

const wrapper = mount(MyComponent, {
  global: {
    plugins: [pinia],
    provide: {
      theme: "dark",
    },
    stubs: ["FontAwesomeIcon"],
  },
});

find / findAll — поиск элементов

VTU предоставляет несколько способов поиска элементов в DOM. Предпочитайте семантические селекторы: по тегу, роли, тексту. CSS-классы — последний резерв.

Типы селекторов

МетодОписаниеПример
find("button")По CSS-селекторуwrapper.find("button")
find("[data-testid='submit']")По data-testidwrapper.find('[data-testid="submit"]')
findComponent(Component)По компонентуwrapper.findComponent(Child)
findAll("li")Все совпадающие элементыwrapper.findAll("li")

getByRole через DOM-методы

VTU не имеет встроенного getByRole, но можно использовать wrapper.find с селекторами ролей или подключить @testing-library/vue.

npm install -D @testing-library/vue
import { render, screen } from "@testing-library/vue";

it("finds button by role", () => {
  render(Counter);
  expect(screen.getByRole("button", { name: /count/i })).toBeInTheDocument();
});

💡 @testing-library/vue — это обёртка над Vue Test Utils с философией RTL. Если вы привыкли к React Testing Library, переход будет плавным.

Проверка наличия и отсутствия

// Элемент есть в DOM
expect(wrapper.find(".error").exists()).toBe(true);

// Элемента нет
expect(wrapper.find(".error").exists()).toBe(false);

// Текст содержится
expect(wrapper.text()).toContain("Error message");

// Количество элементов
expect(wrapper.findAll("li")).toHaveLength(3);

trigger — события

trigger имитирует DOM-события. Он возвращает Promise, поэтому события нужно await. В отличие от fireEvent в RTL, trigger отправляет одно событие — для полной имитации пользовательского ввода иногда нужно отправлять несколько событий последовательно.

Клик по кнопке

it("increments count on click", async () => {
  const wrapper = mount(Counter);
  await wrapper.find("button").trigger("click");
  expect(wrapper.find("button").text()).toBe("Count: 1");
});

Ввод текста

it("updates input value", async () => {
  const wrapper = mount(SearchInput);
  const input = wrapper.find("input");

  await input.setValue("hello");

  expect(input.element.value).toBe("hello");
});

💡 setValue — удобный метод VTU, который устанавливает value и триггерит input событие. Это аналог userEvent.type в React Testing Library.

Отправка формы

it("submits form", async () => {
  const wrapper = mount(LoginForm);
  await wrapper.find("input[type='email']").setValue("user@example.com");
  await wrapper.find("form").trigger("submit");

  expect(wrapper.emitted("submit")).toBeTruthy();
});

Чекбокс

it("toggles checkbox", async () => {
  const wrapper = mount(AgreementCheckbox);
  const checkbox = wrapper.find("input[type='checkbox']");

  expect(checkbox.element.checked).toBe(false);

  await checkbox.setValue(true);

  expect(checkbox.element.checked).toBe(true);
});

Выбор из select

it("selects option", async () => {
  const wrapper = mount(CountrySelect);
  const select = wrapper.find("select");

  await select.setValue("us");

  expect(select.element.value).toBe("us");
});

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

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

<!-- Alert.vue -->
<template>
  <div :class="['alert', `alert--${type}`]">
    {{ message }}
  </div>
</template>

<script setup>
defineProps({
  message: String,
  type: {
    type: String,
    default: "info",
  },
});
</script>
it("renders message prop", () => {
  const wrapper = mount(Alert, {
    props: { message: "Hello" },
  });
  expect(wrapper.text()).toBe("Hello");
});

it("applies type class", () => {
  const wrapper = mount(Alert, {
    props: { message: "Error", type: "error" },
  });
  expect(wrapper.find(".alert--error").exists()).toBe(true);
});

Обновление пропсов

it("updates when props change", async () => {
  const wrapper = mount(Greeting, {
    props: { name: "Alice" },
  });

  expect(wrapper.text()).toContain("Alice");

  await wrapper.setProps({ name: "Bob" });

  expect(wrapper.text()).toContain("Bob");
});

Тестирование emits

Компоненты Vue общаются с родителями через события (emits). VTU отслеживает все события через wrapper.emitted().

<!-- SearchForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="query" />
    <button type="submit">Search</button>
  </form>
</template>

<script setup>
import { ref } from "vue";

const query = ref("");
const emit = defineEmits(["search"]);

function handleSubmit() {
  emit("search", query.value);
}
</script>
it("emits search event with query", async () => {
  const wrapper = mount(SearchForm);

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

  expect(wrapper.emitted("search")).toHaveLength(1);
  expect(wrapper.emitted("search")[0]).toEqual(["react"]);
});

Проверка отсутствия события

it("does not emit when input is empty", async () => {
  const wrapper = mount(SearchForm);
  await wrapper.find("form").trigger("submit");

  expect(wrapper.emitted("search")).toBeFalsy();
});

Тестирование слотов

Слоты — мощный механизм композиции во Vue. Тесты должны проверять, что компонент корректно рендерит переданный контент.

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card__header">
      <slot name="header" />
    </div>
    <div class="card__body">
      <slot />
    </div>
  </div>
</template>
it("renders default and named slots", () => {
  const wrapper = mount(Card, {
    slots: {
      default: "Card content",
      header: "Card title",
    },
  });

  expect(wrapper.text()).toContain("Card title");
  expect(wrapper.text()).toContain("Card content");
});

Слоты с пропсами (scoped slots)

it("renders scoped slot", () => {
  const wrapper = mount(DataTable, {
    slots: {
      row: `<template #row="{ item }">{{ item.name }}</template>`,
    },
  });

  expect(wrapper.text()).toContain("Alice");
});

Тестирование v-model

v-model — синтаксический сахар для пропса modelValue и события update:modelValue. В тестах нужно передать modelValue и проверить, что компонент эмитит update:modelValue.

<!-- TextInput.vue -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

<script setup>
defineProps({
  modelValue: String,
});

defineEmits(["update:modelValue"]);
</script>
it("emits update:modelValue on input", async () => {
  const wrapper = mount(TextInput, {
    props: {
      modelValue: "",
      "onUpdate:modelValue": (value) => wrapper.setProps({ modelValue: value }),
    },
  });

  await wrapper.find("input").setValue("hello");

  expect(wrapper.emitted("update:modelValue")).toHaveLength(1);
  expect(wrapper.emitted("update:modelValue")[0]).toEqual(["hello"]);
});

💡 В Vue 3 v-model можно кастомизировать через defineModel, что упрощает тестирование — не нужно вручную связывать пропс и событие.


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

Асинхронные операции — запросы к API, таймеры, анимации — требуют ожидания. В Vue Test Utils для этого используется flushPromises.

import { flushPromises } from "@vue/test-utils";

Компонент с загрузкой данных

<!-- UserList.vue -->
<template>
  <div>
    <div v-if="loading">Loading...</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";

const users = ref([]);
const loading = ref(true);

onMounted(async () => {
  const response = await fetch("/api/users");
  users.value = await response.json();
  loading.value = false;
});
</script>

Тест с MSW

it("displays users after loading", async () => {
  server.use(
    http.get("/api/users", () =>
      HttpResponse.json([
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
      ])
    )
  );

  const wrapper = mount(UserList);

  expect(wrapper.text()).toContain("Loading...");

  await flushPromises();

  expect(wrapper.text()).toContain("Alice");
  expect(wrapper.text()).toContain("Bob");
  expect(wrapper.text()).not.toContain("Loading...");
});

Ожидание через find и retry

it("waits for element", async () => {
  const wrapper = mount(AsyncComponent);

  await vi.waitFor(() => {
    expect(wrapper.find(".loaded").exists()).toBe(true);
  });
});

Тестирование composables

Composables — аналог кастомных хуков в React. Их можно тестировать напрямую через mount в тестовом компоненте или с помощью @vue/test-utils + вспомогательного компонента.

Через тестовый компонент

import { mount } from "@vue/test-utils";
import { defineComponent } from "vue";
import { useCounter } from "./useCounter";

it("useCounter increments", async () => {
  const TestComponent = defineComponent({
    setup() {
      return useCounter();
    },
    template: "<button @click=\"increment\">{{ count }}</button>",
  });

  const wrapper = mount(TestComponent);

  expect(wrapper.text()).toBe("0");

  await wrapper.find("button").trigger("click");

  expect(wrapper.text()).toBe("1");
});

Через @vue/composition-api-test-utils

В небольших проектах удобно создавать вспомогательный компонент прямо в тесте. Для сложных composables лучше выносить логику в чистые функции и тестировать их отдельно.


Тестирование с Pinia

Pinia — стандарт управления состоянием во Vue 3. Для тестирования нужно создать тестовый store и передать его в global.plugins.

import { setActivePinia, createPinia } from "pinia";
import { useCartStore } from "./stores/cart";

beforeEach(() => {
  setActivePinia(createPinia());
});

it("adds item to cart", () => {
  const cart = useCartStore();
  cart.addItem({ id: 1, name: "Book", price: 100 });

  expect(cart.items).toHaveLength(1);
  expect(cart.total).toBe(100);
});

Компонент с Pinia

import { createPinia } from "pinia";

it("displays cart total", () => {
  const pinia = createPinia();
  const wrapper = mount(CartBadge, {
    global: {
      plugins: [pinia],
    },
  });

  const cart = useCartStore();
  cart.addItem({ id: 1, name: "Book", price: 100 });

  expect(wrapper.text()).toContain("100");
});

Мокирование store

import { createTestingPinia } from "@pinia/testing";

it("calls store action on click", async () => {
  const wrapper = mount(AddToCart, {
    props: { productId: 1 },
    global: {
      plugins: [
        createTestingPinia({
          stubActions: false,
        }),
      ],
    },
  });

  const cart = useCartStore();

  await wrapper.find("button").trigger("click");

  expect(cart.addItem).toHaveBeenCalledWith(1);
});

💡 @pinia/testing позволяет создавать мок-сторы. stubActions: true заменяет actions на vi.fn(), stubActions: false вызывает реальные actions.


Тестирование с Vue Router

Для тестирования компонентов, зависящих от роутера, используйте createRouter с createWebHistory или createMemoryHistory.

import { createRouter, createWebHistory } from "vue-router";
import { routes } from "./router";

it("renders route component", async () => {
  const router = createRouter({
    history: createWebHistory(),
    routes,
  });

  router.push("/about");
  await router.isReady();

  const wrapper = mount(App, {
    global: {
      plugins: [router],
    },
  });

  expect(wrapper.text()).toContain("About us");
});

Мокирование useRoute / useRouter

import { useRoute, useRouter } from "vue-router";

vi.mock("vue-router", async () => {
  const actual = await vi.importActual("vue-router");
  return {
    ...actual,
    useRoute: vi.fn(),
    useRouter: vi.fn(),
  };
});

it("reads query param", () => {
  useRoute.mockReturnValue({
    query: { q: "react" },
  });

  const wrapper = mount(SearchResults);

  expect(wrapper.text()).toContain("react");
});

Тестирование provide / inject

provide/inject используется для передачи зависимость через дерево компонентов без пропс-дриллинга.

it("uses injected value", () => {
  const wrapper = mount(ThemedButton, {
    global: {
      provide: {
        theme: "dark",
      },
    },
  });

  expect(wrapper.find("button").classes()).toContain("btn--dark");
});

Кастомные mount-обёртки

Если многие компоненты требуют одних и тех же плагинов (Pinia, router, i18n), создайте кастомную функцию mount.

// test-utils.ts
import { mount as vueMount } from "@vue/test-utils";
import { createPinia } from "pinia";
import { createRouter, createWebHistory } from "vue-router";
import { routes } from "../src/router";

export function mount(component, options = {}) {
  const pinia = createPinia();
  const router = createRouter({
    history: createWebHistory(),
    routes,
  });

  return vueMount(component, {
    global: {
      plugins: [pinia, router],
    },
    ...options,
  });
}
// MyComponent.test.ts
import { mount } from "../test-utils";
import MyComponent from "./MyComponent.vue";

it("renders", () => {
  const wrapper = mount(MyComponent);
  expect(wrapper.exists()).toBe(true);
});

Лучшие практики

1. Тестируйте поведение, а не реализацию

// ❌ Хрупкий тест
expect(wrapper.vm.isOpen).toBe(true);

// ✅ Устойчивый тест
expect(wrapper.find(".modal").isVisible()).toBe(true);

2. Используйте data-testid как последний резерв

// ❌ Зависит от CSS-класса
wrapper.find(".btn-primary");

// ✅ Семантический поиск
wrapper.find("button");

// ✅ Когда нет другого способа
wrapper.find('[data-testid="submit"]');

3. Очищайте DOM и моки между тестами

// vitest.setup.ts
import { cleanup } from "@vue/test-utils";
import { afterEach, vi } from "vitest";

afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

4. Используйте setValue для форм

// ✅ Просто и надёжно
await wrapper.find("input").setValue("hello");

// ❌ Нужно вручную обновлять value и триггерить событие
wrapper.find("input").element.value = "hello";
await wrapper.find("input").trigger("input");

5. Один тест — одна проверка одного поведения

// ❌ Слишком много в одном тесте
it("handles form", async () => { ... });

// ✅ Разбито
it("shows error for empty email", async () => { ... });
it("emits submit with valid data", async () => { ... });

6. Мокируйте внешние зависимости через MSW

server.use(
  http.get("/api/users", () =>
    HttpResponse.json([{ id: 1, name: "Alice" }])
  )
);

Антипаттерны

1. Тестирование внутреннего состояния

// ❌ Зависит от ref
expect(wrapper.vm.count).toBe(1);

// ✅ Проверяет UI
expect(wrapper.find("button").text()).toBe("Count: 1");

2. Использование querySelector и CSS-классов

// ❌ Хрупкий селектор
expect(wrapper.find("div > button.btn").exists()).toBe(true);

// ✅ Стабильный селектор
expect(wrapper.find("button").exists()).toBe(true);

3. Snapshot-тестирование всего компонента

// ❌ Ломается при любом изменении разметки
expect(wrapper.html()).toMatchSnapshot();

// ✅ Snapshot только для стабильных частей
expect(wrapper.find("svg").html()).toMatchSnapshot();

4. Зависимость тестов друг от друга

// ❌ Тесты делят состояние
const wrapper = mount(Counter);

it("increments", async () => {
  await wrapper.find("button").trigger("click");
  expect(wrapper.text()).toBe("Count: 1");
});

it("increments again", async () => {
  // Зависит от предыдущего теста
  await wrapper.find("button").trigger("click");
  expect(wrapper.text()).toBe("Count: 2");
});

// ✅ Каждый тест изолирован
it("increments from zero to one", async () => {
  const wrapper = mount(Counter);
  await wrapper.find("button").trigger("click");
  expect(wrapper.text()).toBe("Count: 1");
});

5. Игнорирование асинхронности

// ❌ Не дожидается обновления
it("loads data", () => {
  const wrapper = mount(UserList);
  expect(wrapper.text()).toContain("Alice");
});

// ✅ Дожидается завершения
it("loads data", async () => {
  const wrapper = mount(UserList);
  await flushPromises();
  expect(wrapper.text()).toContain("Alice");
});

6. Избыточное мокирование

// ❌ Мокирует Vue
vi.mock("vue", () => ({ ref: vi.fn() }));

// ✅ Мокирует только внешние зависимости
server.use(http.get("/api/data", () => HttpResponse.json({ data: [] })));

7. Тесты без assertions

// ❌ Тест всегда проходит
it("renders component", () => {
  mount(MyComponent);
});

// ✅ Есть проверка
it("renders component", () => {
  const wrapper = mount(MyComponent);
  expect(wrapper.text()).toContain("Hello");
});

Ключевые тезисы для интервью

  • Vue Test Utils предоставляет wrapper с доступом к DOM и инстансу, но предпочтительнее проверять поведение пользователя, а не wrapper.vm; mount создаёт изолированный инстанс, в global передаются плагины, provide, stubs и моки.
  • Для поиска элементов используют find, findAll, findComponent; семантические селекторы надёжнее CSS-классов, а data-testid — последний резерв; trigger имитирует DOM-события, setValue упрощает работу с полями ввода.
  • Пропсы проверяются через рендеринг, классы и текст, setProps позволяет проверить реакцию на изменения; события проверяют через wrapper.emitted(), а v-model тестируют через связку modelValue и update:modelValue.
  • Слоты передаются через опцию slots (именованные и scoped); асинхронные тесты требуют flushPromises или vi.waitFor, чтобы дождаться обновления DOM.
  • Pinia тестируют с createPinia/createTestingPinia, Vue Router — через создание роутера и передачу в global.plugins; provide/inject задаётся в global.provide, повторяющиеся зависимости выносят в кастомную функцию mount.
  • Антипаттерны: завязка на внутреннее состояние, сложные CSS-селекторы, зависимые друг от друга тесты, игнорирование асинхронности, избыточное мокирование, snapshot всего компонента и тесты без assertions.

Заключение

Vue Test Utils даёт удобный API для компонентного тестирования, но его мощь — доступ к инстансу — легко превращается в хрупкие тесты. Основная цель: проверять то, что видит пользователь, — текст, кнопки, формы и эмитированные события. Для сложных сценариев используйте flushPromises, кастомные mount-обёртки и createTestingPinia. Настройте cleanup и clearAllMocks в afterEach, мокируйте API через MSW и выбирайте семантические селекторы. Регулярная практика поможет писать устойчивые тесты, которые не ломаются при рефакторинге.

Полезные ссылки