Тестирование во Vue: виды тестов и как писать unit-тесты

Тесты — это код, который проверяет ваш код. Вы один раз описываете, как функция или компонент должны себя вести, и дальше программа сама проверяет это при каждом запуске. Сломали что-то рефакторингом — тест сразу краснеет, и вы узнаёте о баге до того, как его увидит пользователь.

В этой статье разберём тестирование фронтенда на примере Vue: что такое unit-тесты, какие ещё бывают виды тестов, и как писать их правильно. Все примеры — на Vitest и Vue Test Utils: это стандартный современный стек для Vue 3.

Зачем вообще писать тесты

Тесты решают несколько практических задач:

  • Защита от регрессий. Меняете одно — не ломаете другое: тесты ловят случайные поломки.
  • Уверенность при рефакторинге. Можно смело переписывать внутренности, пока тесты зелёные.
  • Живая документация. Хороший тест читается как описание поведения: «при клике счётчик увеличивается».
  • Меньше ручной проверки. Не нужно каждый раз вручную кликать по интерфейсу, чтобы убедиться, что всё работает.

Главное правило: тестируем поведение, а не реализацию. Тест должен проверять «что видит и делает пользователь», а не то, какие внутренние переменные вы назвали. Тогда тесты не ломаются от любого рефакторинга и приносят пользу, а не боль.

Пирамида тестирования: какие бывают тесты

Тесты во фронтенде принято делить на уровни. Их часто рисуют в виде «пирамиды»: чем ниже уровень, тем тестов больше, тем они быстрее и дешевле.

  • Unit-тесты (модульные) — проверяют маленькую изолированную часть: одну функцию, один composable, один компонент. Их много, они быстрые.
  • Интеграционные тесты — проверяют, как несколько частей работают вместе: компонент + хранилище (Pinia), форма + валидация.
  • E2E-тесты (end-to-end) — проверяют приложение целиком в настоящем браузере, как живой пользователь: «открыл страницу → заполнил форму → нажал отправить → увидел результат». Их мало, они медленные, но дают максимум уверенности.

Отдельно во Vue выделяют компонентные тесты — это что-то между unit и e2e: рендерим один компонент, но в настоящем браузере (например, через Cypress или Playwright Component Testing). Для большинства задач хватает unit-тестов на Vitest — с них и стоит начинать.

Что такое unit-тест на практике

Unit-тест — это функция, которая запускает кусочек вашего кода и проверяет результат. Структура почти любого теста описывается схемой AAA: Arrange (подготовь данные), Act (выполни действие), Assert (проверь результат).

Начнём с самого простого — с чистой функции, без всякого Vue:

// sum.js
export function sum(a, b) {
  return a + b
}

// sum.test.js
import { describe, it, expect } from 'vitest'
import { sum } from './sum'

describe('sum', () => {
  it('складывает два числа', () => {
    // Arrange + Act
    const result = sum(2, 3)
    // Assert
    expect(result).toBe(5)
  })

  it('работает с отрицательными числами', () => {
    expect(sum(-1, -4)).toBe(-5)
  })
})

Здесь видны главные «кирпичики», которые есть в любом тест-фреймворке:

  • describe(...) — группа связанных тестов (по функции или компоненту);
  • it(...) (или test(...)) — один тест-кейс. Текст внутри — человекочитаемое описание ожидаемого поведения;
  • expect(...) — то, что мы проверяем;
  • .toBe(...), .toEqual(...) и т.п. — «матчеры», описывающие ожидание.

Установка и запуск

Если проект собран на Vite (а большинство свежих Vue-проектов — да), Vitest подключается в пару команд:

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

# package.json → "scripts"
#   "test": "vitest"

npm test            # запустить в watch-режиме
npx vitest run      # один прогон (для CI)

Чтобы тесты могли «рендерить» компоненты без настоящего браузера, нужна среда jsdom — это эмуляция DOM в Node.js. Указываем её в конфиге:

// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,       // describe/it/expect без импорта
  },
})

Тестируем Vue-компонент

Для компонентов используют библиотеку Vue Test Utils. Её основная функция — mount(): она «монтирует» компонент в виртуальный DOM и возвращает обёртку (wrapper), через которую можно искать элементы, кликать и читать текст.

Возьмём простой компонент-счётчик:

<!-- Counter.vue -->
<template>
  <div>
    <p>Значение: {{ count }}</p>
    <button @click="increment">Увеличить</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)
function increment() {
  count.value++
}
</script>

А вот тест к нему:

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

describe('Counter', () => {
  it('показывает начальное значение 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('Значение: 0')
  })

  it('увеличивает счётчик по клику', async () => {
    const wrapper = mount(Counter)

    await wrapper.find('button').trigger('click')

    expect(wrapper.text()).toContain('Значение: 1')
  })
})

