Vitest — тесты на скорости Vite

Vitest — тестовый раннер, созданный авторами Vite. Он понимает конфигурацию Vite «из коробки», запускает тесты в тех же модулях и умеет мокать CSS, картинки и Vue-компоненты без настройки.

Уровень: средний 16 разделов ⏱ 2 часа

1. Что такое тесты и зачем Vitest

Юнит-тесты проверяют маленькие кусочки логики изолированно: функции, компоненты, сторы. Vitest даёт:

Тесты — это «спецификация» кода: они документируют поведение и ловят регрессии. Сравни с курсами JUnit (Java) и pytest (Python) — идеи те же.

2. Установка

npm install -D vitest @vue/test-utils jsdom
// package.json
"scripts": {
  "test": "vitest",
  "test:run": "vitest run"
}
// vite.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",      // для Vue-компонентов
    globals: true              // describe/it/expect без импорта
  }
});

3. describe, it, expect

// utils/calc.ts
export function add(a: number, b: number): number {
  return a + b;
}
// utils/calc.test.ts
import { describe, it, expect } from "vitest";
import { add } from "./calc";

describe("add", () => {
  it("складывает два числа", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("складывает отрицательные", () => {
    expect(add(-1, -1)).toBe(-2);
  });
});

4. Матчеры

МатчерКогда применяется
toBeпримитивы, ссылки
toEqualглубокое сравнение объектов и массивов
toBeTruthy / toBeFalsyистинность значения
toContainэлемент в массиве / подстрока в строке
toThrowфункция выбрасывает исключение
toHaveLength / toHavePropertyдлина массива и свойство объекта
expect([1, 2, 3]).toContain(2);
expect({ a: 1, b: 2 }).toEqual({ b: 2, a: 1 });
expect(() => parse("bad")).toThrow();
expect("hello").toMatch(/^hel/);
toEqual vs toBe: для объектов применяй toEqual, иначе сравнение пойдёт по ссылке и провалится, даже если содержимое одинаковое.

5. Хуки: beforeEach, beforeAll

import { beforeAll, beforeEach, afterEach, describe, it, expect } from "vitest";

const items: number[] = [];

beforeAll(() => console.log("перед всеми тестами"));
beforeEach(() => items.push(1));
afterEach(() => items.length = 0);   // чистим между тестами

describe("items", () => {
  it("имеет один элемент после beforeEach", () => {
    expect(items).toHaveLength(1);
  });
});
Изоляция важнее чистоты: каждый тест должен работать независимо от порядка остальных.

6. Моки: vi.fn и vi.spyOn

import { vi } from "vitest";

const send = vi.fn();
send("ok");
expect(send).toHaveBeenCalledWith("ok");
expect(send).toHaveBeenCalledTimes(1);

// подменить реализацию
const calc = vi.fn().mockImplementation((a: number) => a * 2);
expect(calc(3)).toBe(6);
// spy на метод объекта
const api = { get: () => Promise.resolve([]) };
const spy = vi.spyOn(api, "get");
await api.get();
expect(spy).toHaveBeenCalled();

7. Мок fetch

Запросы в тестах заменяются, чтобы не зависеть от сети:

import { vi } from "vitest";

// app.ts
export async function loadUsers() {
  const res = await fetch("/api/users");
  return res.json();
}

// app.test.ts
it("возвращает пользователей", async () => {
  const fake = { json: async () => [{ id: 1, name: "Аня" }] };
  vi.stubGlobal("fetch", vi.fn(() => fake));

  const users = await loadUsers();
  expect(users).toHaveLength(1);
  expect(fetch).toHaveBeenCalledWith("/api/users");
});
После теста вызывай vi.unstubAllGlobals() или восстановление в afterEach, чтобы мок не протёк в соседние тесты.

8. Тесты Vue-компонентов

Тестируем компонент в изоляции через @vue/test-utils:

// Counter.vue
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>
// Counter.test.ts
import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";

it("инкрементирует счётчик по клику", async () => {
  const wrapper = mount(Counter);
  await wrapper.find("button").trigger("click");
  expect(wrapper.text()).toContain("1");
});

9. Покрытие кода (coverage)

npm install -D @vitest/coverage-v8
{
  "scripts": { "test:coverage": "vitest run --coverage" }
}
test: {
  coverage: {
    provider: "v8",
    include: ["src/**/*.{ts,vue}"],
    exclude: ["src/main.ts", "**/*.d.ts"]
  }
}

Отчёт покажет, какой процент строк кода покрыт тестами (Lines), сколько функций и веток — Functions и Branches.

Цель в 80%+ покрытия — популярный ориентир, но важнее тестировать критичные сценарии: бизнес-логику сторов, вычисления, запросы. Гнаться за 100% выборки ради цифры не стоит.

10. Конфигурация и окружение

Тесты могут бежать по-разному: в jsdom нет реальной канвас, картинок и сети. Настройки уточняются в vite.config.ts:

test: {
  environment: "jsdom",
  environmentOptions: { jsdom: { url: "http://localhost:5173/" } },
  setupFiles: ["src/test/setup.ts"],
  pool: "forks",                 // изоляция процессов
  maxWorkers: "50%",
  restoreMocks: true,
  testTimeout: 10_000
}

В setup.ts обычно чистят моки и регистрируют утилиты:

import { afterEach, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); });
jsdom — не браузер: он не проверяет реальный рендер и сетевой стек. Для сценариев с живым DOM используй e2e-инструменты вроде Playwright или Selenium.

11. Снимки (snapshots)

Snapshots сохраняют сериализованный результат («слепок») и при следующем прогоне сверяют с ним. Удобно для крупных объектов и DOM-структур:

it("имеет корректную структуру", () => {
  const wrapper = mount(Product);
  expect(wrapper.html()).toMatchSnapshot();
});

Первый запуск создаёт папку __snapshots__. Если изменение осознанное, снимок обновляют:

npx vitest -u        # обновить все снапшоты
Снимки легко превращаются в источник шума: обновление фона в CSS не должно менять снапшот. Оставляй их для стабильной разметки, а динамичные части (даты, id) заменяй фиксированными значениями.

12. Тесты в CI

В непрерывной интеграции тесты запускаются один раз без watch-режима. Типовой сценарий:

npm ci
npm run test:run -- --coverage
npm run build   // не соберётся при упавших типах

Полезные флаги для пайплайна:

Объединяй Vitest с качеством кода: ESLint отвечает за правила, тесты — за поведение. Зелёный пайплайн — пропуск в деплой.

13. Параметризация тестов: it.each

Когда одна и та же логика проверяется на десятке входных значений, не копируй блоки. it.each превращает массив данных в отдельные тесты с читаемым именем:

import { it, expect } from "vitest";

it.each([
  [2, 3, 5],
  [-1, 1, 0],
  [2, 2, 4],
])("складывает %i + %i = %i", (a, b, expected) => {
  expect(add(a, b)).toBe(expected);
});

Поддерживаются плейсхолдеры %i (целое), %f (вещественное), %s (строка). Можно передавать объекты и пользоваться полями в имени теста:

it.each([{ a: 1, e: 2 }, { a: 3, e: 6 }])
  ("удваивает $a", ({ a, e }) => expect(dbl(a)).toBe(e));
Параметризация убирает copy-paste и делает явление кейса наглядным: при падении сразу видно, какой вход сломал поведение, — не надо лезть в исходник.

14. Виртуальные таймеры (fake timers)

setTimeout и setInterval в тестах делают прогон медленным или нестабильным. Включи фейковые таймеры и «проигрывай» время вручную:

import { vi, it, expect, afterEach } from "vitest";

afterEach(() => vi.useRealTimers());

it("выполняет действие через 1 секунду", () => {
  vi.useFakeTimers();
  const spy = vi.fn();
  setTimeout(spy, 1000);

  vi.advanceTimersByTime(1000);
  expect(spy).toHaveBeenCalled();
});
Фейковые таймеры подменяют Date и setInterval глобально. Если компонент использует debounce или карусель на таймерах — без виртуального времени тест превратится в таймаут.

15. Тестирование сторов и композаблов

Логика приложения живёт в Pinia-сторах и композаблах, а не в шаблоне. Тестируй её напрямую — быстрее и точнее, чем через mount:

// store.test.ts
import { setActivePinia, createPinia } from "pinia";
import { useCartStore } from "./cart";

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

