Apply the complete Red-Green-Refactor cycle to pure functions, utility classes, and data transformation logic — the bread and butter of backend TDD in TypeScript.
Pure functions are the ideal starting point for learning TDD because they have no side effects, no dependencies to mock, and a clear input-output relationship. A pure function's test is: given these inputs, the function returns this output. The feedback loop is immediate and the implementation stays small.
This lesson applies TDD to four common backend patterns: data transformation, class design, input parsing, and aggregate calculations. Each session demonstrates how writing tests first leads to a simpler, more correct implementation than writing code first.
Data transformation functions convert one shape of data to another. TDD is ideal here because each test specifies an input/output pair and the implementation must satisfy all of them.
1// TDD session: normaliseUserRecord
2// (Converts raw DB record to API response shape)
3
4// Test list:
5// [ ] maps id, name, email
6// [ ] formats createdAt as ISO string
7// [ ] excludes passwordHash
8// [ ] excludes deletedAt
9// [ ] handles null avatar → null in output
10// [ ] handles missing optional fields gracefully
11
12// CYCLE 1: Red
13test('maps id, name, and email from raw record', () => {
14 const raw = { id: '1', name: 'Alice', email: '[email protected]', passwordHash: 'secret', createdAt: new Date('2024-01-01') };
15 const result = normaliseUserRecord(raw);
16 expect(result).toMatchObject({ id: '1', name: 'Alice', email: '[email protected]' });
17});
18// Green: return { id: raw.id, name: raw.name, email: raw.email };
19
20// CYCLE 2: Red — ISO date
21test('formats createdAt as ISO 8601 string', () => {
22 const raw = { id: '1', name: 'Alice', email: '[email protected]', passwordHash: 'x', createdAt: new Date('2024-01-15T10:30:00Z') };
23 expect(normaliseUserRecord(raw).createdAt).toBe('2024-01-15T10:30:00.000Z');
24});
25// Green: createdAt: raw.createdAt.toISOString()
26
27// CYCLE 3: Red — exclude sensitive fields
28test('excludes passwordHash from the output', () => {
29 const raw = { id: '1', name: 'Alice', email: '[email protected]', passwordHash: 'bcrypt-hash', createdAt: new Date() };
30 const result = normaliseUserRecord(raw);
31 expect(result).not.toHaveProperty('passwordHash');
32});
33// Green: destructure to exclude
34
35// CYCLE 4: Red — null avatar
36test('includes null avatar when avatar is not set', () => {
37 const raw = { id: '1', name: 'Alice', email: '[email protected]', passwordHash: 'x', createdAt: new Date(), avatar: undefined };
38 expect(normaliseUserRecord(raw).avatar).toBeNull();
39});
40// Green: avatar: raw.avatar ?? null
41
42// Final implementation (emerged from 4 cycles):
43interface RawUserRecord {
44 id: string;
45 name: string;
46 email: string;
47 passwordHash: string;
48 createdAt: Date;
49 avatar?: string;
50 deletedAt?: Date;
51}
52
53interface UserDto {
54 id: string;
55 name: string;
56 email: string;
57 createdAt: string;
58 avatar: string | null;
59}
60
61export function normaliseUserRecord(raw: RawUserRecord): UserDto {
62 return {
63 id: raw.id,
64 name: raw.name,
65 email: raw.email,
66 createdAt: raw.createdAt.toISOString(),
67 avatar: raw.avatar ?? null,
68 // passwordHash and deletedAt are excluded by construction
69 };
70}TDD for classes starts by writing usage tests — how the class will be used — before writing the class itself. The tests design the interface.
1// TDD session: EventEmitter class (simplified)
2// Test list:
3// [ ] can register a listener for an event type
4// [ ] listener is called when that event is emitted
5// [ ] listener receives the event payload
6// [ ] multiple listeners for the same event are all called
7// [ ] listener for event A is not called when event B is emitted
8// [ ] listener can be removed with off()
9// [ ] once() listener fires only once
10
11// CYCLE 1: basic listener registration and emission
12test('calls a listener when its event is emitted', () => {
13 const emitter = new SimpleEventEmitter();
14 const listener = jest.fn();
15 emitter.on('click', listener);
16 emitter.emit('click');
17 expect(listener).toHaveBeenCalledTimes(1);
18});
19// Green: Map<string, Function[]>, push in on(), forEach in emit()
20
21// CYCLE 2: listener receives payload
22test('passes the event payload to the listener', () => {
23 const emitter = new SimpleEventEmitter<{ x: number; y: number }>();
24 const listener = jest.fn();
25 emitter.on('mousemove', listener);
26 emitter.emit('mousemove', { x: 10, y: 20 });
27 expect(listener).toHaveBeenCalledWith({ x: 10, y: 20 });
28});
29
30// CYCLE 3: event isolation
31test('does not call listener for a different event', () => {
32 const emitter = new SimpleEventEmitter();
33 const clickListener = jest.fn();
34 emitter.on('click', clickListener);
35 emitter.emit('keydown');
36 expect(clickListener).not.toHaveBeenCalled();
37});
38
39// CYCLE 4: remove listener
40test('off() removes a specific listener', () => {
41 const emitter = new SimpleEventEmitter();
42 const listener = jest.fn();
43 emitter.on('click', listener);
44 emitter.off('click', listener);
45 emitter.emit('click');
46 expect(listener).not.toHaveBeenCalled();
47});
48
49// CYCLE 5: once()
50test('once() listener fires only on the first emit', () => {
51 const emitter = new SimpleEventEmitter();
52 const listener = jest.fn();
53 emitter.once('ready', listener);
54 emitter.emit('ready');
55 emitter.emit('ready');
56 expect(listener).toHaveBeenCalledTimes(1);
57});
58
59// The class interface was designed entirely by these 5 tests
60// before a single line of implementation was writtenParsers convert unstructured input (strings, CSV, query parameters) into structured data. TDD is excellent here because parsing has many edge cases that are easy to miss.
1// TDD session: parseQueryString
2// '?page=2&limit=10&level=BEGINNER' → { page: 2, limit: 10, level: 'BEGINNER' }
3
4// Test list:
5// [ ] returns defaults for empty string
6// [ ] parses page as integer
7// [ ] parses limit as integer
8// [ ] parses level as string
9// [ ] ignores unknown parameters
10// [ ] throws for invalid page (non-numeric)
11// [ ] throws for page < 1
12// [ ] clamps limit to max 100
13// [ ] handles leading ? correctly
14
15test('returns defaults when query string is empty', () => {
16 expect(parseQueryString('')).toEqual({ page: 1, limit: 10, level: undefined });
17});
18
19test('parses page and limit as integers', () => {
20 expect(parseQueryString('?page=3&limit=20')).toMatchObject({ page: 3, limit: 20 });
21});
22
23test('throws for non-numeric page value', () => {
24 expect(() => parseQueryString('?page=abc')).toThrow('page must be a positive integer');
25});
26
27test('throws for page less than 1', () => {
28 expect(() => parseQueryString('?page=0')).toThrow();
29});
30
31test('clamps limit to maximum of 100', () => {
32 expect(parseQueryString('?limit=500').limit).toBe(100);
33});
34
35test('parses level as-is when present', () => {
36 expect(parseQueryString('?level=ADVANCED').level).toBe('ADVANCED');
37});
38
39test('strips leading ? from query string', () => {
40 expect(() => parseQueryString('?page=1')).not.toThrow();
41});
42
43// Implementation (emerged from 7 cycles):
44interface QueryParams {
45 page: number;
46 limit: number;
47 level?: string;
48}
49
50export function parseQueryString(qs: string): QueryParams {
51 const params = new URLSearchParams(qs.replace(/^\?/, ''));
52
53 const pageStr = params.get('page') ?? '1';
54 const page = parseInt(pageStr, 10);
55 if (isNaN(page) || page < 1) throw new Error('page must be a positive integer');
56
57 const limitStr = params.get('limit') ?? '10';
58 const limit = Math.min(parseInt(limitStr, 10) || 10, 100);
59
60 const level = params.get('level') ?? undefined;
61
62 return { page, limit, level };
63}Aggregate calculations (sums, averages, percentiles, statistics) have well-defined inputs and outputs, making them ideal for TDD.
1// TDD session: calculateCourseStats
2
3test('returns zero averageRating for empty reviews', () => {
4 expect(calculateCourseStats([])).toMatchObject({ averageRating: 0, reviewCount: 0 });
5});
6
7test('calculates correct average for a single review', () => {
8 expect(calculateCourseStats([{ rating: 4, helpful: true }]).averageRating).toBe(4);
9});
10
11test('calculates average rating from multiple reviews', () => {
12 const reviews = [{ rating: 5 }, { rating: 3 }, { rating: 4 }];
13 // (5 + 3 + 4) / 3 = 4.0
14 expect(calculateCourseStats(reviews).averageRating).toBe(4);
15});
16
17test('rounds average to 1 decimal place', () => {
18 const reviews = [{ rating: 5 }, { rating: 4 }];
19 // (5 + 4) / 2 = 4.5
20 expect(calculateCourseStats(reviews).averageRating).toBe(4.5);
21});
22
23test('counts helpful reviews separately', () => {
24 const reviews = [
25 { rating: 5, helpful: true },
26 { rating: 4, helpful: false },
27 { rating: 3, helpful: true },
28 ];
29 expect(calculateCourseStats(reviews).helpfulCount).toBe(2);
30});
31
32test('calculates rating distribution', () => {
33 const reviews = [{ rating: 5 }, { rating: 5 }, { rating: 4 }, { rating: 3 }];
34 const stats = calculateCourseStats(reviews);
35 expect(stats.distribution[5]).toBe(2);
36 expect(stats.distribution[4]).toBe(1);
37 expect(stats.distribution[3]).toBe(1);
38 expect(stats.distribution[1]).toBe(0);
39 expect(stats.distribution[2]).toBe(0);
40});
41
42// Implementation emerged from 6 cycles:
43export function calculateCourseStats(reviews: Array<{ rating: number; helpful?: boolean }>) {
44 if (reviews.length === 0) {
45 return { averageRating: 0, reviewCount: 0, helpfulCount: 0,
46 distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } };
47 }
48
49 const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
50 const averageRating = Math.round((sum / reviews.length) * 10) / 10;
51 const helpfulCount = reviews.filter(r => r.helpful === true).length;
52 const distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } as Record<number, number>;
53 for (const r of reviews) { distribution[r.rating] = (distribution[r.rating] ?? 0) + 1; }
54
55 return { averageRating, reviewCount: reviews.length, helpfulCount, distribution };
56}TDD consistently produces simpler implementations because each test constrains the implementation precisely — no over-engineering, no premature abstraction.
1// WITHOUT TDD: developer pre-designs a complex system
2class PasswordStrengthChecker {
3 private rules: Rule[];
4 private scorer: Scorer;
5 private categoriser: Categoriser;
6 private validator: Validator;
7
8 constructor(config: PasswordConfig) {
9 this.rules = RuleFactory.create(config.rules);
10 this.scorer = new WeightedScorer(config.weights);
11 this.categoriser = new ThresholdCategoriser(config.thresholds);
12 this.validator = new CompositeValidator(this.rules);
13 }
14
15 check(password: string): PasswordStrengthResult { ... }
16}
17// 200 lines of architecture for a feature that needs 20 lines
18
19// WITH TDD: implementation grows only as fast as the tests require
20// Cycle 1: test('returns weak for short password')
21// Implementation: return { strength: 'weak' }
22
23// Cycle 2: test('returns medium for 8+ chars with number')
24// Implementation: simple if/else conditions
25
26// Cycle 3: test('returns strong for 12+ chars with mixed case, number, symbol')
27// Implementation: count criteria, return based on count
28
29// Final implementation after TDD:
30export function checkPasswordStrength(password: string): { strength: 'weak' | 'medium' | 'strong'; score: number } {
31 let score = 0;
32 if (password.length >= 8) score++;
33 if (password.length >= 12) score++;
34 if (/[A-Z]/.test(password)) score++;
35 if (/[a-z]/.test(password)) score++;
36 if (/[0-9]/.test(password)) score++;
37 if (/[!@#$%^&*]/.test(password)) score++;
38
39 const strength = score >= 5 ? 'strong' : score >= 3 ? 'medium' : 'weak';
40 return { strength, score };
41}
42// 10 lines. All tests pass. No Factory, no Scorer, no Config class.
43// TDD prevented 190 lines of speculative architecture.A complete TDD session building a CSV line parser with 12 test cycles covering happy path, edge cases, error handling, and quoted fields.
1// TDD Session: parseCSVLine
2// Converts 'Alice,30,"New York, USA",active' to ['Alice', '30', 'New York, USA', 'active']
3
4// Test list (written before session):
5// [ ] empty string → []
6// [ ] single value → ['value']
7// [ ] comma-separated values → array
8// [ ] trims whitespace from values
9// [ ] handles quoted field with comma inside
10// [ ] handles quoted field with escaped quote ("")
11// [ ] handles empty field between commas → ''
12// [ ] handles trailing comma → last element is ''
13
14import { parseCSVLine } from './csv-parser';
15
16test('returns empty array for empty string', () => {
17 expect(parseCSVLine('')).toEqual([]);
18});
19
20test('returns single-element array for string with no commas', () => {
21 expect(parseCSVLine('Alice')).toEqual(['Alice']);
22});
23
24test('splits comma-separated values into array', () => {
25 expect(parseCSVLine('Alice,30,active')).toEqual(['Alice', '30', 'active']);
26});
27
28test('trims whitespace from each value', () => {
29 expect(parseCSVLine(' Alice , 30 , active ')).toEqual(['Alice', '30', 'active']);
30});
31
32test('treats quoted field as a single value even if it contains commas', () => {
33 expect(parseCSVLine('Alice,"New York, USA",active')).toEqual(['Alice', 'New York, USA', 'active']);
34});
35
36test('handles escaped double quote inside quoted field', () => {
37 expect(parseCSVLine('Alice,"He said ""hello""",active')).toEqual(['Alice', 'He said "hello"', 'active']);
38});
39
40test('returns empty string for consecutive commas', () => {
41 expect(parseCSVLine('Alice,,active')).toEqual(['Alice', '', 'active']);
42});
43
44test('returns empty string as last element for trailing comma', () => {
45 expect(parseCSVLine('Alice,30,')).toEqual(['Alice', '30', '']);
46});
47
48// FINAL IMPLEMENTATION (emerged from 8 TDD cycles):
49export function parseCSVLine(line: string): string[] {
50 if (line === '') return [];
51
52 const result: string[] = [];
53 let current = '';
54 let inQuotes = false;
55 let i = 0;
56
57 while (i < line.length) {
58 const ch = line[i];
59
60 if (inQuotes) {
61 if (ch === '"' && line[i + 1] === '"') {
62 current += '"'; // escaped quote
63 i += 2;
64 } else if (ch === '"') {
65 inQuotes = false;
66 i++;
67 } else {
68 current += ch;
69 i++;
70 }
71 } else {
72 if (ch === '"') {
73 inQuotes = true;
74 i++;
75 } else if (ch === ',') {
76 result.push(current.trim());
77 current = '';
78 i++;
79 } else {
80 current += ch;
81 i++;
82 }
83 }
84 }
85
86 result.push(current.trim());
87 return result;
88}
89// All 8 tests pass. No external library needed. 30-line implementation.
90// TDD discovered the quoted-field and escaped-quote requirements
91// that a code-first approach would likely have missed.The test list written before the session identified 8 distinct scenarios. Each was implemented in turn: the first three cycles produced a simple split implementation; cycle 4 (trim) added one line; cycle 5 (quoted fields) required a complete rewrite to a character-by-character parser; cycles 6-8 extended it for edge cases. The character-by-character parser was not pre-designed — it emerged because the quoted-field test made split() insufficient. TDD drove the design to exactly the right level of complexity.
The next lesson covers TDD for React components using React Testing Library: writing component tests before the component exists, driving props and state design through failing tests.
Your progress is saved in this browser, on this device only.