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 thereturn). 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.
- A stub returns a fixed predefined value.
- A spy wraps a function and records how it was called (with what arguments, how many times) without changing its behavior.
- A mock combines both: it replaces the dependency and lets you verify the interactions.
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));