React тестирование с Vitest

10 сентября 2026
Автор

Антон Ларичев

Что такое Vitest и зачем он нужен

Vitest — это современный фреймворк для тестирования JavaScript и TypeScript, построенный поверх Vite. Он совместим с API Jest, работает значительно быстрее за счёт нативной поддержки ESM и не требует отдельной трансформации исходников — проект уже использует Vite, и Vitest переиспользует ту же конфигурацию.

Для React-проектов на Vite Vitest — очевидный выбор: единая цепочка инструментов, мгновенный cold-start, встроенный watch-режим с HMR-подобным поведением.

Курс по теме

Курс React и Redux Toolkit с нуля

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

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

Установите зависимости:

npm install -D vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

Добавьте конфигурацию в vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});

Создайте файл src/test/setup.ts:

import '@testing-library/jest-dom';

Добавьте типы в tsconfig.json:

{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

Добавьте скрипты в package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Первый тест компонента

Рассмотрим простой компонент:

// src/components/Greeting.tsx
interface GreetingProps {
  name: string;
  isLoggedIn?: boolean;
}

export function Greeting({ name, isLoggedIn = false }: GreetingProps) {
  return (
    <div>
      <h1>Привет, {name}!</h1>
      {isLoggedIn && <p>Вы вошли в систему</p>}
    </div>
  );
}

Тест для него:

// src/components/Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting', () => {
  it('отображает имя пользователя', () => {
    render(<Greeting name="Иван" />);
    expect(screen.getByText('Привет, Иван!')).toBeInTheDocument();
  });

  it('скрывает статус, когда пользователь не вошёл', () => {
    render(<Greeting name="Иван" />);
    expect(screen.queryByText('Вы вошли в систему')).not.toBeInTheDocument();
  });

  it('показывает статус авторизации', () => {
    render(<Greeting name="Иван" isLoggedIn />);
    expect(screen.getByText('Вы вошли в систему')).toBeInTheDocument();
  });
});

Ключевые утилиты @testing-library/react:

  • render — монтирует компонент в виртуальный DOM
  • screen.getByText — ищет элемент по тексту, бросает ошибку если не найден
  • screen.queryByText — возвращает null если элемент не найден (используйте для проверки отсутствия)
  • screen.findByText — асинхронный вариант, ждёт появления элемента

Тестирование пользовательских взаимодействий

Для имитации действий пользователя используйте @testing-library/user-event — он точнее воспроизводит реальные события браузера, чем fireEvent.

// src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount(c => c + 1)}>Увеличить</button>
      <button onClick={() => setCount(c => c - 1)} disabled={count === 0}>
        Уменьшить
      </button>
    </div>
  );
}
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('увеличивает счётчик при клике', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('Увеличить'));
    await user.click(screen.getByText('Увеличить'));

    expect(screen.getByTestId('count')).toHaveTextContent('2');
  });

  it('кнопка уменьшения недоступна при нулевом значении', () => {
    render(<Counter />);
    expect(screen.getByText('Уменьшить')).toBeDisabled();
  });
});

Обратите внимание: userEvent.setup() создаёт изолированный экземпляр с собственным состоянием. Все вызовы методов — async/await.

Тестирование форм

// src/components/LoginForm.tsx
import { useState } from 'react';

interface LoginFormProps {
  onSubmit: (data: { email: string; password: string }) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!email || !password) {
      setError('Заполните все поля');
      return;
    }
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p role="alert">{error}</p>}
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="Пароль"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <button type="submit">Войти</button>
    </form>
  );
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('вызывает onSubmit с данными формы', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByPlaceholderText('Email'), 'test@example.com');
    await user.type(screen.getByPlaceholderText('Пароль'), 'secret123');
    await user.click(screen.getByText('Войти'));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'secret123',
    });
  });

  it('показывает ошибку при пустых полях', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByText('Войти'));

    expect(screen.getByRole('alert')).toHaveTextContent('Заполните все поля');
  });
});

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

Для тестирования хуков используйте renderHook из @testing-library/react:

// src/hooks/useLocalStorage.ts
import { useState } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  const setStored = (newValue: T) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };

  return [value, setStored] as const;
}
// src/hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('возвращает начальное значение', () => {
    const { result } = renderHook(() => useLocalStorage('key', 42));
    expect(result.current[0]).toBe(42);
  });

  it('сохраняет значение в localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('theme', 'light'));

    act(() => {
      result.current[1]('dark');
    });

    expect(result.current[0]).toBe('dark');
    expect(localStorage.getItem('theme')).toBe('"dark"');
  });

  it('читает существующее значение из localStorage', () => {
    localStorage.setItem('count', '"10"');
    const { result } = renderHook(() => useLocalStorage('count', 0));
    expect(result.current[0]).toBe('10');
  });
});

