Section 2 · Unit Tests That Matter
Testing Errors and Async Code
18 min3 min readCode playground
Lesson at a glance
Test code that throws, returns promises or depends on a flaky API, using awaited resolves and rejects assertions and small hand-written stubs.
- 1The function under test
- 2Asserting on promises
- 3Stubs: collaborators you control
- 4Test every branch of the error handling
- 5Timeouts
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Splitwise-lite saves expenses over a phone connection on a train. Requests fail. Some failures are worth one retry (a timeout); others are not (the server says the amount is invalid). The code that decides is a dozen lines, and it is exactly the code nobody tests, because “it’s async” and “it needs the API”. Neither is a real obstacle.
The function under test
export async function saveExpense(expense, api) {
if (!expense.description?.trim()) throw new Error('Description is required');
if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {
throw new Error('Amount must be positive');
}
try {
const { id } = await api.post('/expenses', expense);
return id;
} catch (error) {
if (!error.retryable) throw error;
const { id } = await api.post('/expenses', expense); // one retry
return id;
}
}Because it’s an async function, every throw inside it becomes a rejected promise. Callers (and tests) never see a synchronous exception; they see a promise that rejects.
Asserting on promises
Vitest waits for a test that is async or returns a promise. Inside, you have two good options:
test('resolves with the new id', async () => {
const api = { post: async () => ({ id: 'e1' }) };
await expect(saveExpense({ description: 'Dinner', amountCents: 4500 }, api))
.resolves.toBe('e1');
});
test('rejects a blank description', async () => {
const api = { post: async () => ({ id: 'e1' }) };
await expect(saveExpense({ description: ' ', amountCents: 4500 }, api))
.rejects.toThrow('Description is required');
});You can also await the call and assert on the value: const id = await saveExpense(...); expect(id).toBe('e1');. For rejections, prefer rejects over try/catch: if the promise unexpectedly resolves, rejects fails the test, while a try/catch silently skips the catch block and passes. (If you must use try/catch, put expect.assertions(1) at the top so Vitest fails when no assertion ran.)
Stubs: collaborators you control
saveExpense talks to an api. In a test you don’t want a real server; you want an object whose post does whatever this test needs. That object is a stub, and you don’t need a library to write one:
// Fails once with a retryable error, then succeeds; counts its calls.
function flakyApi() {
let calls = 0;
return {
get calls() { return calls; },
async post() {
calls += 1;
if (calls === 1) throw Object.assign(new Error('Timed out'), { retryable: true });
return { id: 'e2' };
},
};
}
test('retries once after a retryable error', async () => {
const api = flakyApi();
await expect(saveExpense({ description: 'Taxi', amountCents: 1800 }, api)).resolves.toBe('e2');
expect(api.calls).toBe(2);
});Passing api in as a parameter is what makes this easy. Code that imports a global client deep inside is much harder to test; you meet the tools for that (and their cost) in the next lesson.
Test every branch of the error handling
Error handling has more branches than the happy path, and each one is a decision someone made. For saveExpense:
| Situation | Expected |
|---|---|
| API succeeds | resolves with the id, one call |
| Retryable error, then success | resolves with the id, two calls |
| Non-retryable error | rejects with that error, one call |
| Invalid input | rejects with a validation message, zero calls |
The third row is the one people skip, and it hides two common bugs: a catch that swallows the error and returns null (callers think the save worked), and a catch that retries everything (a rejected payment charged twice). The rejection alone can’t distinguish the second bug, because the retry fails too. Counting the calls can.
Notice the last row too: invalid input should make zero calls. That is a promise about side effects, and it matters. A version that validates after posting would still reject with the right message, so only the call count proves the bad expense never reached the server.
Timeouts
If a promise never settles (a stub that forgets to resolve, a missing await in a loop), the test hangs until Vitest’s timeout, five seconds by default, and fails with a timeout error. Pass a third argument to test for a slower case, or set testTimeout in the config, but treat a slow unit test as a smell: something real is probably leaking in.
In the exercise you test all four rows of that table and catch four realistic bugs, including the double-charge one. The next lesson replaces the hand-written stubs with vi.fn.
Key takeaways
- Always
awaitthe promise or the assertion on it; a test that ends before the promise settles can pass no matter what the code does. await expect(promise).rejects.toThrow('message')is the clearest way to test an async failure.- A stub is a tiny fake collaborator you control, such as an api object whose post method succeeds, fails or counts its calls.
- Test every branch of error handling, including the one that should give up, and count calls when the difference is how many attempts were made.