Обратите внимание на два важных момента:

  • await перед trigger('click') — Vue обновляет DOM асинхронно. Без await вы проверите состояние ещё до перерисовки и получите старое значение.
  • Мы проверяем видимый текст, а не внутреннюю переменную count. Если завтра переименуете count в value, тест останется зелёным — потому что для пользователя ничего не изменилось.

Props, события и поиск элементов

Компоненты редко живут в вакууме: им передают входные данные (props) и они отдают наружу события (emit). Тестируется и то, и другое.

<!-- Greeting.vue -->
<template>
  <button data-test="hello" @click="$emit('greet', name)">
    Привет, {{ name }}!
  </button>
</template>

<script setup>
defineProps({ name: String })
defineEmits(['greet'])
</script>
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'

describe('Greeting', () => {
  it('выводит имя из props', () => {
    const wrapper = mount(Greeting, {
      props: { name: 'Павел' },
    })
    expect(wrapper.text()).toContain('Привет, Павел!')
  })

  it('эмитит событие greet с именем при клике', async () => {
    const wrapper = mount(Greeting, {
      props: { name: 'Павел' },
    })

    await wrapper.get('[data-test="hello"]').trigger('click')

    // проверяем, что событие вылетело и с каким payload
    expect(wrapper.emitted('greet')).toBeTruthy()
    expect(wrapper.emitted('greet')[0]).toEqual(['Павел'])
  })
})

Здесь же — важная практика: для поиска элементов в тестах удобно вешать атрибут data-test="...". Он не зависит от вёрстки и стилей: можно сколько угодно менять классы и теги, тест по data-test не сломается. Искать элементы по CSS-классам в тестах — плохая идея.

Моки: как изолировать тестируемый код

«Unit» значит «изолированный». Если компонент ходит в сеть или зависит от таймера, в тесте мы это подменяем заглушкой — моком. Тест не должен реально стучаться на сервер: это медленно и непредсказуемо.

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

// подменяем модуль с запросами к API
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ name: 'Анна' }),
}))

describe('UserCard', () => {
  it('показывает имя пользователя после загрузки', async () => {
    const wrapper = mount(UserCard)

    // ждём, пока завершатся промисы (запрос «вернёт» мок)
    await flushPromises()

    expect(wrapper.text()).toContain('Анна')
  })
})

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

Лучшие практики написания unit-тестов

  • Тестируйте поведение, а не реализацию. Проверяйте, что видит и делает пользователь (текст, клики, события), а не имена внутренних переменных и приватных методов.
  • Один тест — одна мысль. Каждый it() должен проверять что-то одно. Если в названии теста есть «и» — возможно, это два теста.
  • Понятные названия. Описание в it(...) читается как фраза: «увеличивает счётчик по клику», а не «test 1».
  • Структура AAA. Подготовка → действие → проверка. Так тест легко читать даже спустя полгода.
  • Тесты независимы. Порядок запуска не должен влиять на результат. Общее состояние сбрасывайте в beforeEach.
  • Не мокайте лишнего. Подменяйте только внешние границы (сеть, время, сторонние сервисы). Если замокать слишком много — тест будет проверять заглушки, а не ваш код.
  • Тесты должны быть быстрыми и стабильными. «Мигающий» тест, который то падает, то проходит, хуже отсутствия теста — ему перестают верить.
  • Гонитесь не за процентом покрытия, а за смыслом. 100% покрытия не гарантируют отсутствие багов. Покрывайте важную логику и граничные случаи, а не геттеры ради цифры.

Что писать в тесте в первую очередь

Если не знаете, с чего начать, проверяйте эти вещи:

  • компонент рендерится с разными props (включая пустые и граничные значения);
  • пользовательские действия (клик, ввод в поле) меняют интерфейс как ожидается;
  • наружу вылетают нужные события с правильными данными;
  • условный рендеринг: показывается ли блок, когда он должен, и скрыт ли, когда не должен;
  • обработка ошибок и состояния загрузки (спиннер, сообщение об ошибке).

Итог

Тестирование во фронтенде делится на уровни: много быстрых unit-тестов, меньше интеграционных и совсем немного медленных e2e. Для Vue базовый и самый окупаемый уровень — это unit-тесты на Vitest и Vue Test Utils.

Начните с малого: подключите Vitest, напишите тест для одной чистой функции, потом для простого компонента — проверьте рендер, клик и событие. Держите в голове одно правило — тестируйте поведение, а не реализацию — и ваши тесты будут помогать, а не мешать. Лучший способ разобраться — открыть свой проект и покрыть тестами хотя бы один компонент уже сегодня.