JavaScript and TypeScript course Β· Module 11: Testing with Jest

Project: Complete Test Suite for the Jurassic Park System

6 min read
In this lesson2

The park is running and the dinosaurs are fed, but nobody knows whether tomorrow's fix to the feeding system will switch off the fence in the raptor zone. That is why we write a test suite: so that every change passes through the same sensors before it reaches the park.

Time for the final challenge! You will build a complete test suite for the Jurassic Park management system. This project combines all concepts from the module: Jest, matchers, setup/teardown, mocking, asynchronous testing, TDD, and TypeScript.

System Under Test

Below you will find the complete park management system. Your task is to write tests for each module. The code is long, but you do not need to memorize it. Read it like a specification in which every if statement and every throw is a candidate for a separate test.

Module 1: Dinosaur Management

DinosaurManager stores specimens in a Map, a collection of key-value pairs, and assigns them the identifiers DINO-001, DINO-002 and so on. Note the exception when a name or species is missing, and the omnivores, which accept two kinds of food:

1// dinosaurManager.js
2class DinosaurManager {
3  constructor() {
4    this.dinosaurs = new Map();
5    this.nextId = 1;
6  }
7
8  addDinosaur(name, species, diet, zone) {
9    if (!name || !species) {
10      throw new Error('Name and species are required');
11    }
12
13    const id = `DINO-${String(this.nextId).padStart(3, '0')}`;
14    this.nextId++;
15
16    const dinosaur = {
17      id,
18      name,
19      species,
20      diet,
21      zone,
22      health: 100,
23      fed: false,
24      lastFed: null,
25    };
26
27    this.dinosaurs.set(id, dinosaur);
28    return dinosaur;
29  }
30
31  getDinosaur(id) {
32    return this.dinosaurs.get(id) || null;
33  }
34
35  feedDinosaur(id, food) {
36    const dino = this.getDinosaur(id);
37    if (!dino) throw new Error('Dinosaur not found');
38
39    const correctFood = {
40      carnivore: 'meat',
41      herbivore: 'plants',
42      omnivore: ['meat', 'plants'],
43    };
44
45    const expected = correctFood[dino.diet];
46    const isCorrect = Array.isArray(expected)
47      ? expected.includes(food)
48      : expected === food;
49
50    if (!isCorrect) {
51      return { success: false, message: 'Wrong food type' };
52    }
53
54    dino.health = Math.min(100, dino.health + 10);
55    dino.fed = true;
56    dino.lastFed = new Date();
57
58    return { success: true, health: dino.health };
59  }
60
61  getByZone(zone) {
62    return [...this.dinosaurs.values()].filter(d => d.zone === zone);
63  }
64
65  getByDiet(diet) {
66    return [...this.dinosaurs.values()].filter(d => d.diet === diet);
67  }
68
69  getTotalCount() {
70    return this.dinosaurs.size;
71  }
72}

Start with a beforeEach that creates a fresh manager, because otherwise the nextId counter carries over between tests and the second test gets DINO-002 instead of DINO-001. The lastFed field receives new Date(), a value you cannot predict. Check it with expect.any(Date), or freeze time with fake timers and jest.setSystemTime. For the wrong food, also make sure the health did not change.

Module 2: Security System

SecuritySystem receives alertService in its constructor. That is dependency injection, and the best news for a tester, because instead of jest.mock you only need to pass an object with a mock. After the third breach, the reportBreach method triggers a lockdown and doubles the fence voltage:

1// securitySystem.js
2class SecuritySystem {
3  constructor(alertService) {
4    this.alertService = alertService; // dependency to mock
5    this.zones = new Map();
6    this.logs = [];
7  }
8
9  registerZone(name, fenceVoltage, securityLevel) {
10    this.zones.set(name, {
11      name,
12      fenceVoltage,
13      securityLevel,
14      status: 'active',
15      breachCount: 0,
16    });
17  }
18
19  async reportBreach(zoneName) {
20    const zone = this.zones.get(zoneName);
21    if (!zone) throw new Error('Zone not found');
22
23    zone.breachCount++;
24    zone.status = 'breached';
25
26    this.logs.push({
27      type: 'BREACH',
28      zone: zoneName,
29      timestamp: Date.now(),
30    });
31
32    await this.alertService.sendAlert(zoneName, 'Security breach detected');
33
34    if (zone.breachCount >= 3) {
35      await this.activateLockdown(zoneName);
36    }
37
38    return { status: 'reported', breachCount: zone.breachCount };
39  }
40
41  async activateLockdown(zoneName) {
42    const zone = this.zones.get(zoneName);
43    zone.status = 'lockdown';
44    zone.fenceVoltage *= 2;
45
46    await this.alertService.sendAlert(
47      zoneName,
48      'LOCKDOWN ACTIVATED'
49    );
50
51    this.logs.push({
52      type: 'LOCKDOWN',
53      zone: zoneName,
54      timestamp: Date.now(),
55    });
56  }
57
58  getZoneStatus(zoneName) {
59    const zone = this.zones.get(zoneName);
60    return zone ? zone.status : null;
61  }
62}

