В истории развития Test-Driven Development сформировались два фундаментальных архитектурных направления: Классическая (Чикагская / Детройтская) школа и Лондонская (Mockist) школа. Раскол между ними лежит не в синтаксических предпочтениях, а в понимании того, что является единицей изоляции (Unit of Isolation) и как тесты должны влиять на декомпозицию системы.
Чикагская школа (Classicist / Detroit) опирается на принцип черного ящика: тестируется публичное поведение группы кооперирующихся объектов, используя их реальные экземпляры или легковесные реализации в памяти (In-Memory Fakes). Мокируются исключительно недетерминированные внешние границы ввода-вывода (база данных, HTTP-запросы, системное время, генераторы случайных чисел).
Лондонская школа (Mockist / Outside-In) возникла как инструмент проектирования объектно-ориентированных систем сверху вниз (Outside-In). Разработчик начинает с внешнего сценария использования (Use Case / Controller) и формулирует протоколы взаимодействия между ролями еще до того, как эти роли будут физически реализованы в кодовой базе.
Различие школ проявляется в том, как выстраивается граф вызовов и в какой точке фиксируются ожидания тестового фреймворка.
+-----------------------------------------------------------------------+
| ЧИКАГСКАЯ ШКОЛА (STATE VERIFICATION) |
+-----------------------------------------------------------------------+
| [Тест] |
| │ |
| ▼ (Вызов публичного метода) |
| +-------------------+ +-------------------+ |
| | SUT (OrderService)| -----> | Collaborator | (Реальный объект) |
| +-------------------+ | (OrderLine / Tax) | |
| │ +-------------------+ |
| ▼ |
| [Проверка результирующего состояния: expect(order.total).toBe(120)] |
+-----------------------------------------------------------------------+
+-----------------------------------------------------------------------+
| ЛОНДОНСКАЯ ШКОЛА (INTERACTION VERIFICATION) |
+-----------------------------------------------------------------------+
| [Тест] |
| │ |
| ▼ (Вызов публичного метода) |
| +-------------------+ +-------------------+ |
| | SUT (OrderService)| -----> | Mock / Spy | (Тестовый дублер) |
| +-------------------+ | (PaymentGateway) | |
| +-------------------+ |
| [Проверка вызова протокола: expect(mockGateway.charge).toBeCalled()] |
+-----------------------------------------------------------------------+| Параметр сравнения | Чикагская школа (Classicist) | Лондонская школа (Mockist) | | :--- | :--- | :--- | | Единица изоляции | Поведение (Unit of Behavior). Объект тестируется вместе со своими реальными зависимостями. | Модуль / Класс (Unit of Code). Объект изолируется от всех соседей. | | Метод верификации | State Verification: Проверка возвращаемого значения и мутации состояния. | Interaction Verification: Проверка отправки сообщений (вызовов методов с аргументами). | | Направление дизайна | Inside-Out: От простых доменных моделей к комплексным интеграциям. | Outside-In: От точки входа (API / UI) вниз по дереву абстракций. | | Связность с кодом | Низкая. Внутренний рефакторинг не ломает тесты, пока контракт соблюден. | Высокая. Изменение сигнатуры зависимостей или порядка вызовов ломает тест. | | Локализация ошибки | Размытая. Падение теста в сервисе может быть вызвано багом в глубоком хелпере. | Точечная. Падает только тест класса, содержащего непосредственную ошибку. | | Использование дублеров | Только для внешних I/O ресурсов (DB, Network, Clock, FS). | Повсеместно для любых внешних коллабораторов и интерфейсов. |
Сущности, расчеты, агрегаты, алгоритмы. Использовать Чикагскую школу: только реальные объекты, проверка возвращаемых значений и инвариантов состояния.
Оркестрация процессов, интеграционные сценарии. Использовать элементы Лондонской школы: мокирование портов (Outbound Ports) для верификации координации.
SQL-репозитории, HTTP-клиенты, очереди брокеров сообщений. Использовать интеграционные тесты с Testcontainers или In-Memory полифилами без имитации протокола.
Компоненты и страницы. Использовать Чикагскую философию (React Testing Library): тестирование через доступные роли ARIA и рендеринг реального DOM-дерева.
В чем заключается фундаментальное отличие Чикагской (Classicist) школы TDD от Лондонской (Mockist) в вопросе верификации?
Реализуем сценарий использования PlaceOrderUseCase, включающий списание остатков товара на складе, создание заказа и отправку уведомления клиенту.
export interface OrderItem {
readonly productId: string;
readonly quantity: number;
readonly unitPriceCents: number;
}
export interface Order {
readonly id: string;
readonly customerEmail: string;
readonly items: readonly OrderItem[];
readonly totalCents: number;
readonly status: 'CREATED' | 'FAILED';
}
export interface InventoryRepository {
reserveStock(productId: string, quantity: number): Promise<boolean>;
}
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(orderId: string): Promise<Order | null>;
}
export interface NotificationPort {
sendOrderConfirmation(email: string, orderId: string): Promise<void>;
}Классический подход использует легковесные реализации репозиториев в оперативной памяти (In-Memory Fakes). Тест проверяет результат изменения состояния системы — заказ сохранен в хранилище, а баланс остатков уменьшился.
import { describe, it, expect, beforeEach } from 'vitest';
import { PlaceOrderUseCase } from './place-order-use-case';
import { Order, OrderRepository, InventoryRepository, NotificationPort } from './types';
// In-Memory Fake: Реализует настоящий контракт без внешнего I/O
class FakeInventoryRepository implements InventoryRepository {
private stock = new Map<string, number>();
setStock(productId: string, qty: number): void {
this.stock.set(productId, qty);
}
getStock(productId: string): number {
return this.stock.get(productId) ?? 0;
}
async reserveStock(productId: string, quantity: number): Promise<boolean> {
const current = this.stock.get(productId) ?? 0;
if (current < quantity) return false;
this.stock.set(productId, current - quantity);
return true;
}
}
class FakeOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async save(order: Order): Promise<void> {
this.orders.set(order.id, order);
}
async findById(orderId: string): Promise<Order | null> {
return this.orders.get(orderId) ?? null;
}
}
class FakeNotificationPort implements NotificationPort {
public sentEmails: Array<{ email: string; orderId: string }> = [];
async sendOrderConfirmation(email: string, orderId: string): Promise<void> {
this.sentEmails.push({ email, orderId });
}
}
describe('PlaceOrderUseCase (Chicago School)', () => {
let inventoryRepo: FakeInventoryRepository;
let orderRepo: FakeOrderRepository;
let notificationPort: FakeNotificationPort;
let useCase: PlaceOrderUseCase;
beforeEach(() => {
inventoryRepo = new FakeInventoryRepository();
orderRepo = new FakeOrderRepository();
notificationPort = new FakeNotificationPort();
useCase = new PlaceOrderUseCase(inventoryRepo, orderRepo, notificationPort);
});
it('должен успешно оформить заказ и списать товар со склада (State Verification)', async () => {
// Arrange: Настройка начального состояния
inventoryRepo.setStock('prod_laptop', 10);
// Act: Вызов публичного сценария
const result = await useCase.execute({
orderId: 'ord_999',
customerEmail: '[email protected]',
items: [{ productId: 'prod_laptop', quantity: 2, unitPriceCents: 150000 }],
});
// Assert: Проверка изменения состояния (State Verification)
expect(result.success).toBe(true);
// 1. Проверяем остаток в доменном хранилище
expect(inventoryRepo.getStock('prod_laptop')).toBe(8);
// 2. Проверяем персистентность заказа
const savedOrder = await orderRepo.findById('ord_999');
expect(savedOrder).not.toBeNull();
expect(savedOrder?.totalCents).toBe(300000);
expect(savedOrder?.status).toBe('CREATED');
// 3. Проверяем факт доставки уведомления
expect(notificationPort.sentEmails).toHaveLength(1);
expect(notificationPort.sentEmails[0]?.orderId).toBe('ord_999');
});
});Лондонский подход изолирует тестируемый класс от любых сторонних реализаций. Все зависимости подменяются шпионами (Spies/Mocks), проверяющими строгий протокол вызовов.
import { describe, it, expect, vi } from 'vitest';
import { PlaceOrderUseCase } from './place-order-use-case';
import { InventoryRepository, OrderRepository, NotificationPort } from './types';
describe('PlaceOrderUseCase (London School)', () => {
it('должен координировать вызовы портов согласно протоколу (Interaction Verification)', async () => {
// Arrange: Создание строгих моков для всех коллабораторов
const mockInventoryRepo: InventoryRepository = {
reserveStock: vi.fn().mockResolvedValue(true),
};
const mockOrderRepo: OrderRepository = {
save: vi.fn().mockResolvedValue(undefined),
findById: vi.fn().mockResolvedValue(null),
};
const mockNotificationPort: NotificationPort = {
sendOrderConfirmation: vi.fn().mockResolvedValue(undefined),
};
const useCase = new PlaceOrderUseCase(
mockInventoryRepo,
mockOrderRepo,
mockNotificationPort
);
// Act
const result = await useCase.execute({
orderId: 'ord_999',
customerEmail: '[email protected]',
items: [{ productId: 'prod_laptop', quantity: 2, unitPriceCents: 150000 }],
});
// Assert: Проверка протокола взаимодействия (Interaction Verification)
expect(result.success).toBe(true);
// 1. Проверяем, что резервация вызвана с точными аргументами
expect(mockInventoryRepo.reserveStock).toHaveBeenCalledTimes(1);
expect(mockInventoryRepo.reserveStock).toHaveBeenCalledWith('prod_laptop', 2);
// 2. Проверяем вызов сохранения заказа
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1);
expect(mockOrderRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ord_999',
totalCents: 300000,
status: 'CREATED',
})
);
// 3. Проверяем отправку нотификации
expect(mockNotificationPort.sendOrderConfirmation).toHaveBeenCalledTimes(1);
expect(mockNotificationPort.sendOrderConfirmation).toHaveBeenCalledWith(
'[email protected]',
'ord_999'
);
});
});Ниже представлена чистая реализация PlaceOrderUseCase, успешно проходящая проверки обеих школ.
import {
InventoryRepository,
OrderRepository,
NotificationPort,
OrderItem,
Order,
} from './types';
export interface PlaceOrderCommand {
readonly orderId: string;
readonly customerEmail: string;
readonly items: readonly OrderItem[];
}
export interface PlaceOrderResult {
readonly success: boolean;
readonly error?: string;
}
export class PlaceOrderUseCase {
constructor(
private readonly inventoryRepo: InventoryRepository,
private readonly orderRepo: OrderRepository,
private readonly notificationPort: NotificationPort
) {}
public async execute(command: PlaceOrderCommand): Promise<PlaceOrderResult> {
// 1. Валидация остатков на складе
for (const item of command.items) {
const reserved = await this.inventoryRepo.reserveStock(
item.productId,
item.quantity
);
if (!reserved) {
return { success: false, error: `Insufficient stock for ${item.productId}` };
}
}
// 2. Вычисление доменного итога
const totalCents = command.items.reduce(
(acc, item) => acc + item.quantity * item.unitPriceCents,
0
);
const order: Order = {
id: command.orderId,
customerEmail: command.customerEmail,
items: command.items,
totalCents,
status: 'CREATED',
};
// 3. Сохранение заказа в репозиторий
await this.orderRepo.save(order);
// 4. Отправка подтверждения
await this.notificationPort.sendOrderConfirmation(
command.customerEmail,
command.orderId
);
return { success: true };
}
}Почему мокирование объектов-значений (Value Objects: Money, EmailAddress, DateRange) считается опасным антипаттерном в обеих школах TDD?
Попытка замокать классы или типы вроде Money, Currency, EmailAddress или DateTimeRange. Объекты-значения неизменяемы (immutable), детерминированы и не содержат побочных эффектов. Их мокирование вносит колоссальную хрупкость и раздувает бойлерплейт тестов. Используйте реальные экземпляры Value Objects в обеих школах.
Ассерты на точный порядок или количество вспомогательных вызовов, не влияющих на итоговый бизнес-результат. Если разработчик решает оптимизировать код (например, объединить несколько мелких чтений в один батч), тест Лондонской школы упадет, хотя контракт не нарушен. Мокируйте только ключевые командные границы (Side Effects), а не внутренние вызовы чтения.
В Лондонской школе интерфейс зависимости может измениться в продакшене, но если тесты продолжают использовать устаревший mockResolvedValue(), сьют останется зеленым при полностью сломанном продакшене. Решение: валидировать контракты моков через TypeScript-типы и дополнять интеграционными тестами адаптеров.
Использование шпионов (vi.spyOn(sut as any, 'privateHelper')) для инспекции внутренних функций. Это худший антипаттерн: тест привязывается к структуре приватного кода, лишая разработчика возможности рефакторинга. Тестируйте поведение класса исключительно через его публичные методы.