Learn how to choose the right first test for any TDD session: the simplest degenerate case, triangulation to drive generalisation, and the test list technique for planning a session.
The hardest part of TDD for beginners is often not the Red-Green-Refactor cycle itself — it is knowing what to test first. Start too complex and you are stuck in the Green phase for 20 minutes. Start with a trivial empty-state test and you build momentum immediately.
This lesson teaches the concrete techniques for starting TDD sessions confidently: how to choose the simplest possible first test, how to use triangulation to drive the implementation toward correctness, and how to create a test plan before writing a single line.
The first test should verify the simplest possible scenario — often an empty input, a null check, or a boundary case where the answer is trivially obvious.
1// Building a Roman numeral converter with TDD
2// WRONG first test — too ambitious:
3test('converts 2024 to MMXXIV', () => {
4 expect(toRoman(2024)).toBe('MMXXIV');
5});
6// This requires implementing I, V, X, L, C, D, M all at once
7
8// CORRECT first test — simplest degenerate case:
9test('converts 1 to I', () => {
10 expect(toRoman(1)).toBe('I');
11});
12// Implementation: return 'I';
13// Done in 30 seconds, cycle complete
14
15// Second test — next simplest:
16test('converts 2 to II', () => {
17 expect(toRoman(2)).toBe('II');
18});
19// Now 'return I' fails — we must think about repetition
20
21// More examples of simplest degenerate cases:
22
23// For a sorting algorithm:
24test('returns empty array for empty input', () => {
25 expect(sortNumbers([])).toEqual([]);
26});
27
28// For a string parser:
29test('returns empty list for empty string', () => {
30 expect(parseCSV('')).toEqual([]);
31});
32
33// For a tree traversal:
34test('returns empty list for null root', () => {
35 expect(traverse(null)).toEqual([]);
36});
37
38// For a calculator:
39test('returns 0 for addition of two zeros', () => {
40 expect(add(0, 0)).toBe(0);
41});
42
43// The degenerate case is usually:
44// - Empty input (empty string, empty array, null)
45// - Boundary value (0, 1, -1)
46// - The simplest non-trivial input (1 item, single character)
47// It should take < 30 seconds to implementTriangulation means writing two or three tests with different inputs that force the implementation to be genuinely general rather than hardcoded.
1// Without triangulation, the first two tests can pass with hardcoded values:
2test('converts 1 to I', () => { expect(toRoman(1)).toBe('I'); });
3// Green: return 'I';
4
5// With only one test, we cannot tell if the implementation is general.
6// Triangulation: add a test with a DIFFERENT input
7test('converts 2 to II', () => { expect(toRoman(2)).toBe('II'); });
8// FAIL — 'I' !== 'II'
9// Now we MUST write a general implementation
10
11export function toRoman(n: number): string {
12 return 'I'.repeat(n); // general! passes both tests
13}
14
15// Third triangle point:
16test('converts 4 to IV', () => { expect(toRoman(4)).toBe('IV'); });
17// FAIL — 'IIII' !== 'IV'
18// Forces us to handle subtractive notation
19
20export function toRoman(n: number): string {
21 const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
22 const symbols = ['M','CM','D','CD','C','XC','L','XL','X','IX','V','IV','I'];
23 let result = '';
24 for (let i = 0; i < values.length; i++) {
25 while (n >= values[i]) {
26 result += symbols[i];
27 n -= values[i];
28 }
29 }
30 return result;
31}
32// All three tests pass ✅
33
34// TRIANGULATION PATTERN:
35// Test 1: simplest case (may allow hardcoded solution)
36// Test 2: different specific case (forces first generalisation)
37// Test 3: edge case or boundary (forces complete generalisation)
38// After 3 triangulation points, the implementation is usually correctBefore starting a TDD session, write a list of all the test scenarios you can think of — as comments or notes, not as actual tests. Then work through the list from simplest to most complex.
1// Test list for a credit card validator:
2// (Written before any code, as a planning document)
3
4// BASIC STRUCTURE:
5// [ ] empty string → invalid
6// [ ] null/undefined → invalid
7// [ ] string with non-digits → invalid
8// [ ] 15-digit number → valid (Amex)
9// [ ] 16-digit number → valid (Visa/Mastercard)
10// [ ] 17-digit number → invalid
11
12// LUHN CHECK:
13// [ ] known valid Visa test card (4242424242424242) → valid
14// [ ] same number with last digit changed → invalid
15// [ ] known valid Amex test card → valid
16
17// CARD TYPE DETECTION:
18// [ ] starts with 4 → Visa
19// [ ] starts with 51-55 → Mastercard
20// [ ] starts with 34 or 37 → Amex
21// [ ] starts with other → Unknown
22
23// Start with the FIRST item and work down:
24
25test('returns invalid for empty string', () => {
26 expect(validateCard('').valid).toBe(false);
27});
28// Green: return { valid: false }
29
30test('returns invalid for non-digit characters', () => {
31 expect(validateCard('4111-1111-1111-1111').valid).toBe(false);
32});
33// Green: check for non-digits
34
35// Continue down the list...
36// Mark items as done as you complete them
37// Add new items to the list as you discover edge casesTests within a describe block should be ordered from simplest (degenerate) to most complex (full feature). This mirrors the implementation progression.
1// WRONG order — jumps to complex before establishing simple:
2describe('parseDateRange', () => {
3 it('parses a complex range with timezones', () => {
4 // Requires: date parsing, timezone handling, range validation
5 expect(parseDateRange('2024-01-01T00:00:00Z/2024-12-31T23:59:59Z'))
6 .toEqual({ start: new Date('2024-01-01'), end: new Date('2024-12-31') });
7 });
8 // First test requires complete implementation → stuck in Green for 20 minutes
9});
10
11// CORRECT order — builds from simple to complex:
12describe('parseDateRange', () => {
13 it('throws for null input', () => { // cycle 1: 30 seconds
14 expect(() => parseDateRange(null)).toThrow();
15 });
16
17 it('throws for invalid format', () => { // cycle 2: 1 minute
18 expect(() => parseDateRange('not-a-date')).toThrow('Invalid date range');
19 });
20
21 it('parses a simple date-only range', () => { // cycle 3: 2 minutes
22 const result = parseDateRange('2024-01-01/2024-12-31');
23 expect(result.start).toEqual(new Date('2024-01-01'));
24 expect(result.end).toEqual(new Date('2024-12-31'));
25 });
26
27 it('throws when end is before start', () => { // cycle 4: 1 minute
28 expect(() => parseDateRange('2024-12-31/2024-01-01'))
29 .toThrow('End date must be after start date');
30 });
31
32 it('parses ISO 8601 datetime range with timezone', () => { // cycle 5: 3 minutes
33 const result = parseDateRange('2024-01-01T00:00:00Z/2024-12-31T23:59:59Z');
34 expect(result.start).toEqual(new Date('2024-01-01T00:00:00Z'));
35 });
36});
37// Each test adds one concept; the implementation grows incrementallyA test that is too large tries to verify multiple behaviours at once, making the Green phase too long and the Red failure message too vague.
1// TOO LARGE — fails for too many reasons at once:
2test('creates a user, hashes password, saves to DB, sends email', async () => {
3 const result = await registerUser({
4 name: 'Alice',
5 email: '[email protected]',
6 password: 'SecurePass1!',
7 });
8 expect(result.id).toBeDefined();
9 expect(result.passwordHash).not.toBe('SecurePass1!');
10 expect(result.emailVerified).toBe(false);
11 const dbUser = await db.findByEmail('[email protected]');
12 expect(dbUser).toBeDefined();
13 const emails = await emailSpy.getCalls();
14 expect(emails).toHaveLength(1);
15 expect(emails[0].to).toBe('[email protected]');
16});
17// When this fails, you do not know which of the 6 assertions is the problem
18
19// SPLIT into 4 focused tests:
20test('returns a user with a generated id', async () => {
21 const result = await registerUser({ name: 'Alice', email: '[email protected]', password: 'Pass1!' });
22 expect(result.id).toBeDefined();
23});
24
25test('hashes the password — never stores plaintext', async () => {
26 const result = await registerUser({ name: 'Alice', email: '[email protected]', password: 'Pass1!' });
27 expect(result.passwordHash).not.toBe('Pass1!');
28 expect(result.passwordHash.startsWith('$2b$')).toBe(true); // bcrypt prefix
29});
30
31test('marks email as unverified initially', async () => {
32 const result = await registerUser({ name: 'Alice', email: '[email protected]', password: 'Pass1!' });
33 expect(result.emailVerified).toBe(false);
34});
35
36test('sends a welcome email to the registered address', async () => {
37 const emailSpy = jest.fn();
38 await registerUser({ name: 'Alice', email: '[email protected]', password: 'Pass1!' }, { sendEmail: emailSpy });
39 expect(emailSpy).toHaveBeenCalledWith(expect.objectContaining({ to: '[email protected]' }));
40});
41// Each test has one reason to fail — pinpoints exactly what brokeA complete TDD session from test list creation through 8 cycles to a fully working Roman numeral converter, demonstrating triangulation and test ordering.
1// STEP 1: Write the test list (before any code)
2// Roman numeral converter: toRoman(n: number): string
3//
4// Test list:
5// [ ] 1 → 'I'
6// [ ] 2 → 'II'
7// [ ] 3 → 'III'
8// [ ] 4 → 'IV' (subtractive notation)
9// [ ] 5 → 'V'
10// [ ] 9 → 'IX'
11// [ ] 10 → 'X'
12// [ ] 40 → 'XL'
13// [ ] 50 → 'L'
14// [ ] 90 → 'XC'
15// [ ] 100 → 'C'
16// [ ] 400 → 'CD'
17// [ ] 500 → 'D'
18// [ ] 900 → 'CM'
19// [ ] 1000 → 'M'
20// [ ] 2024 → 'MMXXIV'
21// [ ] 0 → throws (no Roman numeral for 0)
22// [ ] negative → throws
23
24// STEP 2: Work through the list
25import { toRoman } from './roman-numerals';
26
27test('converts 1 to I', () => {
28 expect(toRoman(1)).toBe('I');
29});
30// Green: export function toRoman(n: number) { return 'I'; }
31
32test('converts 2 to II', () => {
33 expect(toRoman(2)).toBe('II');
34});
35// Green: return 'I'.repeat(n);
36
37test('converts 4 to IV (subtractive notation)', () => {
38 expect(toRoman(4)).toBe('IV');
39});
40// FAIL — 'IIII' !== 'IV'
41// Green: introduce value/symbol table approach
42
43test('converts 5 to V', () => {
44 expect(toRoman(5)).toBe('V');
45});
46
47test('converts 9 to IX', () => {
48 expect(toRoman(9)).toBe('IX');
49});
50
51test('converts 40 to XL', () => {
52 expect(toRoman(40)).toBe('XL');
53});
54
55test('converts 1994 to MCMXCIV', () => {
56 expect(toRoman(1994)).toBe('MCMXCIV');
57});
58// This test proves the full algorithm works
59
60test('converts 2024 to MMXXIV', () => {
61 expect(toRoman(2024)).toBe('MMXXIV');
62});
63
64test('throws RangeError for 0', () => {
65 expect(() => toRoman(0)).toThrow(RangeError);
66});
67
68test('throws RangeError for negative numbers', () => {
69 expect(() => toRoman(-1)).toThrow(RangeError);
70});
71
72// FINAL IMPLEMENTATION (emerged from cycles):
73export function toRoman(n: number): string {
74 if (n <= 0) throw new RangeError('toRoman requires a positive integer');
75
76 const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
77 const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
78
79 let result = '';
80 for (let i = 0; i < values.length; i++) {
81 while (n >= values[i]) {
82 result += symbols[i];
83 n -= values[i];
84 }
85 }
86 return result;
87}
88// All 10 tests pass. Session duration: ~20 minutes.The test list provided the roadmap — we knew what scenarios to cover before writing any test code. Tests were ordered from simplest (1→I) to complex (1994→MCMXCIV). Triangulation was used after cycle 2 (1→I worked with repeat, but 4→IV broke it), forcing the value/symbol table design. The RangeError tests came last because they are edge cases independent of the core algorithm. The 20-minute session produced a fully specified, fully covered implementation with zero speculation.
The next lesson covers implementing minimal code to pass: the discipline of not writing code the tests do not yet require, and how fake-it-till-you-make-it leads to better designs.
Your progress is saved in this browser, on this device only.