This is what the start of your tests might look like. The sendAlert mock returns a fulfilled promise right away, and the zone gets a voltage of 10000 and security level 9:

1const alertService = {
2  sendAlert: jest.fn().mockResolvedValue({ status: 'sent' }),
3};
4const security = new SecuritySystem(alertService);
5security.registerZone('Raptor Pen', 10000, 9);

After three calls to await security.reportBreach('Raptor Pen'), the zone should have the lockdown status, and sendAlert should have been called four times: three breach alerts and one "LOCKDOWN ACTIVATED". An unknown zone means a rejected promise, so you will check it with rejects.toThrow('Zone not found').

Module 3: Monitoring System (Asynchronous)

MonitoringService combines a promise with a timer: checkSensor responds after one second, and startMonitoring passes readings to a callback every intervalMs milliseconds through setInterval:

1// monitoringService.js
2class MonitoringService {
3  constructor() {
4    this.sensors = [];
5    this.interval = null;
6  }
7
8  addSensor(zoneId, type) {
9    this.sensors.push({
10      zoneId,
11      type,
12      status: 'online',
13      lastReading: null,
14    });
15  }
16
17  async checkSensor(index) {
18    return new Promise((resolve, reject) => {
19      setTimeout(() => {
20        if (index < 0 || index >= this.sensors.length) {
21          reject(new Error('Sensor not found'));
22          return;
23        }
24
25        const sensor = this.sensors[index];
26        sensor.lastReading = Date.now();
27
28        resolve({
29          zoneId: sensor.zoneId,
30          type: sensor.type,
31          status: sensor.status,
32          reading: Math.random() * 100,
33        });
34      }, 1000);
35    });
36  }
37
38  startMonitoring(callback, intervalMs) {
39    this.interval = setInterval(() => {
40      const readings = this.sensors.map(s => ({
41        zone: s.zoneId,
42        status: s.status,
43      }));
44      callback(readings);
45    }, intervalMs);
46  }
47
48  stopMonitoring() {
49    if (this.interval) {
50      clearInterval(this.interval);
51      this.interval = null;
52    }
53  }
54}

This is where fake timers come in handy. Call checkSensor, advance time by 1000 ms, and only then wait for the promise with await. Without advancing time, the promise would never be fulfilled and the test would hang until the timeout. The reading field comes from Math.random(), so check it with expect.any(Number). For startMonitoring, advance time by a few intervals and count the callback calls with toHaveBeenCalledTimes, and after stopMonitoring make sure that advancing time again adds nothing.

Your Task

Write a complete test suite covering:

  1. DinosaurManager - unit tests with beforeEach, exception testing
  2. SecuritySystem - mocking alertService, async testing
  3. MonitoringService - fake timers, callback and Promise testing
  4. Integration tests - cooperation between modules

Remember the AAA pattern (Arrange-Act-Assert) and TDD principles! An integration test could, for example, add a raptor to a zone in DinosaurManager, register the same zone in SecuritySystem, and check that after a series of breaches the raptor is still on the list and the zone has the lockdown status.

My advice: start with one describe per module and one test for every if branch. Only when all paths are green, add edge cases, such as feeding a dinosaur that is not in the registry. Send the finished project to your mentor in the next task.

In the lab below you will find simplified DinosaurManager tests and TODO comments for the other two modules. Treat them as a warm-up before the real suite in Jest.

Remember: a complete test suite is a sensor network for the whole park - only when every enclosure has its sensor can you sleep soundly.

