Протягом багатьох років класична піраміда тестування Майка Кона (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)| типи, контракти, синтаксис
+---------------------------------------+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, немає складних зовнішніх сайд-ефектів, але є нетривіальна обчислювальна або бізнес-логіка. Приклади: калькулятори податків, алгоритми дифінгу структур даних, парсери протоколів, скінченні автомати (state machines).
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('./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} />);Визначити типи вхідних та вихідних параметрів у strict TypeScript. Використовувати Zod/ArkType для валідації runtime-меж.
Якщо задача містить чисті математичні функції, парсери або перетворення даних — написати швидкі ізольовані Unit-тести.
Змонтувати компонент повністю з дочірніми елементами у Vitest + RTL. Налаштувати мережеві сценарії в MSW v2, перевірити взаємодію через ARIA-ролі.
Написати рівно один E2E-сценарій у Playwright для перевірки повного циклу в реальному браузері (Smoke test ключової бізнес-фічі).