Throughout the history of Test-Driven Development, two foundational architectural schools emerged: the Classicist (Chicago / Detroit) school and the Mockist (London / Outside-In) school. The divergence between them is not stylistic syntactic trivia; it reflects fundamentally contrasting answers to what constitutes a unit of isolation and how test construction should guide system decomposition.
Chicago School (Classicist / Detroit) treats software components as black boxes. It exercises the public behavior of a collaborating cluster of objects using real instances or high-performance in-memory implementations (In-Memory Fakes). Mocks are reserved strictly for non-deterministic out-of-process boundaries (databases, HTTP APIs, system clocks, file system I/O).
London School (Mockist / Outside-In) emerged as an architectural design technique for decomposing object-oriented systems from the perimeter inward. Developers begin with an entry-point scenario (API Controller, CLI Command, or UI Form) and iteratively discover and formalize protocol contracts between collaborating roles before those lower-tier components physically exist in the codebase.
The fundamental distinction manifests in call-graph construction and where test framework expectations are anchored.
+-----------------------------------------------------------------------+
| CHICAGO SCHOOL (STATE VERIFICATION) |
+-----------------------------------------------------------------------+
| [Test] |
| │ |
| ▼ (Invoke Public Method) |
| +-------------------+ +-------------------+ |
| | SUT (OrderService)| -----> | Collaborator | (Real Object) |
| +-------------------+ | (OrderLine / Tax) | |
| │ +-------------------+ |
| ▼ |
| [Assert Terminal State: expect(order.total).toBe(120)] |
+-----------------------------------------------------------------------+
+-----------------------------------------------------------------------+
| LONDON SCHOOL (INTERACTION VERIFICATION) |
+-----------------------------------------------------------------------+
| [Test] |
| │ |
| ▼ (Invoke Public Method) |
| +-------------------+ +-------------------+ |
| | SUT (OrderService)| -----> | Mock / Spy | (Test Double) |
| +-------------------+ | (PaymentGateway) | |
| +-------------------+ |
| [Assert Message Protocol: expect(mockGateway.charge).toBeCalled()] |
+-----------------------------------------------------------------------+| Architectural Dimension | Chicago School (Classicist) | London School (Mockist) | | :--- | :--- | :--- | | Unit of Isolation | Unit of Behavior. An object is tested cohesively alongside its real collaborating dependencies. | Unit of Code / Class. An object is isolated strictly from all surrounding peers. | | Verification Method | State Verification: Asserts return values, state mutations, and persistent side-effects. | Interaction Verification: Asserts communication protocols (method invocation signatures and counts). | | Direction of Design | Inside-Out: From core domain algorithms upward toward coordinating layers. | Outside-In: From perimeter boundaries (API / UI) downward through discovering peer roles. | | Coupling to Code | Low. Internal refactoring preserves passing tests as long as public invariants hold. | High. Changing dependency signatures or execution sequences breaks tests. | | Failure Localization | Broad. A regression in a deeply nested helper can cause higher-level tests to fail. | Precise. Only the test suite dedicated to the defect-harboring class fails. | | Test Double Strategy | Reserved for external I/O boundaries (relational DBs, HTTP clients, clocks). | Ubiquitous across all collaborating domain peers and secondary interfaces. |
We demonstrate both schools by implementing an order processing scenario: 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>;
}The Chicago approach constructs fast in-memory fakes. The test verifies state transformations: stock is decremented and the order entity is persisted.
import { describe, it, expect, beforeEach } from 'vitest';
import { PlaceOrderUseCase } from './place-order-use-case';
import { Order, OrderRepository, InventoryRepository, NotificationPort } from './types';
// In-Memory Fake: Fulfills the genuine contract without I/O latency
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('successfully places order and deducts inventory stock (State Verification)', async () => {
// Arrange: Seed real in-memory state
inventoryRepo.setStock('prod_laptop', 10);
// Act: Invoke public use case boundary
const result = await useCase.execute({
orderId: 'ord_999',
customerEmail: '[email protected]',
items: [{ productId: 'prod_laptop', quantity: 2, unitPriceCents: 150000 }],
});
// Assert: Verify persisted state transformations
expect(result.success).toBe(true);
// 1. Verify decremented balance in inventory store
expect(inventoryRepo.getStock('prod_laptop')).toBe(8);
// 2. Verify state persistence of the order
const savedOrder = await orderRepo.findById('ord_999');
expect(savedOrder).not.toBeNull();
expect(savedOrder?.totalCents).toBe(300000);
expect(savedOrder?.status).toBe('CREATED');
// 3. Verify notification side effect
expect(notificationPort.sentEmails).toHaveLength(1);
expect(notificationPort.sentEmails[0]?.orderId).toBe('ord_999');
});
});The London approach isolates PlaceOrderUseCase completely. Collaborators are replaced with spies verifying that the orchestration protocol was dispatched with exact arguments.
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('orchestrates outbound port protocols exactly as specified (Interaction Verification)', async () => {
// Arrange: Construct strict spies for all collaborators
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: Verify interaction protocol
expect(result.success).toBe(true);
// 1. Verify reservation protocol invoked with exact arguments
expect(mockInventoryRepo.reserveStock).toHaveBeenCalledTimes(1);
expect(mockInventoryRepo.reserveStock).toHaveBeenCalledWith('prod_laptop', 2);
// 2. Verify order persistence dispatch
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1);
expect(mockOrderRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ord_999',
totalCents: 300000,
status: 'CREATED',
})
);
// 3. Verify customer notification dispatch
expect(mockNotificationPort.sendOrderConfirmation).toHaveBeenCalledTimes(1);
expect(mockNotificationPort.sendOrderConfirmation).toHaveBeenCalledWith(
'[email protected]',
'ord_999'
);
});
});Below is the clean PlaceOrderUseCase satisfying the specifications of both schools:
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. Validate and reserve inventory stock
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. Compute total cost in cents
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. Persist order entity
await this.orderRepo.save(order);
// 4. Dispatch external notification
await this.notificationPort.sendOrderConfirmation(
command.customerEmail,
command.orderId
);
return { success: true };
}
}Mocking immutable types such as Money, CurrencyCode, or DateRange. Value Objects are deterministic, side-effect free, and have no identity. Mocking them introduces needless brittleness and pollutes test suites with boilerplate. Always use genuine Value Objects across both schools.
Asserting the exact invocation order or frequency of intermediate helper reads that have zero bearing on external business contracts. If an engineer optimizes internal queries into a single batch call, overspecified interaction tests shatter despite a compliant public contract. Mock only outbound mutation boundaries (commands), not intermediate query lookups.
In London TDD, a collaborator's interface may change in production, but if unit tests rely on outdated mock stubs (mockResolvedValue), the suite remains falsely green while production breaks. Mitigate this by grounding mocks in strict TypeScript types and supplementing with integration contract tests.
Using reflection or type coercions (vi.spyOn(sut as any, 'privateHelper')) to inspect private helper calls. This ties tests directly to private implementation structure, paralyzing refactoring. Assert behavior strictly through public APIs.
Business rules, calculations, and state invariants. Apply Chicago School: real objects, state assertions, zero mocks.
Process orchestration and coordination. Apply London School: mock outbound ports to verify orchestration order.
SQL repositories, HTTP gateways, message queues. Apply Chicago integration tests using Testcontainers or in-memory polyfills without mocks.
Views and layout hierarchies. Apply Chicago philosophy (React Testing Library): test observable accessibility roles and real DOM rendering.