В течение многих лет классическая пирамида тестирования Майка Конна (Mike Cohn, 2009) оставалась негласным стандартом распределения усилий: огромный пласт изолированных модульных (Unit) тестов в основании, умеренная прослойка интеграционных проверок и минимальный объем сквозных (End-to-End) сценариев на вершине. Однако в веб-разработке 2026 года с компонентной архитектурой, React 19, Server Actions и строгими компиляторами эта модель демонстрирует системную неэффективность.
Изолированные юнит-тесты компонентов, где каждая внешняя зависимость (хуки, сетевой клиент, роутер, контекст) подменяется фиктивными заглушками, тестируют исключительно детали реализации (Implementation Details). Изменение внутренней структуры компонента ломает такие тесты при неизменном внешнем поведении (False Positives), а интеграционные дефекты — некорректная склейка компонентов, несоответствие сетевых контрактов, поломка событийной модели DOM — благополучно проходят в продакшн (False Negatives).
Модель Testing Trophy переосмысливает баланс между стоимостью поддержки тестов (Cost) и уровнем уверенности в стабильности релиза (Confidence).
+---------------------------------------+
| End-to-End | <- Высокая уверенность (Confidence),
| (Playwright / Cypress) | но высокая стоимость и медленный CI
+---------------------------------------+
| |
| INTEGRATION | <- Золотая середина (The Sweet Spot):
| (Vitest + React Testing Lib) | максимальный ROI, реальный DOM,
| | сетевые перехватчики (MSW)
+---------------------------------------+
| Unit | <- Чистые функции, утилиты,
| (Vitest) | математика, сложные стейт-машины
+---------------------------------------+
| STATIC ANALYSIS | <- Нулевая стоимость рантайма:
| (TypeScript Strict, ESLint, Oxlint)| типы, контракты, синтаксис
+---------------------------------------+Определить типы входных и выходных параметров в strict TypeScript. Использовать Zod/ArkType для валидации runtime-границ.
Если задача содержит чистые математические функции, парсеры или преобразования данных — написать быстрые изолированные Unit-тесты.
Смонтировать компонент целиком с дочерними элементами в Vitest + RTL. Настроить сетевые сценарии в MSW v2, проверить взаимодействие через ARIA-роли.
Написать ровно один E2E-сценарий в Playwright для проверки полного цикла в реальном браузере (Smoke test ключевой бизнес-фичи).
Почему модель Testing Trophy отдает наивысший приоритет интеграционным тестам, а не изолированным модульным (Unit) тестам пирамиды Конна?
tsc --noEmit и линтеру oxlint вместо прогона тяжелых тестов.Статический анализ в 2026 году — это не просто проверка стилей кода, а первая линия обороны качества. Строгий режим TypeScript (strict: true) в связке с компиляторными флагами устраняет до 40% потенциальных runtime-ошибок (null/undefined, несоответствие структуры данных, опечатки в ключах объектов).
{
"compilerOptions": {
"target": "ES2023",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"skipLibCheck": true,
"isolatedModules": true
}
}Флаг noUncheckedIndexedAccess: true заставляет разработчика явно обрабатывать неопределенные индексы массивов и словарей (тип возвращаемого элемента T | undefined), что исключает класс ошибок TypeError: Cannot read properties of undefined еще до запуска тестов.
Модульные тесты идеальны там, где нет DOM, нет сложных внешних сайд-эффектов, но есть нетривиальная вычислительная или бизнес-логика. Примеры: калькуляторы налогов, алгоритмы диффинга структур данных, парсеры протоколов, стейт-машины.
import { describe, it, expect } from 'vitest';
export interface CartItem {
id: string;
priceInCents: number;
quantity: number;
category: 'electronics' | 'groceries' | 'apparel';
}
export interface DiscountPolicy {
bulkThreshold: number;
bulkDiscountPercentage: number;
}
export function calculateOrderTotal(
items: readonly CartItem[],
policy: DiscountPolicy
): number {
return items.reduce((total, item) => {
const rawCost = item.priceInCents * item.quantity;
if (item.quantity >= policy.bulkThreshold) {
const discount = Math.round(rawCost * (policy.bulkDiscountPercentage / 100));
return total + (rawCost - discount);
}
return total + rawCost;
}, 0);
}
describe('calculateOrderTotal (Pure Unit Logic)', () => {
const policy: DiscountPolicy = {
bulkThreshold: 5,
bulkDiscountPercentage: 10,
};
it('применяет оптовую скидку при достижении порогового количества', () => {
const items: readonly CartItem[] = [
{ id: '1', priceInCents: 1000, quantity: 5, category: 'apparel' },
];
// 5 * 1000 = 5000; скидка 10% = 500; итого = 4500
expect(calculateOrderTotal(items, policy)).toBe(4500);
});
it('не применяет скидку, если количество строго меньше порога', () => {
const items: readonly CartItem[] = [
{ id: '2', priceInCents: 1000, quantity: 4, category: 'apparel' },
];
expect(calculateOrderTotal(items, policy)).toBe(4000);
});
it('корректно обрабатывает пустую корзину', () => {
expect(calculateOrderTotal([], policy)).toBe(0);
});
});Здесь нет ни одного мока (vi.mock), тест исполняется за 2 миллисекунды и обеспечивает 100% уверенность в алгоритме.
Рассмотрим компонент корзины CartCheckout. При старом подходе разработчики мокировали дочерние компоненты CartSummary, сетевой запрос к оформлению и хук корзины, оставляя только проверку вызова функций с конкретными аргументами.
В подходе Testing Trophy мы рендерим компонент целиком, используя реальный DOM (через happy-dom), перехватываем HTTP-запросы через Mock Service Worker (MSW) и эмулируем действия пользователя через userEvent.
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import React, { useState } from 'react';
// Реальный интерфейс данных
interface OrderRequest {
couponCode: string;
itemsCount: number;
}
// Сетевые обработчики MSW v2
const server = setupServer(
http.post('/api/checkout', async ({ request }) => {
const body = (await request.json()) as OrderRequest;
if (body.couponCode === 'INVALID2026') {
return HttpResponse.json(
{ error: 'Промокод недействителен или истек' },
{ status: 422 }
);
}
return HttpResponse.json({ orderId: 'ORD-98765', status: 'confirmed' });
})
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Тестируемый компонент: проверяем совместную работу формы, состояния и сетевого ответа
function CartCheckoutForm({ itemsCount }: { itemsCount: number }) {
const [coupon, setCoupon] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus('loading');
setErrorMessage('');
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ couponCode: coupon, itemsCount }),
});
if (!res.ok) {
const err = (await res.json()) as { error: string };
setErrorMessage(err.error);
setStatus('error');
return;
}
setStatus('success');
} catch {
setErrorMessage('Сетевая ошибка при обращении к серверу');
setStatus('error');
}
};
return (
<form onSubmit={handleSubmit} aria-label="Форма оформления заказа">
<label htmlFor="coupon-input">Промокод на скидку</label>
<input
id="coupon-input"
type="text"
value={coupon}
onChange={(e) => setCoupon(e.target.value)}
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Оформление...' : 'Подтвердить заказ'}
</button>
{status === 'success' && <p role="status">Заказ успешно сформирован!</p>}
{status === 'error' && <p role="alert">{errorMessage}</p>}
</form>
);
}
describe('Интеграция: Оформление заказа и валидация промокода', () => {
it('отображает ошибку валидации при неверном промокоде', async () => {
const user = userEvent.setup();
render(<CartCheckoutForm itemsCount={3} />);
const input = screen.getByLabelText(/промокод на скидку/i);
const submitBtn = screen.getByRole('button', { name: /подтвердить заказ/i });
await user.type(input, 'INVALID2026');
await user.click(submitBtn);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('Промокод недействителен или истек');
});
it('переводит форму в состояние успеха при корректном запросе', async () => {
const user = userEvent.setup();
render(<CartCheckoutForm itemsCount={3} />);
const input = screen.getByLabelText(/промокод на скидку/i);
const submitBtn = screen.getByRole('button', { name: /подтвердить заказ/i });
await user.type(input, 'SUMMER2026');
await user.click(submitBtn);
const successMessage = await screen.findByRole('status');
expect(successMessage).toHaveTextContent('Заказ успешно сформирован!');
});
});Этот интеграционный тест проверяет ввод пользователя, сабмит формы, сериализацию JSON, обработку HTTP-статусов, рендеринг состояний доступности (ARIA role="alert", role="status"). При этом кодовая база может быть отрефакторена (например, переписана на useActionState), и тест продолжит успешно проходить.
К каким негативным последствиям приводит замена реальных дочерних компонентов на заглушки (vi.mock('./ChildComponent')) в тестах клиентского дерева?
Разработчики часто используют конструкцию вида vi.mock('./ComplexDropdown'), заменяя дочерний компонент пустым div. В результате тесты не проверяют реальную интеграцию пропсов, событий и синхронизацию состояний.
// ❌ Антипаттерн: тест проверяет фантазию о приложении, а не само приложение
vi.mock('./CartItemRow', () => ({
CartItemRow: () => <div data-testid="mock-row" />
}));
// ✅ Правильное решение: рендерить реальное дерево компонентов
// Сетевой или контекстный уровень мокируется на границах (MSW, Store Provider),
// а компоненты внутри дерева взаимодействуют нативно.
render(<CartList items={sampleItems} />);
expect(screen.getAllByRole('listitem')).toHaveLength(sampleItems.length);Метрика 100% покрытия строк кода (Line Coverage) стимулирует написание тестов для геттеров, тривиальных редьюсеров и констант. Это увеличивает стоимость поддержки (Cost) без увеличения уверенности (Confidence).
// ❌ Антипаттерн: тест, не дающий реальной уверенности в бизнес-логике
it('возвращает начальное состояние', () => {
expect(initialState.theme).toBe('light'); // Тестирование константы
});
// ✅ Правильное решение: фокусироваться на сценариях отказа и критических путях
it('переключает тему и сохраняет преференс в persistent storage', async () => {
const user = userEvent.setup();
render(<ThemeToggle />);
await user.click(screen.getByRole('button', { name: /переключить тему/i }));
expect(localStorage.getItem('user-theme')).toBe('dark');
});Запуск 50 вариаций E2E-теста в Playwright для проверки каждого пограничного правила валидации формы (пустое поле, спецсимволы, максимальная длина) замедляет CI на десятки минут.
// ❌ Антипаттерн: 20 тяжелых E2E-тестов браузера для валидации каждого поля
test('E2E: проверка формата email при 15 невалидных значениях', async ({ page }) => {
// Запуск браузера, сетевые задержки, навигация
});
// ✅ Правильное решение: валидация на уровне интеграционного теста формы в Vitest (миллисекунды)
// В Playwright оставляется только один сквозной Happy Path и один критический Negative Path.Отключение TypeScript проверки для тестов (// @ts-ignore или использование any) приводит к тому, что при изменении интерфейсов тесты перестают соответствовать актуальному контракту приложения.
// ❌ Антипаттерн: маскирование невалидных пропсов
// @ts-expect-error заглушаем несовпадение типов
render(<UserProfile user={{ id: 1, name: 'Alice' } as any} />);
// ✅ Правильное решение: фабрики тестовых данных с 100% строгой типизацией
const mockUser: User = createTestUser({ name: 'Alice' });
render(<UserProfile user={mockUser} />);