В історії розвитку Test-Driven Development сформувалися два фундаментальні архітектурні напрями: Класична (Чиказька / Детройтська) школа та Лондонська (Mockist / Outside-In) школа. Розбіжність між ними полягає не в синтаксичних уподобаннях, а в розумінні того, що саме є одиницею ізоляції (Unit of Isolation) та як тести повинні спрямовувати декомпозицію системи.
Чиказька школа (Classicist / Detroit) спирається на принцип чорної скриньки: тестується публічна поведінка групи взаємодіючих об'єктів з використанням їхніх реальних екземплярів або швидких реалізацій у пам'яті (In-Memory Fakes). Мокуються виключно недетерміновані зовнішні межі вводу-виводу (бази даних, HTTP-запити, системний годинник, файлова система).
Лондонська школа (Mockist / Outside-In) виникла як інструмент проєктування об'єктно-орієнтованих систем ззовні-всередину (Outside-In). Інженер розпочинає з точки входу (API Controller або UI-форма) та ітеративно формулює протоколи взаємодії між ролями ще до того, як ці нижчі компоненти будуть фізично реалізовані в кодовій базі.
Фундаментальна відмінність проявляється в тому, як вибудовується граф викликів і в якій точці фіксуються очікування тестового фреймворку.
+-----------------------------------------------------------------------+
| ЧИКАЗЬКА ШКОЛА (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 меж (БД, мережа, годинник, файлова система). | Повсюдно для будь-яких зовнішніх колабораторів та вторинних інтерфейсів. |
Реалізуємо сценарій оформлення замовлення: 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');
});
});Лондонський підхід ізолює PlaceOrderUseCase від будь-яких сторонніх реалізацій. Залежності підміняються шпигунами, що перевіряють строгий протокол викликів.
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: Перевірка протоколу взаємодії
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 };
}
}Спроба мокувати незмінні доменні структури на зразок Money, CurrencyCode або DateRange. Об'єкти-значення не мають ідентичності, детерміновані й не мають побічних ефектів. Їхнє мокування породжує колосальну крихкість. Завжди використовуйте справжні екземпляри Value Objects в обох школах.
Твердження на точний порядок чи частоту допоміжних викликів читання, які не впливають на бізнес-результат. Якщо розробник оптимізує код (наприклад, об'єднає запити в один батч), мокований тест зламається, хоча контракт не порушено. Мокуйте лише границі командних мутацій (Side Effects).
У Лондонській школі інтерфейс залежності може змінити сигнатуру в продакшні, але якщо тести спираються на застарілий mockResolvedValue(), сьют залишиться хибно зеленим. Запобігайте цьому через сувору типізацію моків у TypeScript та контрактні інтеграційні тести.
Використання рефлексії чи приведення типів (vi.spyOn(sut as any, 'privateHelper')) для перевірки внутрішніх функцій. Це найгірший антипатерн: тест прив'язується до структури приватного коду і позбавляє розробника можливості рефакторингу. Тестуйте поведінку виключно через публічне API.
Сутності, бізнес-правила, агрегати, розрахунки. Використовувати Чиказьку школу: реальні об'єкти, перевірка стану, нуль моків.
Оркестрація процесів, координація. Використовувати Лондонську школу: мокування вихідних портів для перевірки протоколу.
SQL-репозиторії, HTTP-клієнти, брокери повідомлень. Застосовувати Чиказькі інтеграційні тести з Testcontainers без мокування.
Компоненти та форми. Застосовувати Чиказьку філософію (React Testing Library): перевірка доступних ролей ARIA у реальному DOM.