Skip to main content
backend2 ноября 2025 г.12 мин чтения

Модульное тестирование в 2026 году: Отговорок больше нет

Инструментарий догнал. ИИ пишет заготовки тестов, фреймворки быстры, и больше нет веских причин выпускать непроверенный код.

testingvitestjest
Модульное тестирование в 2026 году: Отговорок больше нет

Три года назад отговорки для не написания тестов были, по крайней мере, правдоподобными. Тестовые фреймворки были медленными. Настройка моков была утомительной. Написание тестов занимало больше времени, чем написание кода. Рентабельность инвестиций не была очевидной для стартапа из 3 человек, стремящегося к соответствию продукта рынку. Я не соглашался с этими отговорками, но понимал их.

В 2026 году эти отговорки исчезли. Vitest запускает полный набор тестов за то время, которое раньше требовалось Jest для загрузки. Инструменты ИИ создают комплексные тестовые файлы из сигнатуры функции. TypeScript ловит целые категории ошибок во время компиляции, сужая круг того, что действительно нуждается в тестировании. Docker Compose запускает реальные базы данных для интеграционных тестов за считанные секунды. Разрыв между «без тестов» и «хорошо протестировано» никогда не был меньше.

Современный ландшафт тестирования

Экосистема тестирования консолидировалась вокруг нескольких отличных инструментов, и фрагментация, которая раньше превращала настройку тестов в исследовательский проект, в значительной степени исчезла.

Vitest против Jest: Решение принято

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

Скорость: Vitest использует esbuild для преобразования и запускает тесты в рабочих потоках с нативной поддержкой ESM. В моих проектах один и тот же набор тестов работает в 3-5 раз быстрее под Vitest по сравнению с Jest. Набор, который занимал 45 секунд в Jest, завершается за 12 секунд в Vitest.

Конфигурация: Vitest повторно использует вашу конфигурацию Vite. Если вы уже используете Vite для сборки (а в 2026 году большинство проектов используют), то дополнительная конфигурация не требуется. Сравните это с moduleNameMapper, transform, transformIgnorePatterns в Jest и неизбежным поиском причин, почему какой-то ESM-пакет не работает.

Совместимость: Vitest реализует Jest-совместимый API. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — все работает как ожидается. Миграция с Jest на Vitest в основном сводится к поиску и замене jest.fn() на vi.fn().

// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
    },
  },
});

Когда стоит остаться с Jest: Если у вас есть большой существующий набор тестов Jest (500+ тестов) и нет непосредственных проблем, миграция не является срочной. Jest продолжает получать обновления и работает нормально. Но для новых проектов или проектов с менее чем 100 тестами Vitest — очевидный выбор.

Пирамида тестирования на практике

Традиционная пирамида тестирования (много модульных тестов, меньше интеграционных тестов, мало E2E тестов) остается верной в принципе, но границы сместились.

Модульные тесты проверяют отдельные функции, вспомогательные методы и чистую бизнес-логику. Они должны быть быстрыми (менее 10 мс каждый), не иметь внешних зависимостей и тестировать поведение, а не реализацию.

Интеграционные тесты проверяют, что компоненты работают вместе — маршруты API с запросами к базе данных, методы сервисов с вызовами внешних API, компоненты React с их управлением состоянием. Они медленнее (100-500 мс каждый), но ловят ошибки, которые пропускают модульные тесты.

E2E тесты проверяют полные пользовательские сценарии через реальное приложение. Они самые медленные (5-30 секунд каждый) и самые хрупкие, но они ловят ошибки, которые ничто другое не ловит. Инструменты, такие как Playwright, сделали E2E тесты значительно более надежными, чем в эпоху Selenium.

Соотношение, к которому я стремлюсь: 70% модульных, 20% интеграционных, 10% E2E. Точные цифры менее важны, чем наличие представительства на каждом уровне.

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

Не весь код требует одинаковой строгости тестирования. Знание того, куда вкладывать усилия в тестирование, так же важно, как и знание того, как писать тесты.

Всегда тестировать

Бизнес-логика и правила предметной области. Если код реализует бизнес-правило — расчет цен, проверку разрешений, валидацию данных, переход состояния конечного автомата — ему нужны тесты. Это правила, которые, если они неверны, стоят денег или создают уязвимости безопасности.

// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  if (!discount) return subtotal;

  if (discount.type === 'percentage') {
    return subtotal * (1 - discount.value / 100);
  }

  if (discount.type === 'fixed') {
    return Math.max(0, subtotal - discount.value);
  }

  return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';

describe('calculateOrderTotal', () => {
  const items: OrderItem[] = [
    { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
    { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
  ];

  it('calculates subtotal without discount', () => {
    expect(calculateOrderTotal(items)).toBe(30.97);
  });

  it('applies percentage discount', () => {
    const discount = { type: 'percentage' as const, value: 10 };
    expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
  });

  it('applies fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 5 };
    expect(calculateOrderTotal(items, discount)).toBe(25.97);
  });

  it('does not go below zero with fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 50 };
    expect(calculateOrderTotal(items, discount)).toBe(0);
  });

  it('handles empty items array', () => {
    expect(calculateOrderTotal([])).toBe(0);
  });
});

Функции преобразования данных. Любая функция, которая преобразует данные из одной формы в другую — мапперы ответов API, сериализаторы данных форм, парсеры CSV — нуждается в тестах с репрезентативными входными данными и граничными случаями.

Пути обработки ошибок. Что происходит, когда API возвращает 500? Когда входные данные равны null? Когда файл не существует? Пути обработки ошибок — это места, где скрываются ошибки, потому что разработчики вручную тестируют «счастливый путь» и предполагают, что путь обработки ошибок работает.

Вспомогательные функции. Форматировщики строк, помощники по работе с датами, утилиты для массивов, функции валидации. Они используются повсеместно, и ошибка во вспомогательной функции распространяется по всей кодовой базе.

Пропускать или тестировать легко

Прямые обертки фреймворков. Если ваша функция является тонкой оберткой вокруг хорошо протестированного метода фреймворка, то ее тестирование проверяет фреймворк, а не ваш код. Компонент React, который рендерит <h1>{title}</h1>, не нуждается в тесте, проверяющем, что h1 рендерится.

Файлы конфигурации. Статическая конфигурация, константы, определения типов — они не нуждаются в тестах. TypeScript уже проверяет их во время компиляции.

Клей для интеграции сторонних библиотек. Код, который вызывает stripe.charges.create() с параметрами из вашей модели данных, не нуждается в модульном тесте, который мокирует Stripe и проверяет, что вы его вызвали. Ему нужен интеграционный тест, который проверяет сквозной поток оплаты.

Стратегии мокирования

Мокирование — это когда тесты переходят от «проверки поведения» к «тестированию деталей реализации». Цель состоит в том, чтобы мокировать как можно меньше, сохраняя при этом тесты быстрыми и детерминированными.

Подход с внедрением зависимостей

Вместо мокирования модулей передавайте зависимости в качестве параметров. Это делает тестирование естественным и позволяет избежать магии vi.mock(), которая связывает тесты со структурой модуля.

// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
  return {
    async createUser(data: CreateUserInput): Promise<User> {
      const user = await db.users.create({ data });
      await emailClient.send({
        to: user.email,
        template: 'welcome',
        data: { name: user.name },
      });
      return user;
    },
  };
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';

describe('createUserService', () => {
  it('creates user and sends welcome email', async () => {
    const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
    const db = {
      users: { create: vi.fn().mockResolvedValue(mockUser) },
    };
    const emailClient = {
      send: vi.fn().mockResolvedValue(undefined),
    };

    const service = createUserService(db as any, emailClient as any);
    const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });

    expect(result).toEqual(mockUser);
    expect(emailClient.send).toHaveBeenCalledWith({
      to: 'jane@example.com',
      template: 'welcome',
      data: { name: 'Jane' },
    });
  });
});

Никаких вызовов vi.mock(), никаких строковых путей к модулям, никакой зависящей от порядка настройки. Тест явно указывает, что является реальным, а что поддельным.

Когда использовать vi.mock()

Мокирование на уровне модуля уместно, когда вы не можете контролировать внедрение зависимостей — обычно при тестировании компонентов React, которые импортируют модули напрямую, или при тестировании кода, который использует глобальные переменные, специфичные для окружения.

// When you genuinely need module mocking
vi.mock('./api-client', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));

MSW для мокирования API

