Vitest — тесты на скорости Vite
Vitest — тестовый раннер, созданный авторами Vite. Он понимает конфигурацию Vite «из коробки», запускает тесты в тех же модулях и умеет мокать CSS, картинки и Vue-компоненты без настройки.
1. Что такое тесты и зачем Vitest
Юнит-тесты проверяют маленькие кусочки логики изолированно: функции, компоненты, сторы. Vitest даёт:
- скорость — переиспользует кэш и трансформации Vite;
- watch-режим — тесты перезапускаются при изменении файлов;
- совместимость с Vue/React/TS изучается без боли (в отличие от Jest, где нужны настройки под .vue и ESM).
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);
});
});describe— группировка тестов;it(илиtest) — отдельный тест;expect(actual).matcher(expected)— проверка.
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");
});mount— монтирование полное,shallowMount— без дочерних;wrapper.find— поиск элемента (CSS-селектор);await trigger(...)— ждём обновления DOM после события.
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.
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(); });11. Снимки (snapshots)
Snapshots сохраняют сериализованный результат («слепок») и при следующем прогоне сверяют с ним. Удобно для крупных объектов и DOM-структур:
it("имеет корректную структуру", () => {
const wrapper = mount(Product);
expect(wrapper.html()).toMatchSnapshot();
});Первый запуск создаёт папку __snapshots__. Если изменение осознанное, снимок обновляют:
npx vitest -u # обновить все снапшоты12. Тесты в CI
В непрерывной интеграции тесты запускаются один раз без watch-режима. Типовой сценарий:
npm ci
npm run test:run -- --coverage
npm run build // не соберётся при упавших типахПолезные флаги для пайплайна:
--reporter=json— машиночитаемый отчёт для CI-сервиса;--coverage.reporter=lcov— подхватывается всеми популярными сервисами;--no-file-parallelism— когда тесты чувствительны к порядку файлов.
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));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();
});vi.advanceTimersByTime(ms)— переместить время вперёд;vi.runAllTimers()— выполнить всё, что накопилось;- всегда возвращай реальные таймеры в
afterEach, иначе они протекут в соседние тесты.
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 // авто-перезапуск флаков
}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());- MSW имитирует сеть, а не саму функцию — реальный код вызова остаётся нетронутым;
- хендлеры описывают метод, URL и ответ — читаются как спецификация API;
- ответ с ошибкой задаётся отдельно:
HttpResponse.error()имитирует упавшую сеть.
http.get("/api/user/:id", ({ params }) =>
HttpResponse.json({ id: String(params.id) })) // динамический idsetupFiles и что пути совпадают с базой 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);
});- фабрика мока возвращает объект, повторяющий экспорты модуля;
- для factory с переменными —
vi.hoisted: он поднимает значения вверх до запуска мока; - подмена пакета из node_modules работает по имени:
vi.mock("axios", ...).
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 # подробный вывод- фильтр по файлу:
npx vitest src/components/Cart; - watch-режим: флаг
-tкомбинируется прямо в интерактивном терминале; --pool=threadsи--poolOptionsуправляют параллелизмом для медленных тестов.
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.