Code for this lesson: index.js
1// Project: Complete test suite - Jurassic Park
2console.log("=== Jurassic Park - Complete Test System ===\n");
3
4let passed = 0, failed = 0;
5function assert(cond, msg) {
6  if (cond) { passed++; console.log("    [PASS] " + msg); }
7  else { failed++; console.log("    [FAIL] " + msg); }
8}
9function assertThrows(fn, expected) {
10  try { fn(); failed++; console.log("    [FAIL] Expected throw"); }
11  catch (e) {
12    if (expected && !e.message.includes(expected)) {
13      failed++; console.log("    [FAIL] Wrong error: " + e.message);
14    } else { passed++; console.log("    [PASS] Threw: " + e.message); }
15  }
16}
17
18// =============================================
19// PRODUCTION CODE
20// =============================================
21
22class DinosaurManager {
23  constructor() { this.dinosaurs = new Map(); this.nextId = 1; }
24
25  addDinosaur(name, species, diet, zone) {
26    if (!name || !species) throw new Error("Name and species are required");
27    const id = "DINO-" + String(this.nextId).padStart(3, "0");
28    this.nextId++;
29    const dino = { id, name, species, diet, zone, health: 100, fed: false, lastFed: null };
30    this.dinosaurs.set(id, dino);
31    return dino;
32  }
33
34  getDinosaur(id) { return this.dinosaurs.get(id) || null; }
35
36  feedDinosaur(id, food) {
37    const dino = this.getDinosaur(id);
38    if (!dino) throw new Error("Dinosaur not found");
39    const correct = { carnivore: "meat", herbivore: "plants", omnivore: ["meat", "plants"] };
40    const expected = correct[dino.diet];
41    const ok = Array.isArray(expected) ? expected.includes(food) : expected === food;
42    if (!ok) return { success: false, message: "Wrong food type" };
43    dino.health = Math.min(100, dino.health + 10);
44    dino.fed = true;
45    dino.lastFed = new Date();
46    return { success: true, health: dino.health };
47  }
48
49  getByZone(zone) { return [...this.dinosaurs.values()].filter(d => d.zone === zone); }
50  getByDiet(diet) { return [...this.dinosaurs.values()].filter(d => d.diet === diet); }
51  getTotalCount() { return this.dinosaurs.size; }
52}
53
54// TODO: Write tests for SecuritySystem and MonitoringService
55// Use the AAA pattern, mocking and fake timers
56
57// =============================================
58// TESTS
59// =============================================
60
61console.log("Suite: DinosaurManager\n");
62
63// beforeEach equivalent
64function setup() { return new DinosaurManager(); }
65
66console.log("  describe: addDinosaur");
67let mgr = setup();
68let rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
69assert(rex.id === "DINO-001", "first dino has ID DINO-001");
70assert(rex.name === "Rex", "has correct name");
71assert(rex.health === 100, "starts with 100 health");
72assert(rex.fed === false, "starts not fed");
73
74mgr = setup();
75assertThrows(() => mgr.addDinosaur("", "T-Rex", "carnivore", "A"), "Name and species");
76assertThrows(() => mgr.addDinosaur("Rex", "", "carnivore", "A"), "Name and species");
77
78console.log("\n  describe: feedDinosaur");
79mgr = setup();
80rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
81let feedResult = mgr.feedDinosaur(rex.id, "meat");
82assert(feedResult.success === true, "carnivore eats meat");
83assert(rex.fed === true, "marked as fed");
84assert(rex.health === 100, "health capped at 100");
85
86mgr = setup();
87rex = mgr.addDinosaur("Rex", "T-Rex", "carnivore", "A");
88feedResult = mgr.feedDinosaur(rex.id, "plants");
89assert(feedResult.success === false, "carnivore rejects plants");
90assert(feedResult.message === "Wrong food type", "correct error message");
91
92mgr = setup();
93assertThrows(() => mgr.feedDinosaur("FAKE-ID", "meat"), "not found");
94
95console.log("\n  describe: queries");
96mgr = setup();
97mgr.addDinosaur("Rex", "T-Rex", "carnivore", "Zone A");
98mgr.addDinosaur("Blue", "Velociraptor", "carnivore", "Zone A");
99mgr.addDinosaur("Brachio", "Brachiosaurus", "herbivore", "Zone B");
100
101assert(mgr.getTotalCount() === 3, "total count is 3");
102assert(mgr.getByZone("Zone A").length === 2, "2 in Zone A");
103assert(mgr.getByZone("Zone B").length === 1, "1 in Zone B");
104assert(mgr.getByDiet("carnivore").length === 2, "2 carnivores");
105assert(mgr.getByDiet("herbivore").length === 1, "1 herbivore");
106assert(mgr.getDinosaur("FAKE") === null, "null for missing dino");
107
108// TODO: Add your own tests below!
109// - Test SecuritySystem with a mocked alertService
110// - Test MonitoringService with fake timers
111// - Write integration tests
112
113console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
114console.log("\nAdd more tests in the editor above!");

Spotted a mistake in this lesson?

Useful articles