jsguides

Testing asynchronous code with Jest and Vitest

Introduction

Testing asynchronous code requires a different approach than synchronous tests. Whether you are working with promises, async/await, or callbacks, you need to ensure your tests properly wait for async operations to complete before making assertions.

In JavaScript, async operations are everywhere, from API calls to file I/O, from timers to WebSocket connections. Writing tests for these operations can be tricky because you need to make sure the async work is done before you check your expectations.

You will learn how to test async functions effectively using both Jest and Vitest. The tutorial covers multiple approaches, from basic promise testing to advanced techniques like fake timers and concurrent operations.

Why async testing is different

When you write synchronous tests, your code executes line by line and finishes quickly. But with async code, JavaScript doesn’t wait for operations to complete - it continues executing while the async work happens in the background.

This means traditional test patterns won’t work. If you try to assert on a promise result immediately, you’ll be testing an unresolved promise, not the actual data:

// WRONG - This won't work!
test('fetches user', () => {
  const user = fetchUser(1); // Returns a Promise, not user data
  expect(user.id).toBe(1);   // Fails: user is a Promise
});

The broken test above returns immediately because fetchUser produces a promise, not a value. To fix this, you need to tell the test runner to wait. Jest and Vitest both detect async test functions automatically: if your test callback is async, the runner pauses until the returned promise settles. The next section shows the correct pattern.

Testing Async Functions with async/await

The most straightforward way to test async code is using async/await, which makes asynchronous code look synchronous:

// user-api.js
export async function fetchUserData(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}

// user-api.test.js
import { fetchUserData } from './user-api';

test('fetches user data successfully', async () => {
  const user = await fetchUserData(1);

  expect(user).toHaveProperty('id', 1);
  expect(user).toHaveProperty('name');
  expect(user).toHaveProperty('email');
});

Both Jest and Vitest automatically handle promises returned from async test functions. The test won’t complete until the promise resolves or rejects. This makes async/await the preferred approach for most async testing scenarios.

Testing promises directly

You can also test promises without async/await using the traditional .then() pattern:

import { fetchUserData } from './user-api';

test('returns user data', () => {
  return fetchUserData(1).then(user => {
    expect(user).toHaveProperty('id', 1);
  });
});

The explicit .then() approach works but is easy to get wrong. The most common mistake is forgetting to return the promise from the test function. When you omit return, the test runner reaches the end of the function immediately and marks the test as passed, never running the assertions inside .then(). The example below shows this bug in action:

// WRONG - Missing return!
test('returns user', () => {
  fetchUserData(1).then(user => {
    expect(user.id).toBe(1); // This might not run!
  });
});

The missing-return bug is silent and hard to debug because the test passes when it should fail. Once you have the happy path working, the next step is testing what happens when things go wrong. Jest and Vitest both provide .rejects and .resolves matchers that work directly with promises, making error testing concise.

Handling errors in async tests

Testing async error handling requires special attention. Use .rejects to catch and test thrown errors:

test('throws error for non-existent user', async () => {
  await expect(fetchUserData(99999)).rejects.toThrow('User not found');
});

.rejects.toThrow() checks the error message, but sometimes you need to inspect the full error object to verify multiple fields at once. The .rejects.toMatchObject() matcher lets you assert against specific properties like message and name without needing to catch the error yourself. This is cleaner than a try/catch block when all you need is a snapshot of the error shape:

test('throws detailed error', async () => {
  await expect(fetchUserData(99999)).rejects.toMatchObject({
    message: 'User not found',
    name: 'Error'
  });
});

When you need more control over error handling, or when you want to test recovery logic, .catch() on the promise lets you intercept the error and return a fallback value. This is useful for testing graceful degradation rather than just asserting that an error was thrown:

test('handles error gracefully', async () => {
  const result = await fetchUserData(99999).catch(err => {
    return { error: err.message };
  });

  expect(result).toHaveProperty('error');
});

Error assertions are essential for testing async code, but async tests also need to handle time-dependent code correctly. A test that calls setTimeout or setInterval with real timers would wait seconds for every run, which adds up quickly in a large suite. Fake timers replace the real clock so you can advance time instantly, making timer-dependent tests run in milliseconds instead of real seconds. Both Jest and Vitest provide the same API for this.

Testing with Timers

Code that uses setTimeout or setInterval can be slow in tests if you wait for real time to pass. Fake timers let you control time artificially:

// notification.js
export function showNotification(message, duration = 5000) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ message, shown: true });
    }, duration);
  });
}

// notification.test.js
import { showNotification } from './notification';

test('resolves after specified duration', async () => {
  jest.useFakeTimers();

  const promise = showNotification('Hello!', 5000);

  // Advance time but don't let it complete
  jest.advanceTimersByTime(3000);

  // The promise shouldn't resolve yet
  const result = await Promise.race([
    promise,
    new Promise(resolve => setTimeout(() => resolve('pending'), 1000))
  ]);
  expect(result).toBe('pending');

  // Advance past the duration
  jest.advanceTimersByTime(3000);
  const finalResult = await promise;
  expect(finalResult).toEqual({ message: 'Hello!', shown: true });

  jest.useRealTimers();
});

