JavaScript and TypeScript course Β· Module 11: Testing with Jest
Testing Asynchronous Code
In this lesson3
Scanning a DNA sample does not finish the moment the function is called. The result arrives later, as a promise (Promise), and a test that does not wait for it will finish before the result arrives and report success without having checked anything. That false sense of safety is more dangerous than a red alarm.
In Jurassic Park, many operations are asynchronous - DNA scanning, communicating with sensors, fetching data from satellites. Jest offers several ways to test such code.
Testing Promises
async/await
The most readable way to test asynchronous code takes two steps: mark the test function as async and use await when calling the function under test. We assume that fetch has been replaced with a mock here, as in the previous lesson, and returns a T-Rex sample with a purity of 0.95:
1// Asynchronous function to test
2async function scanDNA(sampleId) {
3 const response = await fetch(`/api/dna/${sampleId}`);
4 const data = await response.json();
5 return {
6 species: data.species,
7 purity: data.purity,
8 viable: data.purity > 0.9
9 };
10}
11
12// Test with async/await
13test('DNA scanning returns species', async () => {
14 const result = await scanDNA('SAMPLE-001');
15
16 expect(result.species).toBe('T-Rex');
17 expect(result.purity).toBeGreaterThan(0.9);
18 expect(result.viable).toBe(true);
19});Jest sees that the test function returns a promise and waits for it before judging the result. Without await, the result variable would be a promise rather than an object, so result.species would give undefined. The scanDNA function itself does not change - the only change is that the test patiently waits for its answer.
resolves and rejects
Jest has dedicated matchers for Promises. .resolves unwraps the value of a fulfilled promise and .rejects the reason for a rejection, after which you add an ordinary matcher. You put await in front of the whole expression (or return it with return). For the INVALID sample, our fetch mock rejects the promise with a "Sample not found" error:
1// Testing resolve
2test('DNA scan resolves with data', async () => {
3 await expect(scanDNA('SAMPLE-001')).resolves.toEqual({
4 species: 'T-Rex',
5 purity: 0.95,
6 viable: true
7 });
8});
9
10// Testing reject
11test('DNA scan rejects for invalid sample', async () => {
12 await expect(scanDNA('INVALID')).rejects.toThrow('Sample not found');
13});A missing await before expect is a classic mistake: the test ends before the matcher checks anything. The same rule applies to async functions that throw exceptions themselves - like a defense system that refuses to activate without a zone number:
1// Another example - testing a rejected Promise
2async function activateDefense(zoneId) {
3 if (!zoneId) {
4 throw new Error('Zone ID is required');
5 }
6 return { status: 'activated', zone: zoneId };
7}
8
9test('defense rejects without zone ID', async () => {
10 await expect(activateDefense(null)).rejects.toThrow('Zone ID is required');
11});
12
13test('defense resolves with valid zone', async () => {
14 await expect(activateDefense('A')).resolves.toEqual({
15 status: 'activated',
16 zone: 'A'
17 });
18});activateDefense is an async function, so a throw inside it does not explode immediately but turns into a rejected promise. That is why we test it with rejects.toThrow rather than the expect(() => ...).toThrow() you know from the matchers lesson. If you prefer a classic try/catch, add expect.assertions(1) at the start of the test. Then a promise that unexpectedly fulfills will not slip past the catch block without any assertion.
Testing Callbacks
Older code does not return promises. Instead, it accepts a callback, that is, a function called with the result. Following the Node.js convention, fetchDinoReport passes the error to the callback first and the data second. The test then receives a done parameter and has to call it when it finishes:
1function fetchDinoReport(id, callback) {
2 setTimeout(() => {
3 if (id === 'unknown') {
4 callback(new Error('Dinosaur not found'), null);
5 } else {
6 callback(null, { id, status: 'healthy' });
7 }
8 }, 100);
9}
10
11// Testing callback with done
12test('callback returns dinosaur report', (done) => {
13 fetchDinoReport('REX-001', (error, report) => {
14 try {
15 expect(error).toBeNull();
16 expect(report.status).toBe('healthy');
17 done(); // Signal test completion
18 } catch (err) {
19 done(err); // Pass error to Jest
20 }
21 });
22});If done is never called, the test fails once it exceeds the time limit, 5 seconds by default. The try/catch is needed because a failed expect throws an exception inside the callback, and when passed to done(err), it reaches the report as the real reason for the failure. Do not combine done with an async test, because Jest will then report an error. For new code I recommend async/await and leave done for old APIs.
Fake Timers
When code uses setTimeout, setInterval, or Date, you can control time. Waiting 30 seconds in a test is a waste of time, so jest.useFakeTimers() swaps the clocks for fake ones. Here is a function that announces an evacuation after 30 seconds:
1// Function with timeout
2function startEmergencyCountdown(callback) {
3 console.log('Evacuation in 30 seconds!');
4 setTimeout(() => {
5 callback('Evacuation started!');
6 }, 30000); // 30 seconds
7}And this is how we test it. jest.advanceTimersByTime fast-forwards time by the given number of milliseconds, instantly. We switch fake timers on in beforeEach and off in afterEach so they do not affect other tests:
1describe('Emergency System', () => {
2 beforeEach(() => {
3 jest.useFakeTimers(); // Enable fake timers
4 });
5
6 afterEach(() => {
7 jest.useRealTimers(); // Restore real timers
8 });
9
10 test('countdown calls callback after 30s', () => {
11 const callback = jest.fn();
12
13 startEmergencyCountdown(callback);
14
15 // Callback has not been called yet
16 expect(callback).not.toHaveBeenCalled();
17
18 // Fast-forward time by 30 seconds
19 jest.advanceTimersByTime(30000);
20
21 // Now the callback has been called!
22 expect(callback).toHaveBeenCalledWith('Evacuation started!');
23 });
24
25 test('countdown does not fire before time', () => {
26 const callback = jest.fn();
27
28 startEmergencyCountdown(callback);
29
30 // Fast-forward by 29 seconds
31 jest.advanceTimersByTime(29000);
32 expect(callback).not.toHaveBeenCalled();
33
34 // Fast-forward by one more second (30s total)
35 jest.advanceTimersByTime(1000);
36 expect(callback).toHaveBeenCalled();
37 });
38});The second test shows that fake time is precise: after 29 seconds the callback is still waiting, and after one more second it fires. Both tests take milliseconds, not half a minute. The production code does not know that time is fake - we did not change a single line of it.
Running Pending Timers
When you do not want to count milliseconds, jest.runAllTimers() runs all pending timers at once. Important: fake timers must be switched on before the code schedules any timers, which is why jest.useFakeTimers() sits at the very start of the test:
1test('runAllTimers - run all timers', () => {
2 jest.useFakeTimers();
3
4 const steps = [];
5
6 setTimeout(() => steps.push('step 1'), 1000);
7 setTimeout(() => steps.push('step 2'), 2000);
8 setTimeout(() => steps.push('step 3'), 5000);
9
10 // Run ALL pending timers at once
11 jest.runAllTimers();
12
13 expect(steps).toEqual(['step 1', 'step 2', 'step 3']);
14
15 jest.useRealTimers();
16});Timers scheduled before fake timers were switched on would land in the real queue, runAllTimers would not see them, and the steps array would stay empty. Be careful with setInterval: runAllTimers also runs timers created along the way, so an interval would spin forever until Jest breaks the loop with an error. In that case use jest.runOnlyPendingTimers(), and when timers mix with promises, reach for await jest.advanceTimersByTimeAsync(ms).
In the final project, fake timers will come in handy for sensor monitoring. In the lab below you will see a simulation of DNA scanning with promises and timers.
Remember: in an asynchronous test, always wait for the result - await is the guard who will not leave the post until the DNA scan is complete.
Code for this lesson: index.js
1// Testing asynchronous code
2console.log("=== Jurassic Park - Asynchronous DNA Scanning ===\n");
3
4// --- async/await in tests ---
5console.log("--- async/await ---\n");
6
7async function scanDNA(sampleId) {
8 // Simulating an asynchronous scan
9 return new Promise((resolve, reject) => {
10 setTimeout(() => {
11 if (sampleId === "INVALID") {
12 reject(new Error("Sample not found"));
13 return;
14 }
15 resolve({
16 species: "T-Rex",
17 purity: 0.95,
18 viable: true,
19 });
20 }, 100);
21 });
22}
23
24// Test async/await
25async function testAsyncAwait() {
26 try {
27 const result = await scanDNA("SAMPLE-001");
28 console.log("[PASS] DNA scan returned:", result.species);
29 console.log("[PASS] Purity:", result.purity, "> 0.9?", result.purity > 0.9);
30 console.log("[PASS] Viable:", result.viable);
31 } catch (e) {
32 console.log("[FAIL]", e.message);
33 }
34}
35
36// Test reject
37async function testReject() {
38 try {
39 await scanDNA("INVALID");
40 console.log("[FAIL] Should have thrown");
41 } catch (e) {
42 console.log("[PASS] Rejected:", e.message);
43 }
44}
45
46// --- Callback testing ---
47console.log("--- Callbacks ---\n");
48
49function fetchDinoReport(id, callback) {
50 setTimeout(() => {
51 if (id === "unknown") {
52 callback(new Error("Dinosaur not found"), null);
53 } else {
54 callback(null, { id, status: "healthy" });
55 }
56 }, 50);
57}
58
59function testCallback() {
60 return new Promise((resolve) => {
61 fetchDinoReport("REX-001", (error, report) => {
62 if (!error && report.status === "healthy") {
63 console.log("[PASS] Callback: report status =", report.status);
64 } else {
65 console.log("[FAIL] Callback failed");
66 }
67 resolve();
68 });
69 });
70}
71
72// --- Fake Timers ---
73console.log("--- Fake Timers ---\n");
74
75function demonstrateFakeTimers() {
76 console.log("In the real Jest:");
77 console.log(" jest.useFakeTimers() - switch on fake timers");
78 console.log(" jest.advanceTimersByTime(30000) - fast-forward by 30s");
79 console.log(" jest.runAllTimers() - run all timers");
80 console.log(" jest.useRealTimers() - restore real timers");
81
82 console.log("\nExample:");
83 console.log(" function startCountdown(cb) {");
84 console.log(" setTimeout(() => cb('GO!'), 30000);");
85 console.log(" }");
86 console.log(" ");
87 console.log(" test('countdown', () => {");
88 console.log(" jest.useFakeTimers();");
89 console.log(" const cb = jest.fn();");
90 console.log(" startCountdown(cb);");
91 console.log(" expect(cb).not.toHaveBeenCalled();");
92 console.log(" jest.advanceTimersByTime(30000);");
93 console.log(" expect(cb).toHaveBeenCalledWith('GO!');");
94 console.log(" });");
95}
96
97// --- resolves/rejects ---
98console.log("--- resolves / rejects matchers ---\n");
99
100async function demonstrateResolves() {
101 // resolves
102 const goodPromise = Promise.resolve({ status: "activated", zone: "A" });
103 const result = await goodPromise;
104 const matches = result.status === "activated" && result.zone === "A";
105 console.log("[" + (matches ? "PASS" : "FAIL") + "] resolves.toEqual:", JSON.stringify(result));
106
107 // rejects
108 const badPromise = Promise.reject(new Error("Zone ID is required"));
109 try {
110 await badPromise;
111 console.log("[FAIL] Should have rejected");
112 } catch (e) {
113 const matches = e.message === "Zone ID is required";
114 console.log("[" + (matches ? "PASS" : "FAIL") + "] rejects.toThrow:", e.message);
115 }
116}
117
118// Run the tests
119async function runAll() {
120 await testAsyncAwait();
121 console.log("");
122 await testReject();
123 console.log("");
124 await testCallback();
125 console.log("");
126 await demonstrateResolves();
127 console.log("");
128 demonstrateFakeTimers();
129}
130
131runAll();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. How do you correctly test an async function in Jest?
2. Which Jest method allows you to advance fake time by a specified number of milliseconds?
Hands-on tasks in the game
- Vertical ordering
Arrange the steps for testing an asynchronous function:
- Code editor
Write async/await tests for the analyzeDNA and cloneDinosaur functions.
- Click in order
Arrange the elements of fake timers usage in a test:
- Code editor
Use fake timers to test the park's evacuation system.