Для тестов, которые выполняют HTTP-запросы, Mock Service Worker (MSW) перехватывает сетевые запросы на уровне service worker. Это превосходит мокирование fetch или axios, потому что тестирует ваш фактический код HTTP-клиента.

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
      email: 'test@example.com',
    });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'order-1', ...body, status: 'created' },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Генерация тестов с помощью ИИ

Инструменты ИИ действительно изменили экономику написания тестов. Генерация начального каркаса теста — шаблонного кода, базовых сценариев «счастливого пути», стандартных граничных случаев — это именно та повторяющаяся, основанная на шаблонах работа, с которой хорошо справляются LLM.

Что ИИ делает хорошо

  • Генерация тестовых файлов из сигнатур функций и определений типов
  • Предложение граничных случаев, о которых вы могли не подумать (пустые массивы, нулевые значения, граничные числа)
  • Написание повторяющегося шаблонного кода для настройки/очистки
  • Создание мок-объектов, соответствующих формам интерфейсов
  • Генерация параметризованных тестовых случаев для функций с множеством комбинаций входных данных

Что ИИ делает плохо

  • Понимание бизнес-контекста («это должно завершиться ошибкой, потому что пользователи не могут делать заказы после полуночи» — это знание предметной области)
  • Тестирование сложных взаимодействий состояний между несколькими вызовами функций
  • Написание значимых интеграционных тестов, которые проверяют реальные границы системы
  • Принятие решения, что тестировать, а что нет
  • Написание тестов, которые проверяют поведение, а не реализацию

Практический рабочий процесс

Мой рабочий процесс с тестированием, поддерживаемым ИИ:

  1. Написать функцию или модуль.
  2. Попросить ИИ сгенерировать тестовый файл с базовыми случаями.
  3. Просмотреть и исправить сгенерированные тесты — тесты, сгенерированные ИИ, часто проверяют детали реализации, а не поведение.
  4. Добавить специфичные для предметной области случаи, которые ИИ пропустил.
  5. Запустить тесты и убедиться, что они действительно ловят ошибки, временно ломая код.

ИИ выполняет шаг 2 (который раньше был утомительной частью), а я концентрируюсь на шагах 3-5 (которые требуют суждения). Это сокращает время написания тестов примерно на 50-60% для стандартного служебного и сервисного кода.

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

Тестирование компонентов значительно эволюционировало. Переход от поверхностного рендеринга Enzyme к пользовательской философии тестирования React Testing Library изменил мое представление о тестах компонентов.

Что тестировать в компонентах

  • Корректно ли компонент рендерится с различными пропсами?
  • Вызывают ли пользовательские взаимодействия (клик, ввод, выбор) ожидаемое поведение?
  • Обрабатывает ли компонент состояния загрузки, ошибки и пустоты?
  • Корректно ли работает условный рендеринг?

Что не тестировать в компонентах

  • Значения внутреннего состояния (тестируйте то, что видит пользователь, а не то, что отслеживает React)
  • Детали реализации (какая функция была вызвана, сколько перерендеров произошло)
  • Стилизация (используйте для этого визуальное регрессионное тестирование)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';

describe('OrderSummary', () => {
  const defaultProps = {
    items: [
      { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
      { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
    ],
    onCheckout: vi.fn(),
  };

  it('displays order items with prices', () => {
    render(<OrderSummary {...defaultProps} />);

    expect(screen.getByText('Burger')).toBeInTheDocument();
    expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
    expect(screen.getByText('Fries')).toBeInTheDocument();
    expect(screen.getByText('$4.99')).toBeInTheDocument();
  });

  it('displays total', () => {
    render(<OrderSummary {...defaultProps} />);
    expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
  });

  it('calls onCheckout when checkout button is clicked', async () => {
    const user = userEvent.setup();
    render(<OrderSummary {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /checkout/i }));
    expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
  });

  it('shows empty state when no items', () => {
    render(<OrderSummary {...defaultProps} items={[]} />);
    expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
  });

  it('disables checkout button during loading', () => {
    render(<OrderSummary {...defaultProps} isLoading={true} />);
    expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
  });
});

Обратите внимание: нет getByTestId, если это не необходимо, нет проверки внутреннего состояния, нет проверки классов CSS. Тесты читаются как описание того, что пользователь видит и делает.

Тестирование маршрутов API

Тесты маршрутов API — это интеграционные тесты, которые проверяют, что ваш HTTP-обработчик корректно работает со своим промежуточным ПО, валидацией и форматированием ответа.

// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';

describe('POST /api/orders', () => {
  let authToken: string;

  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
    // Get auth token for test user
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'testpassword' });
    authToken = loginResponse.body.token;
  });

  afterAll(async () => {
    await db.destroy();
  });

  it('creates an order with valid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      items: expect.arrayContaining([
        expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
      ]),
    });
  });

  it('returns 401 without auth token', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'prod-1', quantity: 2 }] });

    expect(response.status).toBe(401);
  });

  it('returns 400 with invalid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [] });

    expect(response.status).toBe(400);
    expect(response.body.errors).toBeDefined();
  });

  it('returns 400 when product does not exist', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'nonexistent', quantity: 1 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(400);
    expect(response.body.message).toContain('not found');
  });
});

Тестирование операций с базой данных

Тестирование операций с базой данных требует реальной базы данных. Мокирование SQL-запросов тестирует мок, а не запрос. Используйте тестовую базу данных, которая пересоздается для каждого запуска теста.

// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});

beforeEach(async () => {
  // Clean tables in dependency order
  await prisma.orderItem.deleteMany();
  await prisma.order.deleteMany();
  await prisma.user.deleteMany();
});

describe('Order repository', () => {
  it('creates order with items and calculates total', async () => {
    const user = await prisma.user.create({
      data: { email: 'test@example.com', name: 'Test User' },
    });

    const order = await prisma.order.create({
      data: {
        userId: user.id,
        items: {
          create: [
            { productName: 'Burger', price: 12.99, quantity: 2 },
            { productName: 'Fries', price: 4.99, quantity: 1 },
          ],
        },
        total: 30.97,
      },
      include: { items: true },
    });

    expect(order.items).toHaveLength(2);
    expect(order.total).toBe(30.97);
    expect(order.userId).toBe(user.id);
  });

  it('enforces unique email constraint', async () => {
    await prisma.user.create({
      data: { email: 'duplicate@example.com', name: 'User 1' },
    });

    await expect(
      prisma.user.create({
        data: { email: 'duplicate@example.com', name: 'User 2' },
      })
    ).rejects.toThrow();
  });
});

Настройка тестовой базы данных в CI

# In GitHub Actions
services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    ports:
      - 5432:5432

С Prisma, запустите npx prisma migrate deploy перед тестами, чтобы применить схему. С чистым SQL, запустите скрипты миграции. Тестовая база данных запускается с нуля для каждого запуска CI.

Метрики покрытия, которые имеют значение

Покрытие кода — полезный сигнал, но ужасная цель.

Ловушка покрытия

Оптимизация под 100% покрытие приводит к тестам, которые существуют для того, чтобы затронуть непокрытые строки, а не для проверки поведения. Я видел такие тесты:

// This test exists purely for coverage
it('should have a default export', () => {
  expect(module).toBeDefined();
});

Этот тест не проверяет ничего значимого. Он увеличивает число покрытия, при этом не добавляя никакой уверенности.

Значимые цели покрытия

Вместо общего порога покрытия я использую разные цели для разных категорий кода:

Категория Цель Обоснование
Бизнес-логика / Домен 90%+ Ошибки здесь стоят денег
Маршруты API / Контроллеры 80%+ Границы интеграции
Вспомогательные функции 95%+ Широко используются, высокое влияние
Компоненты UI 70%+ Визуальные ошибки, пойманные E2E
Конфигурация / Настройка Нет цели Статические, редко ломаются

Настройте пороги покрытия для каждой директории:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        'src/domain/**': { statements: 90, branches: 85 },
        'src/api/**': { statements: 80, branches: 75 },
        'src/utils/**': { statements: 95, branches: 90 },
      },
    },
  },
});

Альтернатива: Мутационное тестирование

Если цифры покрытия кажутся пустыми, мутационное тестирование предлагает более честную оценку. Инструменты, такие как Stryker, вносят небольшие изменения (мутации) в ваш код и проверяют, что ваши тесты их обнаруживают. Мутация, которая выживает (тесты по-прежнему проходят), указывает на пробел в вашем наборе тестов.

npx stryker run

Мутационное тестирование медленное

DU

Danil Ulmashev

Full Stack Developer

Хотите работать вместе?