У традиційному підході до розробки програмного забезпечення тести пишуться постфактум — після реалізації бізнес-логіки. Це створює фундаментальну архітектурну ваду: інженер пише тести, підлаштовуючись під уже створену внутрішню структуру класів, замикань і структур даних. У результаті тести виявляються жорстко прив'язаними до деталей реалізації (High Coupling to Implementation Details), а проєктування публічного інтерфейсу (Public API) відбувається з точки зору постачальника сервісу, а не його споживача.
Test-Driven Development (TDD) перевертає цей процес. Написання падаючого тесту змушує розробника насамперед відповісти на питання взаємодії:
TDD слугує дисципліною декомпозиції: складна бізнес-задача розбивається на серію дискретних, детермінованих специфікацій. Це усуває спекулятивну функціональність (YAGNI — You Aren't Gonna Need It) і запобігає розростанню когнітивного навантаження.
Формалізація процесу TDD спирається на три закони:
Дотримання цих правил формує ритмічний, безперервний мікроцикл: крок написання тесту триває від кількох секунд до 1-2 хвилин, після чого слідує мінімальне редагування коду і запуск тестового ранера.
Внутрішній механізм TDD описується класичним замкненим конвеєром Red-Green-Refactor.
Кожна фаза циклу має чітко окреслені межі відповідальності:
+-----------------------------------------------------------------------+
| TDD CYCLE |
+-----------------------------------------------------------------------+
| |
| +-------------------+ +--------------------+ |
| | 1. RED | ----> | 2. GREEN | |
| | Написання | | Мінімальний код | |
| | падаючого тесту | | для проходження | |
| +-------------------+ +--------------------+ |
| ^ | |
| | v |
| Нова | +--------------------+ |
| вимога | | 3. REFACTOR | |
| +---------------- | Усунення запахів | |
| | (тести зелені) | |
| +--------------------+ |
| |
+-----------------------------------------------------------------------+На фазі Red пишеться специфікація на ще не існуючий функціонал. Головне правило: тест зобов'язаний впасти з очікуваної причини.
ReferenceError: calculateDiscount is not defined або помилкою збирання TypeScript — вимогу зафіксовано, контракт визначено.AssertionError: expected undefined to be 42) — підтверджено відсутність функціональності.Мета фази Green — максимально швидко повернути тестовий сьют у робочий стан (Exit from Red state). Дозволяється застосовувати стратегії:
Лише коли всі тести зелені, розробник отримує право змінювати вихідний код без остраху зламати наявні контракти. Фаза Refactor спрямована на:
Розглянемо реалізацію сервісу валідації та декодування токенів доступу з обмеженими правами (Scope Evaluator) із застосуванням суворої техніки TDD та ранера Vitest.
Формулюємо початкову вимогу: валідатор повинен відхиляти порожній рядок або некоректний формат токена з явною помилкою.
import { describe, it, expect } from 'vitest';
import { ScopeEvaluator, SecurityTokenError } from './scope-evaluator';
describe('ScopeEvaluator', () => {
it('повинен викидати SecurityTokenError при передачі порожнього рядка', () => {
const evaluator = new ScopeEvaluator();
expect(() => evaluator.evaluateToken('', ['read:users'])).toThrow(
SecurityTokenError
);
});
});Запуск Vitest завершується помилкою компіляції TypeScript: модуль ./scope-evaluator не знайдено. Фаза RED пройдена — падіння з очікуваної причини.
Створюємо мінімально достатній код для проходження першого тесту.
export class SecurityTokenError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityTokenError';
}
}
export class ScopeEvaluator {
evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
if (!token) {
throw new SecurityTokenError('Token cannot be empty');
}
return false;
}
}Тест проходить успішно (PASS). Усі тести зелені.
Додаємо специфікацію: токен складається з префікса Bearer , корисного навантаження у форматі Base64 та набору скоупів, розділених двокрапкою.
it('повинен повертати true, якщо токен містить усі обов’язкові скоупи', () => {
const evaluator = new ScopeEvaluator();
// Закодоване корисне навантаження: {"sub":"usr_123","scopes":["read:users","write:orders"]}
const payload = Buffer.from(
JSON.stringify({
sub: 'usr_123',
scopes: ['read:users', 'write:orders'],
})
).toString('base64url');
const rawToken = `Bearer ${payload}`;
const hasAccess = evaluator.evaluateToken(rawToken, ['read:users']);
expect(hasAccess).toBe(true);
});
it('повинен повертати false, якщо токен не містить хоча б один обов’язковий скоуп', () => {
const evaluator = new ScopeEvaluator();
const payload = Buffer.from(
JSON.stringify({
sub: 'usr_123',
scopes: ['read:users'],
})
).toString('base64url');
const rawToken = `Bearer ${payload}`;
const hasAccess = evaluator.evaluateToken(rawToken, ['read:users', 'admin:all']);
expect(hasAccess).toBe(false);
});Тести падають (FAIL), оскільки метод evaluateToken безумовно повертає false.
Пишемо робочий код для парсингу Base64-пейлоада та зіставлення скоупів.
interface TokenPayload {
readonly sub: string;
readonly scopes: readonly string[];
}
export class ScopeEvaluator {
evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
if (!token || !token.trim()) {
throw new SecurityTokenError('Token cannot be empty');
}
if (!token.startsWith('Bearer ')) {
throw new SecurityTokenError('Invalid authorization scheme');
}
const base64Payload = token.slice(7);
let parsed: TokenPayload;
try {
const decodedJson = Buffer.from(base64Payload, 'base64url').toString('utf-8');
parsed = JSON.parse(decodedJson) as TokenPayload;
} catch {
throw new SecurityTokenError('Malformed token payload');
}
if (!Array.isArray(parsed.scopes)) {
return false;
}
const tokenScopeSet = new Set(parsed.scopes);
return requiredScopes.every((scope) => tokenScopeSet.has(scope));
}
}Усі 3 тести зелені (PASS). Переходимо до фази рефакторингу.
Клас ScopeEvaluator порушує принцип єдиної відповідальності (SRP): він і парсить сирий рядок токена, і валідує схему Bearer, і декодує Base64, і обчислює права. Проведемо рефакторинг, виділивши чистий токенайзер без порушення жодного контракту.
export class SecurityTokenError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecurityTokenError';
}
}
export interface DecodedToken {
readonly subject: string;
readonly scopes: ReadonlySet<string>;
}
export class TokenParser {
private static readonly BEARER_PREFIX = 'Bearer ';
public static parse(rawToken: string): DecodedToken {
if (!rawToken || !rawToken.trim()) {
throw new SecurityTokenError('Token cannot be empty');
}
if (!rawToken.startsWith(this.BEARER_PREFIX)) {
throw new SecurityTokenError('Invalid authorization scheme');
}
const base64Payload = rawToken.slice(this.BEARER_PREFIX.length);
try {
const json = Buffer.from(base64Payload, 'base64url').toString('utf-8');
const payload = JSON.parse(json) as { sub?: unknown; scopes?: unknown };
if (typeof payload.sub !== 'string' || !Array.isArray(payload.scopes)) {
throw new Error('Invalid token structure');
}
return {
subject: payload.sub,
scopes: new Set(payload.scopes.filter((s): s is string => typeof s === 'string')),
};
} catch (error) {
if (error instanceof SecurityTokenError) throw error;
throw new SecurityTokenError('Malformed token payload');
}
}
}
export class ScopeEvaluator {
public evaluateToken(token: string, requiredScopes: readonly string[]): boolean {
const decoded = TokenParser.parse(token);
return this.hasRequiredScopes(decoded.scopes, requiredScopes);
}
private hasRequiredScopes(
grantedScopes: ReadonlySet<string>,
requiredScopes: readonly string[]
): boolean {
return requiredScopes.every((scope) => grantedScopes.has(scope));
}
}Запуск Vitest підтверджує: 100% тестів залишаються зеленими, а внутрішня структура стала модульною, суворо типізованою і готовою до розширення.
Написання тестів, що перевіряють виклики приватних методів або внутрішній стан екземплярів (наприклад, інспекція приватного поля evaluator['cachedScopes']). За найменшого рефакторингу такі тести ламаються, навіть якщо публічна поведінка повністю коректна. Тестуйте виключно публічний контракт: вхідні аргументи та вихідні результати.
Перехід до наступного завдання одразу після фази Green без аналізу запахів коду (Code Smells). Технічний борг у TDD накопичується саме тут: без рефакторингу кодова база перетворюється на нашарування милиць та хардкодів, написаних нашвидкуруч заради проходження тестів.
Спроба написати один гігантський тест, що покриває відразу багаторівневий сценарій із п'ятьма розгалуженнями. Це руйнує зворотний зв'язок: якщо тест падає, неможливо визначити, яка саме частина логіки дала збій. Робіть мінімальні кроки: один тест — один квант бізнес-вимоги.
Спроба відразу на етапі Green побудувати складну фабрику класів або універсальні узагальнені абстракції. На етапі Green пишіть найпростіший, прямолінійний код. Архітектурне узагальнення допустиме лише на етапі Refactor, коли кілька пройдених тестів підтвердили реальну необхідність абстракції.
Обрати один атомарний аспект поведінки системи. Написати тест, що описує виклик публічного API та очікуваний результат. Запустити тест і переконатися, що він упав з очікуваної причини.
Написати рівно стільки коду, скільки потрібно для переходу тесту в зелений статус. Допустимо використовувати найпростіші хардкоди або прямолінійні умови.
Запустити ранер тестів. Переконатися, що не тільки новий тест, а й усі раніше написані тести у сьюті успішно виконуються.
Оцінити код на дублювання, якість іменування, складність методів та відповідність SOLID. Відрефакторити код і переконатися, що тести залишаються зеленими.
Зафіксувати робочий квант функціональності у системі контролю версій (Git) зі зрозумілим повідомленням про зміну контракту.