В традиционном подходе к разработке программного обеспечения тесты пишутся постфактум — после реализации бизнес-логики. Это создает фундаментальную архитектурную проблему: инженер пишет тесты, подстраиваясь под уже созданную внутреннюю структуру классов, замыканий и структур данных. В результате тесты оказываются жестко сцепленными с деталями реализации (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 нацелена на:
Выбрать один атомарный аспект поведения системы. Написать тест, описывающий вызов публичного API и ожидаемый результат. Запустить тест и убедиться, что он упал по понятной причине.
Написать ровно столько кода, сколько требуется для превращения теста в зеленый. Допустимо использовать простейшие хардкоды или прямолинейные условия.
Запустить раннер тестов. Убедиться, что не только новый тест, но и все ранее написанные тесты в сьюте успешно выполняются.
Оценить код на дублирование, именование, сложность методов и соответствие SOLID. Отрефакторить код и убедиться, что все тесты по-прежнему зеленые.
Зафиксировать рабочий квант функциональности в системе контроля версий (Git) с понятным сообщением об изменении контракта.
Почему падение теста на фазе RED является обязательным условием до написания хотя бы одной строки продакшен-кода?
Рассмотрим реализацию сервиса валидации и декодирования токенов доступа с ограниченными правами (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% тестов остаются зелеными, но внутренняя структура системы стала модульной, строго типизированной и готовой к расширению.
Какое действие КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выполнять разработчику, находясь на фазе REFACTOR трехфазного цикла TDD?
Написание тестов, проверяющих вызовы приватных методов или внутренние структуры данных (например, инспекция приватного поля evaluator['cachedScopes']). При малейшем рефакторинге такие тесты ломаются, даже если публичное поведение системы абсолютно корректно. Тестируйте исключительно публичный контракт: входные аргументы и выходные результаты.
Переход к следующей задаче сразу после фазы Green без анализа запахов кода (Code Smells). Накопление технического долга в TDD происходит именно здесь: без рефакторинга кодовая база превращается в нагромождение костылей и хардкодов, написанных на скорую руку для прохождения тестов.
Попытка написать один гигантский тест, покрывающий сразу сложный многоуровневый сценарий с пятью ветвлениями. Это разрушает обратную связь: если тест падает, невозможно определить, какая именно часть логики дала сбой. Делайте минимальные шаги: один тест — один квант бизнес-требования.
Попытка сразу на этапе Green написать сложную фабрику классов, генерирующую универсальные парсеры для любых форматов данных. На этапе Green пишите минимальный, даже наивный код. Архитектурное обобщение допустимо только на этапе Refactor, когда триангуляция тестов подтвердила реальную необходимость абстракции.