The example above uses Jest’s timer API, but the pattern is identical in Vitest. The only difference is the namespace: swap jest. for vi. and the same useFakeTimers, advanceTimersByTime, and useRealTimers calls work without any other changes:

test('with Vitest', () => {
  vi.useFakeTimers();
  // ... same pattern
  vi.useRealTimers();
});

Fake timers are useful when your code depends on real delays. But many async operations involve multiple independent promises that run at the same time. Testing concurrent operations requires asserting that all promises resolve correctly, including cases where some of them fail.

Testing concurrent operations

When testing code that runs multiple async operations, you often want to test both success and failure scenarios:

// user-service.js
export async function fetchMultipleUsers(userIds) {
  const promises = userIds.map(id => fetchUserData(id));
  return Promise.all(promises);
}

export async function fetchFirstAvailableUser(userIds) {
  const promises = userIds.map(id => fetchUserData(id).catch(err => null));
  const results = await Promise.all(promises);
  return results.find(user => user !== null);
}

// concurrent.test.js
test('fetches multiple users concurrently', async () => {
  const users = await fetchMultipleUsers([1, 2, 3]);

  expect(users).toHaveLength(3);
  users.forEach(user => {
    expect(user).toHaveProperty('id');
  });
});

test('returns first successful user', async () => {
  const user = await fetchFirstAvailableUser([999, 1, 2]);
  expect(user).toHaveProperty('id', 1);
});

The concurrent patterns above test modern promise-based code. But many codebases still contain callback-style functions. Jest and Vitest support callbacks through the done parameter, which tells the test runner to wait until the callback is called before finishing the test. While this works, the longer-term strategy should be wrapping callbacks in promises so the rest of the test suite stays consistent.

Testing callback-based code

Some legacy code uses callbacks instead of promises. You can test these with done(), though async/await is preferred when possible:

// legacy-callback.js
export function fetchWithCallback(id, callback) {
  setTimeout(() => {
    if (id > 0) {
      callback(null, { id, name: 'User' });
    } else {
      callback(new Error('Invalid ID'));
    }
  }, 100);
}

// legacy-callback.test.js
test('handles callback success', done => {
  fetchWithCallback(1, (err, user) => {
    expect(err).toBeNull();
    expect(user).toHaveProperty('id', 1);
    done();
  });
});

Tip: Prefer converting callback-based code to promises using util.promisify() in Node.js, or a library like callback-to-promise, then test with async/await.

Summary

Testing async code requires understanding how JavaScript handles asynchronous operations. Here are the key points to remember:

  • Use async/await for readable, straightforward async tests
  • Always return promises from your test function so the test runner waits
  • Use .rejects to test async errors and exceptions
  • Fake timers speed up tests involving setTimeout/setInterval
  • Promise.all lets you test concurrent operations
  • Convert callback-based code to promises for easier testing

A small checklist for async tests

Before you call an async test done, make sure the test waits for the right promise, advances fake time only when it needs to, and leaves the runner in a clean state. That last point matters because a timer or pending promise can leak into the next test and make the failure look random. A short checklist keeps the whole file steadier and makes the async shape easier to scan.

When a test gets confusing, reduce it to the smallest async path that still shows the issue. That often means removing extra setup, deleting unrelated assertions, or splitting one big test into two smaller ones. The simpler version usually reveals whether the problem is timing, error handling, or the test harness itself. Once you know that, the fix is much easier to make.

Practical habits for async tests

Async tests are easiest to maintain when each one has a single reason to wait. If a test is waiting for a promise, a timer, and a callback at the same time, it becomes hard to tell which part actually failed. A cleaner test usually isolates one async shape and one assertion path. That way the failure message points to the real problem instead of to the whole test file.

Timers need special care because they can mask race conditions. Fake timers are great when you want speed and control, but they also change the shape of the code you are exercising. When a test uses fake time, keep the setup explicit and the cleanup just as clear. That makes it obvious when the test is depending on timer behavior and when it is simply checking final state after the delay has passed.

Promise tests also benefit from a disciplined return path. If a promise is meant to resolve, await it or return it. If it is meant to fail, assert the rejection directly rather than catching and ignoring the error. That gives the test runner enough information to know whether the promise behaved as expected. It also protects you from false positives where the test passes before the asynchronous work actually finishes.

For callback-based code, the goal should usually be migration, not long-term preservation. Tests can keep old code stable while you move the implementation toward promises or async functions. When you have a choice, put the awkward callback shape behind a smaller wrapper and test the wrapper once. Then focus most of your test coverage on the promise-based surface that the rest of the app actually uses. That keeps the suite easier to read and easier to extend.

Next steps

The next topic in this series is mocking modules and dependencies. Once your async tests are reliable, mocking lets you isolate the code under test from network calls, file I/O, and third-party services. Combine fake timers with mocked fetch to build fast, deterministic tests for data-fetching hooks and API layers. For a deeper dive into mock patterns, see mocking with Jest.

See Also