EduFlow
Testing JavaScript Apps
0%

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.

  1. 1The function under test
  2. 2Asserting on promises
  3. 3Stubs: collaborators you control
  4. 4Test every branch of the error handling
  5. 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

src/save-expense.js
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:

JavaScript
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.)

Two timelines. Top: the test returns, then the promise rejects later and nobody is listening. Bottom: the test awaits, so the rejection fails the test. No await test starts test passes promise rejects await test starts waits test fails
Without await, the test ends before the assertion runs

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:

JavaScript
// 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 await the 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.

Further reading

Check your understanding

3 questions

Question 1 of 3

This test passes, yet the validation in saveExpense was deleted and the call now resolves. Why didn’t the test catch it?

JavaScript
test('rejects a blank description', async () => {
  try {
    await saveExpense({ description: ' ', amountCents: 100 }, api);
  } catch (error) {
    expect(error.message).toBe('Description is required');
  }
});
Question 2 of 3

saveExpense should retry once on a retryable error, but give up immediately on any other error. Which test catches a version that retries every error?

Question 3 of 3

A test that uses the stub { post: () => new Promise(() => {}) } fails after five seconds with a timeout. What happened?

Practice

Code playground

Test every branch of saveExpense

saveExpense(expense, api) validates the expense, calls api.post, retries once on errors marked retryable: true, and passes every other error on. Its rules are in the subject’s comment.

The starter gives you a stubApi(...steps) helper: each call to post uses the next step, resolving with an object or rejecting with an Error, and api.calls counts the calls. There is also a retryable(message) helper.

Add tests until your suite catches four hidden bugs: a missing await, a catch that returns null instead of rejecting, a version that never retries, and a version that retries every error. Remember to await every resolves and rejects.

// Saves an expense through the API and resolves with the new id.
// - Rejects with "Description is required" if the description is blank.
// - Rejects with "Amount must be positive" unless amountCents is a
//   positive integer. Invalid input never reaches the API.
// - If api.post fails with an error that has { retryable: true },
//   it tries exactly ONE more time.
// - Any other API error is passed on: the promise rejects with it.
async function saveExpense(expense, api) {
  if (!expense.description || !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);
    return id;
  }
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real saveExpense
  • Catches the version with a missing await
  • Catches the version that swallows API errors
  • Catches the version that never retries
  • Catches the version that retries non-retryable errors