Understand the two major TDD strategies: outside-in (London school) starts from acceptance tests and works inward, while inside-out (Chicago school) builds from the bottom up. Learn when each approach is appropriate.
There are two major schools of TDD practice, each representing a different starting point and design philosophy. The London School (outside-in, also called 'mockist' TDD) starts with a failing acceptance test for the complete feature and works inward, mocking each layer as it is designed. The Chicago School (inside-out, also called 'classicist' TDD) starts with the innermost domain objects and builds outward, integrating real collaborators.
Neither approach is universally correct. Understanding both — and the tradeoffs — lets you choose the right tool for each situation. This lesson builds the same feature using both approaches so you can experience the differences directly.
Outside-in TDD starts with the user's perspective: write a failing test for the complete feature, then work inward layer by layer, mocking each collaborator you have not yet implemented.
1Outside-In TDD Flow:
21. Write a failing acceptance test (E2E or API level)
32. Write a failing test for the outermost layer (controller)
43. Implement the controller, mocking the service layer
54. Write a failing test for the service layer
65. Implement the service, mocking the repository layer
76. Write a failing test for the repository layer
87. Implement the repository using real DB
98. All layers integrate — acceptance test passes1// Outside-In TDD: building POST /courses endpoint
2
3// STEP 1: Failing acceptance test (outermost layer)
4test('POST /courses creates a course and returns 201', async () => {
5 const res = await request(app)
6 .post('/courses')
7 .set('Authorization', `Bearer ${instructorToken}`)
8 .send({ title: 'TypeScript Fundamentals', price: 49.99, level: 'BEGINNER' });
9 expect(res.status).toBe(201);
10 expect(res.body.id).toBeDefined();
11});
12// FAIL — route does not exist
13
14// STEP 2: Controller test (mocked service)
15test('CoursesController.create calls service and returns 201', async () => {
16 const mockService = { create: jest.fn().mockResolvedValue({ id: '1', title: 'TypeScript' }) };
17 const controller = new CoursesController(mockService);
18 const mockReq = { body: { title: 'TypeScript', price: 49, level: 'BEGINNER' }, user: { id: 'instr-1' } };
19 const mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
20
21 await controller.create(mockReq as any, mockRes as any);
22
23 expect(mockService.create).toHaveBeenCalledWith(
24 expect.objectContaining({ title: 'TypeScript', instructorId: 'instr-1' })
25 );
26 expect(mockRes.status).toHaveBeenCalledWith(201);
27});
28// Green: implement CoursesController with injected service
29
30// STEP 3: Service test (mocked repository)
31test('CoursesService.create validates and saves course', async () => {
32 const mockRepo = { save: jest.fn().mockResolvedValue({ id: '1', title: 'TypeScript' }) };
33 const service = new CoursesService(mockRepo);
34 const result = await service.create({ title: 'TypeScript', price: 49, level: 'BEGINNER', instructorId: 'i-1' });
35 expect(mockRepo.save).toHaveBeenCalled();
36 expect(result.id).toBe('1');
37});
38// Green: implement CoursesService with injected repository
39
40// STEP 4: Repository test (real database)
41test('CoursesRepository.save persists course to database', async () => {
42 const repo = new CoursesRepository(testDb);
43 const saved = await repo.save({ title: 'TypeScript', price: 49, level: 'BEGINNER', instructorId: 'i-1' });
44 expect(saved.id).toBeDefined();
45 const found = await testDb.course.findUnique({ where: { id: saved.id } });
46 expect(found?.title).toBe('TypeScript');
47});
48// Green: implement CoursesRepository using Prisma
49
50// All steps done → acceptance test now passes ✅Inside-out TDD builds from the domain model upward. Start with the purest logic (domain objects), then add services, then controllers. No mocks needed until integration.
1Inside-Out TDD Flow:
21. Start with domain model tests (pure logic, no I/O)
32. Build the repository with real database tests
43. Build the service using real repository
54. Build the controller using real service
65. Integration test ties it all together1// Inside-Out TDD: building the same course feature
2
3// STEP 1: Domain object tests (pure logic, no DB)
4test('Course validates that price is non-negative', () => {
5 expect(() => new Course({ title: 'TypeScript', price: -1, level: 'BEGINNER' }))
6 .toThrow('Price must be non-negative');
7});
8
9test('Course is draft by default', () => {
10 const course = new Course({ title: 'TypeScript', price: 49, level: 'BEGINNER' });
11 expect(course.published).toBe(false);
12});
13
14test('Course.publish() marks it as published', () => {
15 const course = new Course({ title: 'TypeScript', price: 49, level: 'BEGINNER' });
16 course.publish();
17 expect(course.published).toBe(true);
18});
19// Green: implement Course class with validation and publish()
20
21// STEP 2: Repository tests (real DB)
22test('CoursesRepository.save persists a Course domain object', async () => {
23 const repo = new CoursesRepository(testDb);
24 const course = new Course({ title: 'TypeScript', price: 49, level: 'BEGINNER' });
25 const saved = await repo.save(course, 'instructor-1');
26 expect(saved.id).toBeDefined();
27});
28// Green: implement CoursesRepository
29
30// STEP 3: Service tests (real repository)
31test('CoursesService.create saves a new course', async () => {
32 const repo = new CoursesRepository(testDb);
33 const service = new CoursesService(repo);
34 const result = await service.create(
35 { title: 'TypeScript', price: 49, level: 'BEGINNER' },
36 'instructor-1'
37 );
38 expect(result.id).toBeDefined();
39});
40// Green: implement CoursesService using real repo
41
42// STEP 4: Controller test (real service + supertest)
43test('POST /courses creates course and returns 201', async () => {
44 const res = await request(app)
45 .post('/courses')
46 .set('Authorization', `Bearer ${instructorToken}`)
47 .send({ title: 'TypeScript', price: 49, level: 'BEGINNER' });
48 expect(res.status).toBe(201);
49});
50// Green: wire controller with real service1OUTSIDE-IN (London School):
2 Starts from: User/acceptance perspective
3 Uses: Many mocks (each layer mocks its collaborator)
4 Pros:
5 + Discovers interface design issues early (mocking forces clean APIs)
6 + Acceptance test drives the whole feature from the start
7 + Tests are fast (mocked dependencies)
8 Cons:
9 - Over-mocking risk: tests pass but integration breaks
10 - Mock maintenance overhead as interfaces change
11 - Developer must design all interfaces upfront
12
13INSIDE-OUT (Chicago School):
14 Starts from: Domain model / core logic
15 Uses: Few mocks (real collaborators are used)
16 Pros:
17 + No mock fragility — tests use real objects
18 + Design emerges naturally from logic
19 + Integration issues caught during development
20 Cons:
21 - Early tests do not validate end-to-end behaviour
22 - Domain object design may need rework when integrating
23 - Tests are slower (real DB operations)
24
25WHEN TO USE WHICH:
26 Use OUTSIDE-IN when:
27 - You are adding a new feature to an existing architecture
28 (controller/service/repo pattern already established)
29 - You have clear requirements but uncertain implementation
30 - The API contract is the most important thing to get right
31
32 Use INSIDE-OUT when:
33 - You are building a new domain with unclear boundaries
34 - The business logic is complex and must be proven first
35 - You want to avoid mocks and use real collaboration
36 - The database schema is still being designedMost experienced TDD practitioners use a hybrid: inside-out for complex domain logic, outside-in for the integration of layers.
1// Hybrid TDD for a discount engine:
2
3// PHASE 1: INSIDE-OUT — complex domain logic first
4// (no mocks, pure logic, fast tests)
5describe('DiscountCalculator (inside-out)', () => {
6 test('applies percentage discount', () => { ... });
7 test('applies fixed amount discount', () => { ... });
8 test('stacks multiple discounts correctly', () => { ... });
9 test('does not discount below zero', () => { ... });
10 // 8 precise tests for the discount algorithm
11 // All fast (pure functions, no DB)
12});
13
14// PHASE 2: OUTSIDE-IN — layer integration
15// (mock repository, test service wires correctly)
16describe('OrderService.applyDiscount (outside-in)', () => {
17 let mockRepo: jest.Mocked<OrderRepository>;
18 let service: OrderService;
19
20 beforeEach(() => {
21 mockRepo = { findById: jest.fn(), save: jest.fn() };
22 service = new OrderService(mockRepo, new DiscountCalculator()); // real calculator!
23 });
24
25 test('fetches order, applies discount, and saves', async () => {
26 mockRepo.findById.mockResolvedValue({ id: '1', items: [{ price: 100, qty: 1 }], total: 100 });
27 mockRepo.save.mockResolvedValue(undefined);
28
29 await service.applyDiscount('1', { type: 'percentage', value: 10 });
30
31 expect(mockRepo.save).toHaveBeenCalledWith(
32 expect.objectContaining({ total: 90 })
33 );
34 });
35});
36
37// Note: DiscountCalculator is NOT mocked in the service test
38// It is a real dependency because it has no I/O — only the DB is mocked
39// This is the key insight of the hybrid approach:
40// Mock I/O boundaries (DB, HTTP), use real pure logic1// Decision checklist for each new feature:
2
3// Q1: Do I understand the domain logic well?
4// YES → Inside-out: start with domain objects
5// NO → Outside-in: start with acceptance test to discover what's needed
6
7// Q2: Is the architecture already established?
8// YES → Outside-in: slot the feature into the existing pattern
9// NO → Inside-out: let the design emerge from the logic
10
11// Q3: Is fast feedback most important?
12// YES → Outside-in (mocked): all tests run in milliseconds
13// NO, correctness is paramount → Inside-out: real DB catches integration bugs
14
15// Q4: Am I spiking/exploring?
16// YES → Skip TDD for the spike, then TDD the production implementation
17// (A spike is a time-boxed exploration without TDD discipline)
18
19// SPIKE → THROW AWAY → TDD:
20function spike() {
21 // write quick-and-dirty code to learn the API/domain
22 // NO TESTS during the spike
23 // After: throw away spike code, start TDD session informed by what you learned
24}
25
26// Example:
27// Spike: explore the Stripe API for 30 minutes, try a real charge
28// Learning: understand the PaymentIntent flow, required fields, error codes
29// Throw away spike code
30// TDD: write the StripePaymentService using outside-in TDD
31// driven by the knowledge gained from the spike
32// with Stripe mocked via MSW
33
34// The spike prevents wasted TDD cycles on a wrong implementation
35// TDD prevents shipping the exploratory spike as production codeThe same user registration feature built using both approaches side-by-side, demonstrating the different test structures, mock usage, and design emergence.
1// ===== OUTSIDE-IN APPROACH =====
2// Acceptance test first
3
4// Step 1: Failing acceptance test
5describe('Outside-In: POST /auth/register', () => {
6 test('creates account and returns tokens', async () => {
7 const res = await request(app)
8 .post('/auth/register')
9 .send({ name: 'Alice', email: `alice-${Date.now()}@example.com`, password: 'SecurePass1!' });
10 expect(res.status).toBe(201);
11 expect(res.body.accessToken).toBeDefined();
12 });
13});
14
15// Step 2: Controller test (mocked service)
16describe('AuthController.register', () => {
17 test('calls service and returns 201 with tokens', async () => {
18 const mockService = {
19 register: jest.fn().mockResolvedValue({ user: { id: '1' }, accessToken: 'tok', refreshToken: 'ref' }),
20 };
21 // ... controller test using mock service
22 });
23});
24
25// Step 3: Service test (mocked repo + mocked email)
26describe('AuthService.register', () => {
27 test('hashes password, saves user, and sends welcome email', async () => {
28 const mockRepo = { create: jest.fn().mockResolvedValue({ id: '1', email: '[email protected]' }) };
29 const mockEmail = { sendWelcome: jest.fn().mockResolvedValue(undefined) };
30 const service = new AuthService(mockRepo, mockEmail);
31 // ... service test
32 });
33});
34
35// Step 4: Repository test (real DB)
36describe('UserRepository.create', () => {
37 test('saves user to database', async () => {
38 const repo = new UserRepository(testDb);
39 const user = await repo.create({ name: 'Alice', email: '[email protected]', passwordHash: 'hash' });
40 expect(user.id).toBeDefined();
41 });
42});
43
44// ===== INSIDE-OUT APPROACH =====
45// Domain logic first
46
47// Step 1: Password hashing (pure utility)
48describe('Inside-Out: hashPassword', () => {
49 test('produces a bcrypt hash', async () => {
50 const hash = await hashPassword('SecurePass1!');
51 expect(hash).toMatch(/^\$2b\$/);
52 expect(hash).not.toBe('SecurePass1!');
53 });
54
55 test('produces different hashes for same password (random salt)', async () => {
56 const h1 = await hashPassword('same-password');
57 const h2 = await hashPassword('same-password');
58 expect(h1).not.toBe(h2);
59 });
60
61 test('verifyPassword returns true for matching password', async () => {
62 const hash = await hashPassword('mypassword');
63 expect(await verifyPassword('mypassword', hash)).toBe(true);
64 });
65});
66
67// Step 2: User domain object
68describe('User domain object', () => {
69 test('throws for invalid email format', () => {
70 expect(() => new User({ name: 'Alice', email: 'notanemail', passwordHash: 'hash' }))
71 .toThrow('Invalid email');
72 });
73
74 test('starts with emailVerified = false', () => {
75 const user = new User({ name: 'Alice', email: '[email protected]', passwordHash: 'hash' });
76 expect(user.emailVerified).toBe(false);
77 });
78});
79
80// Step 3: Repository (real DB)
81describe('UserRepository', () => {
82 test('saves user and returns with generated id', async () => {
83 const repo = new UserRepository(testDb);
84 const user = new User({ name: 'Alice', email: `a${Date.now()}@b.com`, passwordHash: 'hash' });
85 const saved = await repo.save(user);
86 expect(saved.id).toBeDefined();
87 });
88});
89
90// Step 4: Service (uses real repo)
91describe('AuthService (real repo)', () => {
92 test('register creates user and returns access token', async () => {
93 const repo = new UserRepository(testDb);
94 const service = new AuthService(repo);
95 const result = await service.register(
96 { name: 'Alice', email: `alice${Date.now()}@b.com`, password: 'SecurePass1!' }
97 );
98 expect(result.accessToken).toBeDefined();
99 expect(result.user.id).toBeDefined();
100 });
101});
102
103// Observation:
104// Outside-in: all layers are tested in 4 steps, but many mocks needed
105// Inside-out: domain logic is rock-solid, fewer mocks, but slower testsThe outside-in approach produced clean layer interfaces through mock discipline — the service interface was designed by writing the service test before the implementation. The inside-out approach produced a battle-tested domain model with zero mocks for pure logic. Neither is wrong. In practice, the hybrid is best: use inside-out for the domain model (hashPassword, User) and outside-in for the integration (AuthService → UserRepository → DB).
The next lesson applies TDD to a complete real-world project: building a course enrollment service end-to-end using the hybrid TDD approach, with domain tests, service tests, and integration tests.
Your progress is saved in this browser, on this device only.