Skip to content

Testing Async/Await in Jest

async/await is the most common way to write asynchronous JavaScript today, and Jest tests can use the exact same syntax. This lesson focuses on testing async functions cleanly, including error cases.

Writing an async Test Function

Marking your test callback as async lets you await any promise directly inside the test body, just like in regular application code. Jest automatically waits for the test function's returned promise to settle before deciding pass or fail.

This is generally considered the clearest way to test asynchronous code, since it reads top-to-bottom like synchronous code, with try/catch available for error handling exactly as you'd expect.

async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error('User not found');
  return response.json();
}

test('returns user data for a valid id', async () => {
  const user = await fetchUser(1);
  expect(user).toEqual({ id: 1, name: 'Ada' });
});

The test function itself is async, letting you await the function under test directly.

Testing Thrown Errors with try/catch

test('throws when the user is not found', async () => {
  expect.assertions(1);
  try {
    await fetchUser(999);
  } catch (error) {
    expect(error.message).toBe('User not found');
  }
});
  • Wrap the await call in try/catch to assert on a thrown error's properties.
  • Always pair this pattern with expect.assertions(n) so a missing rejection fails the test.
  • Alternatively, await expect(fetchUser(999)).rejects.toThrow('User not found') is more concise for simple cases.
  • Choose try/catch when you need to inspect multiple properties of the thrown error object.

Testing async/await Cheat Sheet

The core patterns for testing async functions.

Goal Pattern
Await a successful result const result = await fn(); expect(result)...
Assert a thrown error (concise) await expect(fn()).rejects.toThrow('msg')
Assert error details (verbose) try { await fn(); } catch (e) { expect(e...) }
Guard against a missing rejection expect.assertions(1) at the top of the test
Test parallel async calls await Promise.all([fn1(), fn2()])

When try/catch Beats .rejects

.rejects.toThrow() is concise but limited to checking the error's message or type. If you need to check custom properties on a thrown error (like a statusCode or code field), try/catch gives you full access to the error object.

class ApiError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}

test('throws an ApiError with a 404 status code', async () => {
  expect.assertions(2);
  try {
    await fetchUser(999);
  } catch (error) {
    expect(error).toBeInstanceOf(ApiError);
    expect(error.statusCode).toBe(404);
  }
});

Using async Setup Hooks

beforeEach(), beforeAll(), and their after counterparts can all be async too, which is essential when setup itself involves asynchronous work like seeding a test database.

beforeEach(async () => {
  await db.seed(fixtureUsers);
});

Common Mistakes

  • Forgetting expect.assertions(n) in a try/catch test, letting a missing rejection pass silently.
  • Not marking the test callback as async when using await inside it (a syntax error).
  • Using verbose try/catch when a simple .rejects.toThrow() would be clearer.
  • Forgetting setup hooks can be async too, and running assertions before seed data is ready.

Key Takeaways

  • Mark test callbacks async to await promises directly inside the test body.
  • try/catch with expect.assertions() is the most thorough way to test thrown errors.
  • .rejects.toThrow() is a more concise alternative for simple error-message checks.
  • Setup hooks (beforeEach(), etc.) support async functions for asynchronous setup.

Pro Tip

Default to .rejects.toThrow() for simple cases, and only reach for the more verbose try/catch + expect.assertions() pattern when you genuinely need to inspect multiple properties on the thrown error.