it("добавляет товар и считает сумму", () => {
  const cart = useCartStore();
  cart.add({ id: 1, price: 100, qty: 2 });
  expect(cart.total).toBe(200);
});
// composable вызывается как обычная функция
const { count, inc } = useCounter();
inc();
expect(count.value).toBe(1);
Тест стора без компонента и быстрее, и точнее: не ждём перерисовку, а проверяем именно бизнес-правила. Компонент — лишь транспортировка данных до стора.

16. Стабилизация тестов и шардинг

Флаки — тесты, которые падают «то так, то эдак». Главные причины и защита:

ПричинаРешение
Зависимость от сетимок fetch / MSW, запрет реальных запросов
Общие мок-состоянияrestoreMocks + сброс в afterEach
Тайминги рендераfake timers или явные ожидания
Порядок файлов--no-file-parallelism для изоляции
test: {
  shard: "1/3",            // в CI: 3 джоба по 1/3 тестов
  maxWorkers: "75%",
  retry: 2                 // авто-перезапуск флаков
}
retry хорошо маскирует, но не лечит флак: зелёная сборка на третьем повторе всё равно красная по сути. Ищи причину — тайминги, общее состояние, сеть — а не увеличивай повторы.

17. MSW: мок сети на уровне протокола

Заменить fetch — просто, но у реального приложения запросы идут через axios, WebSocket и картинки. MSW перехватывает сеть на уровне протокола и работает одинаково в jsdom и браузерных e2e:

npm i -D msw
// src/test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Аня" }]))
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
http.get("/api/user/:id", ({ params }) =>
  HttpResponse.json({ id: String(params.id) }))   // динамический id
Если MSW-мок «молчит» — проверь, что setup.ts подключён в setupFiles и что пути совпадают с базой URL из разделов про конфигурацию.

18. Моки модулей: vi.mock и подмена node_modules

Когда заменить надо не сеть, а целый модуль (библиотеку, утилиту, часы), приходит vi.mock. Он подменяет импорт до первого использования:

import { vi } from "vitest";
import { sendEmail } from "./mailer";

vi.mock("./mailer", () => ({
  sendEmail: vi.fn().mockResolvedValue({ ok: true })
}));

it("отправляет письмо при заказе", async () => {
  await placeOrder();
  expect(sendEmail).toHaveBeenCalledTimes(1);
});
const api = vi.hoisted(() => ({ token: "test" }));
vi.mock("../client", () => ({ getToken: () => api.token }));
Моки модулей глобальны внутри файла и «липнут» к импортам в соседних тестах. Держи их в отдельных файлах тестов или явно сбрасывай через vi.clearAllMocks().

19. Опыт разработчика: ui-режим, фильтры и отладка

Тёмный прогон всех тестов сразу — трата времени. Vitest умеет запускать только нужное и показывает процесс наглядно:

npx vitest --ui           # веб-интерфейс Vitest UI
npx vitest -t "касается корзины"   # по имени теста
npx vitest --testNamePattern="add" # аналогичный фильтр
npx vitest --reporter=verbose      # подробный вывод
it.only("единственный тест", ...)   # временно отключает остальные
it.skip("должен быть дописан", ...) # пропустить с явной причиной
Отладка сложного теста: поставь debugger и запусти прогон через «Launch via npm» в VS Code — точка останова сработает прямо в тесте.

20. Почему тесты стали медленными

Когда прогон расползается на минуты, ищи не «заклинания», а причины по классам:

ПроблемаПроявлениеРешение
Реальные сети и таймерытесты ждут таймаутыМСW (раздел 17) и fake timers
Тяжёлое окружение jsdomмедленный маунт компонентовhappy-dom или node для чистых функций
Пересборка проекта на каждый файлпрогон «греет» весь графdeps.optimizer и include
Один файл на всёпоследовательные тяжёлые шагиразбей на модули — параллелизм сработает
// замер отдельного файла
time npx vitest run src/big.test.ts
Производительность тестов — это производительность разработки: начни с замеров и одного класса проблем, а не с хаотичных флагов.

Что дальше

Пиши тесты для логики Pinia-сторов и компонентов Element Plus (маунт с фрагментами из библиотеки). Стиль и правила тестов задай через ESLint, а гоняй их в CI на GitHub. Для React-проектов ты, возможно, встретишь Jest — курс Jest.

← Вернуться к курсам