JavaScript and TypeScript course Β· Module 11: Testing with Jest
Test-Driven Development (TDD)
In this lesson4
Usually you write the code first and then, if there is time left, the tests. Except there rarely is, and tests written after the fact tend to confirm what the code already does, bugs included. TDD reverses that order.
TDD is a software development methodology where you write tests first and then the production code. In Jurassic Park, it is like first designing all the security systems and only then bringing in the dinosaurs. The method was popularized by Kent Beck, author of the book Test-Driven Development: By Example.
The Red-Green-Refactor Cycle
TDD is based on three repeating steps. The colors come from the test report: red means an alarm, and green means everything works.
1. RED - Write a Test That Fails
We start with a test for a function that does not exist yet. In it, we describe how we want to use it: createFence should take a zone and a voltage and return an active fence:
1// STEP 1: We write the test BEFORE the production code
2describe('FenceSystem', () => {
3 it('should create a fence with voltage', () => {
4 const fence = createFence('Zone A', 10000);
5
6 expect(fence.zone).toBe('Zone A');
7 expect(fence.voltage).toBe(10000);
8 expect(fence.isActive).toBe(true);
9 });
10});
11// Result: RED - createFence does not exist!The test fails with a ReferenceError, because createFence does not exist, and that is fine: a failure caused by a missing function also counts as the red state. It is important to see the red with your own eyes. A test that has never been red may not be checking anything.
2. GREEN - Write the Minimum Code to Pass the Test
Now we write the simplest code that satisfies the test, with no reserve for the future and no features added "just in case":
1// STEP 2: We write the simplest code that passes the test
2function createFence(zone, voltage) {
3 return {
4 zone,
5 voltage,
6 isActive: true
7 };
8}
9// Result: GREEN - test passes!The implementation is downright naive, but the test passes. At this stage elegance does not count, only the green light.
3. REFACTOR - Improve Code While Keeping Tests Green
With a green test as a safety net, we can restructure the code. We replace the object literal with a Fence class, and createFence becomes a thin factory:
1// STEP 3: We improve the code (tests still green!)
2class Fence {
3 constructor(zone, voltage) {
4 this.zone = zone;
5 this.voltage = voltage;
6 this.isActive = true;
7 }
8
9 getStatus() {
10 return this.isActive ? 'ACTIVE' : 'OFFLINE';
11 }
12}
13
14function createFence(zone, voltage) {
15 return new Fence(zone, voltage);
16}
17// Result: GREEN - tests still pass after refactoring!The test has not changed by a single character and still passes, because refactoring changes the structure of the code, not its behavior. I will honestly admit, though, that the getStatus method slipped in here without a test. In strict TDD, refactoring adds no new features: getStatus should first get its own red test.
Practical TDD Example
Let's build a dinosaur feeding system step by step. Each iteration adds one test and only as much code as that test forces.
Iteration 1: Basic Feeding
The first test describes the simplest case: a T-Rex gets meat and the feeding succeeds. DinoFeeder is a class whose feed method takes a species and a food:
1// RED: Write the test
2test('carnivore eats meat', () => {
3 const feeder = new DinoFeeder();
4 const result = feeder.feed('T-Rex', 'meat');
5 expect(result.success).toBe(true);
6});Now the simplest implementation that turns red into green. For now, the feed method does not even look at its arguments:
1// GREEN: Minimal implementation
2class DinoFeeder {
3 feed(species, food) {
4 return { success: true };
5 }
6}
7// Test passes - but the implementation is too simple!Always returning success: true looks like cheating, but it is a deliberate minimum. The next tests will force the implementation to get smarter, and that is exactly the point of TDD.
Iteration 2: Diet Validation
The second test closes a gap: a carnivore cannot accept plants, and the result has to give the reason for the refusal:
1// RED: Add test for incorrect food
2test('carnivore rejects plants', () => {
3 const feeder = new DinoFeeder();
4 const result = feeder.feed('T-Rex', 'plants');
5 expect(result.success).toBe(false);
6 expect(result.reason).toBe('Wrong diet');
7});To satisfy it, the class needs a map of diets for species. The constructor stores it in this.diets, and feed checks whether the food matches the diet:
1// GREEN: Extend the implementation
2class DinoFeeder {
3 constructor() {
4 this.diets = {
5 'T-Rex': 'carnivore',
6 'Triceratops': 'herbivore',
7 'Velociraptor': 'carnivore',
8 };
9 }
10
11 feed(species, food) {
12 const diet = this.diets[species];
13
14 if (diet === 'carnivore' && food !== 'meat') {
15 return { success: false, reason: 'Wrong diet' };
16 }
17 if (diet === 'herbivore' && food !== 'plants') {
18 return { success: false, reason: 'Wrong diet' };
19 }
20
21 return { success: true };
22 }
23}Both tests are green now. The first one still protects the first scenario: if the new validation broke feeding with meat, we would find out immediately.
Iteration 3: Unknown Species
The third test checks an exception, so we wrap the call in an arrow function, just as with the toThrow matcher:
1// RED: Test for unknown species
2test('unknown species throws error', () => {
3 const feeder = new DinoFeeder();
4 expect(() => feeder.feed('Unknown', 'meat')).toThrow('Unknown species');
5});The implementation adds one condition at the start of feed. The "previous code" comment stands for the constructor with the diet map from iteration 2, which does not change:
1// GREEN: Add error handling
2class DinoFeeder {
3 // ... previous code ...
4
5 feed(species, food) {
6 const diet = this.diets[species];
7
8 if (!diet) {
9 throw new Error('Unknown species');
10 }
11
12 if (diet === 'carnivore' && food !== 'meat') {
13 return { success: false, reason: 'Wrong diet' };
14 }
15 if (diet === 'herbivore' && food !== 'plants') {
16 return { success: false, reason: 'Wrong diet' };
17 }
18
19 return { success: true };
20 }
21}After three iterations, the two twin conditions for carnivore and herbivore are begging for a refactor, for example a "diet - allowed food" map. Three green tests give you the courage to carry it out.
TDD Principles
The cycle rests on a few principles. The first three are a short version of the so-called three laws of TDD formulated by Robert C. Martin:
- Don't write production code without a red test - always write the test first
- Write the minimum test - just enough to show the failure
- Write the minimum code - just enough to pass the test
- Refactor often - improve code while tests are green
- Small steps - each iteration adds one small piece of functionality
Benefits of TDD
What do you gain by working in this rhythm? We have gathered it in an object, as befits a park laboratory:
1// TDD leads to:
2const tddBenefits = {
3 betterDesign: 'Code is designed for tests = better design',
4 lessDebugging: 'Bugs caught immediately',
5 documentation: 'Tests document code behavior',
6 confidence: 'Confidence during changes and refactoring',
7 coverage: 'Very high code coverage (but no 100% guarantee)'
8};Note the last entry: TDD usually yields very high coverage, because every line is written in response to a test, but it does not guarantee 100%, and coverage alone says nothing about the quality of the tests. My advice: use TDD where you know what the result should be, that is, in business logic, validation and calculations. With quick prototypes, the strict cycle can slow you down.
In the exercises after this lesson you will use TDD to build a ticket system and a feeding schedule, and in the next lesson you will carry these habits over to TypeScript. In the lab below you will follow a simulation of successive iterations.
Remember: in TDD you build the fence first and only then let the dinosaur in - a red test is the blueprint, a green one is the finished enclosure.
Code for this lesson: index.js
1// TDD - Test-Driven Development
2console.log("=== Jurassic Park - TDD Security Protocols ===\n");
3
4// TDD simulation
5let testsPassed = 0, testsFailed = 0;
6
7function assert(condition, msg) {
8 if (condition) {
9 console.log(" [PASS] " + msg);
10 testsPassed++;
11 } else {
12 console.log(" [FAIL] " + msg);
13 testsFailed++;
14 }
15}
16
17function assertThrows(fn, expectedMsg) {
18 try {
19 fn();
20 console.log(" [FAIL] Expected throw: " + expectedMsg);
21 testsFailed++;
22 } catch (e) {
23 if (e.message === expectedMsg) {
24 console.log(" [PASS] Threw: " + e.message);
25 testsPassed++;
26 } else {
27 console.log(" [FAIL] Wrong error: " + e.message + " (expected: " + expectedMsg + ")");
28 testsFailed++;
29 }
30 }
31}
32
33// --- The Red-Green-Refactor cycle ---
34console.log("--- The TDD cycle: Red -> Green -> Refactor ---\n");
35console.log("1. RED - Write a test that FAILS");
36console.log("2. GREEN - Write the minimum code to make it PASS");
37console.log("3. REFACTOR - Improve the code (tests stay green)\n");
38
39// --- Iteration 1: Creating a fence ---
40console.log("=== Iteration 1: Creating a fence ===\n");
41
42// RED -> GREEN
43function createFence(zone, voltage) {
44 return {
45 zone,
46 voltage,
47 isActive: true,
48 getStatus() { return this.isActive ? "ACTIVE" : "OFFLINE"; }
49 };
50}
51
52assert(createFence("Zone A", 10000).zone === "Zone A", "fence has zone");
53assert(createFence("Zone A", 10000).voltage === 10000, "fence has voltage");
54assert(createFence("Zone A", 10000).isActive === true, "fence is active");
55
56// --- Iteration 2: DinoFeeder ---
57console.log("\n=== Iteration 2: Feeding System (TDD) ===\n");
58
59class DinoFeeder {
60 constructor() {
61 this.diets = {
62 "T-Rex": "carnivore",
63 "Triceratops": "herbivore",
64 "Velociraptor": "carnivore",
65 "Gallimimus": "omnivore",
66 };
67 this.feedLog = [];
68 }
69
70 feed(species, food) {
71 const diet = this.diets[species];
72
73 if (!diet) {
74 throw new Error("Unknown species");
75 }
76
77 if (diet === "carnivore" && food !== "meat") {
78 return { success: false, reason: "Wrong diet" };
79 }
80 if (diet === "herbivore" && food !== "plants") {
81 return { success: false, reason: "Wrong diet" };
82 }
83
84 this.feedLog.push({ species, food, time: Date.now() });
85 return { success: true };
86 }
87
88 getFeedCount() {
89 return this.feedLog.length;
90 }
91}
92
93// Test 1: Feeding carnivores
94console.log("Test: Feeding carnivores");
95const feeder = new DinoFeeder();
96const result1 = feeder.feed("T-Rex", "meat");
97assert(result1.success === true, "T-Rex eats meat");
98
99// Test 2: Rejecting the wrong food
100console.log("\nTest: Rejecting the wrong food");
101const result2 = feeder.feed("T-Rex", "plants");
102assert(result2.success === false, "T-Rex rejects plants");
103assert(result2.reason === "Wrong diet", "reason is Wrong diet");
104
105// Test 3: Herbivores
106console.log("\nTest: Feeding herbivores");
107const result3 = feeder.feed("Triceratops", "plants");
108assert(result3.success === true, "Triceratops eats plants");
109
110// Test 4: Unknown species
111console.log("\nTest: Unknown species");
112assertThrows(() => feeder.feed("Unknown", "meat"), "Unknown species");
113
114// Test 5: Feeding counter
115console.log("\nTest: Feeding counter");
116assert(feeder.getFeedCount() === 2, "2 successful feedings logged");
117
118// --- TDD summary ---
119console.log("\n=== TDD Principles ===");
120console.log("1. Do NOT write code without a red test");
121console.log("2. Write the minimum test (one assert)");
122console.log("3. Write the minimum code (to make it pass)");
123console.log("4. Refactor often");
124console.log("5. Small steps - one feature per iteration");
125
126console.log(`\n=== Results: ${testsPassed} passed, ${testsFailed} failed ===`);Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the correct order of steps in the TDD cycle?
Hands-on tasks in the game
- Vertical ordering
Arrange the steps of one TDD iteration in the correct order:
- Code editor
Write tests for the park's ticketing system, then implement the code to pass the tests.
- Click in order
Arrange the elements of one TDD iteration:
- Horizontal ordering
Arrange the elements of a test checking for an exception:
- Code editor
Build a FeedingScheduler iteratively: test -> code -> refactor.