Важно: все изменения состояния хука оборачивайте в act() — это гарантирует, что React обработает все обновления до проверки результата.

Моки и подмена модулей

Мок функции

const mockFn = vi.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ data: [] }); // для промисов

Мок модуля

// src/services/api.ts
export async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
// src/components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import * as api from '../services/api';
import { UserCard } from './UserCard';

vi.mock('../services/api');

test('отображает данные пользователя', async () => {
  vi.mocked(api.fetchUser).mockResolvedValue({
    id: 1,
    name: 'Иван Петров',
  });

  render(<UserCard userId={1} />);

  expect(await screen.findByText('Иван Петров')).toBeInTheDocument();
});

Мок глобальных объектов

// мок fetch
global.fetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: 'Тест' }),
});

Тестирование асинхронного кода

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

// src/components/PostList.tsx
import { useEffect, useState } from 'react';

interface Post {
  id: number;
  title: string;
}

export function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/posts')
      .then(r => r.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Загрузка...</p>;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
// src/components/PostList.test.tsx
import { render, screen } from '@testing-library/react';
import { PostList } from './PostList';

beforeEach(() => {
  global.fetch = vi.fn();
});

test('показывает состояние загрузки', () => {
  vi.mocked(fetch).mockReturnValue(new Promise(() => {}));
  render(<PostList />);
  expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});

test('отображает список постов', async () => {
  vi.mocked(fetch).mockResolvedValue({
    json: () =>
      Promise.resolve([
        { id: 1, title: 'Первый пост' },
        { id: 2, title: 'Второй пост' },
      ]),
  } as Response);

  render(<PostList />);

  expect(await screen.findByText('Первый пост')).toBeInTheDocument();
  expect(screen.getByText('Второй пост')).toBeInTheDocument();
});

screen.findBy* — асинхронные запросы, которые ждут появления элемента до 1000 мс. Используйте их вместо getBy* когда элемент появляется после асинхронных операций.

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

Если компонент требует провайдеры (Router, Redux, Theme), создайте вспомогательную обёртку:

// src/test/utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <BrowserRouter>
      <ThemeProvider>{children}</ThemeProvider>
    </BrowserRouter>
  );
}

const customRender = (ui: ReactElement, options?: RenderOptions) =>
  render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

Теперь в тестах импортируйте render из ../test/utils вместо @testing-library/react:

import { render, screen } from '../test/utils';
import { NavLink } from './NavLink';

test('рендерит ссылку навигации', () => {
  render(<NavLink to="/about">О нас</NavLink>);
  expect(screen.getByText('О нас')).toBeInTheDocument();
});

Запуск тестов и покрытие кода

# watch-режим (запускает только изменённые тесты)
npm test

# однократный запуск всех тестов
npm run test:run

# отчёт о покрытии
npm run test:coverage

Отчёт о покрытии появится в папке coverage/. Откройте coverage/index.html в браузере для наглядного отображения непокрытых строк.

Для настройки порогов покрытия в vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    thresholds: {
      lines: 80,
      functions: 80,
      branches: 70,
    },
  },
}

Организация тестов

Рекомендуемые практики:

  • Располагайте тесты рядом с компонентами (Button.test.tsx рядом с Button.tsx)
  • Называйте тест с точки зрения пользователя: «показывает ошибку при...», «вызывает колбэк когда...»
  • Один тест — одна проверка поведения
  • Используйте describe для группировки сценариев одного компонента
  • Очищайте моки в beforeEach/afterEach, чтобы тесты не влияли друг на друга
describe('Button', () => {
  describe('в состоянии загрузки', () => {
    it('показывает спиннер', () => { ... });
    it('недоступна для клика', () => { ... });
  });

  describe('при ошибке', () => {
    it('отображает сообщение об ошибке', () => { ... });
  });
});

Заключение

Vitest в связке с Testing Library даёт быструю и удобную среду тестирования React-компонентов. Ключевые принципы: тестируйте поведение, а не реализацию; запрашивайте элементы так, как это делает пользователь (по тексту, роли, плейсхолдеру); изолируйте внешние зависимости через моки.

Полный курс по React с тестированием и другими практиками разработки — на PurpleSchool: https://purpleschool.ru/course/react?utm_source=knowledgebase&utm_medium=text&utm_campaign=react-testing-vitest

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

React тестирование с Vitest
React с TanStack Router
Uncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиReact с WebSocket — реалтайм в компонентахReact Native приложение с нуля до публикации в сторахПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыОшибки гидратации в React и способы их устраненияHOC в React: мастерство композиции компонентовuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRedux Toolkit - современный ReduxRecoil — библиотека управления состоянием от FacebookВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseOptimistic — оптимистичные обновления UIuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияХук use() в React 19useEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19Оптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextНовые возможности React 19Как предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияflushSync — синхронные обновления состояния в ReactuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в React
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий