DevPath · Learn to code ESPTEN

Node.js, npm and testing

Asynchronous testing and test doubles (mocks)

Testing asynchronous code

A lot of real code is asynchronous: it requests data from a server, reads a file, waits for a timer... It returns promises. The challenge when testing it is that the result is not available immediately, so the test must wait.

There are two ways to do it right, and one way to do it wrong.

1. Mark the test as async and use await

async function getUser(id) {
  return { id, name: "Ada" };  // in real life it would do a fetch
}

test("getUser returns the name", async () => {
  const user = await getUser(1);
  expect(user.name).toBe("Ada");
});

2. Return the promise from the test

If you return the promise, the runner waits for it to resolve for you:

test("getUser resolves", () => {
  return getUser(1).then((u) => {
    expect(u.name).toBe("Ada");
  });
});

The classic mistake: forgetting the await (or the return). The test finishes before the promise resolves and the assertion is never checked: the test "passes" even though the code is broken. If you test async, always wait.

Test doubles: mocks and spies

Sometimes you don't want to run a real dependency in a test: sending an email, charging a card or calling an API are slow, costly or have effects we don't want in tests. The solution is test doubles: fake objects that replace the real dependency.

The core idea of a spy is to save every call to inspect it later:

function spyOn(fn) {
  const spy = (...args) => {
    spy.calls.push(args);        // records the arguments
    return fn(...args);          // delegates to the real function
  };
  spy.calls = [];
  return spy;
}

const spiedAdd = spyOn((a, b) => a + b);
spiedAdd(2, 3);
spiedAdd(10, 1);
console.log(spiedAdd.calls);        // [[2, 3], [10, 1]]
console.log(spiedAdd.calls.length); // 2

With this, in a test you can assert things like "the pay button called charge exactly once with the correct amount", without actually charging. Tools like Jest or Vitest bring this built in (vi.fn(), jest.fn()), but under the hood they do exactly what you see above.

Examples

A spy that counts calls and records arguments

function spyOn(fn) {
  const spy = (...args) => {
    spy.calls.push(args);
    return fn(...args);
  };
  spy.calls = [];
  return spy;
}

const greeting = spyOn((name) => "Hello, " + name);
greeting("Ada");
greeting("Linus");
console.log("Returns:", greeting("Grace"));
console.log("Times called:", greeting.calls.length);
console.log("Calls:", JSON.stringify(greeting.calls));
Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
← Quality and bundlingView the module →