Testing Asynchronous Code
Introduction
Testing asynchronous code requires a different approach than synchronous tests. Whether you’re 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.
In this tutorial, you’ll learn how to test async functions effectively using both Jest and Vitest. We’ll cover 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
});
You need to tell your test runner to wait for the async operation to complete.
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);
});
});
Critical: Always return the promise! If you forget to return, Jest or Vitest will consider the test complete as soon as it reaches the last synchronous line, without waiting for the promise to resolve:
// WRONG - Missing return!
test('returns user', () => {
fetchUserData(1).then(user => {
expect(user.id).toBe(1); // This might not run!
});
});
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');
});
You can also test the error object more precisely:
test('throws detailed error', async () => {
await expect(fetchUserData(99999)).rejects.toMatchObject({
message: 'User not found',
name: 'Error'
});
});
Alternatively, wrap your async code in a try/catch block:
test('handles error gracefully', async () => {
const result = await fetchUserData(99999).catch(err => {
return { error: err.message };
});
expect(result).toHaveProperty('error');
});
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();
});
For Vitest, replace jest. with vi.:
test('with Vitest', () => {
vi.useFakeTimers();
// ... same pattern
vi.useRealTimers();
});
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);
});
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/awaitfor readable, straightforward async tests - Always return promises from your test function so the test runner waits
- Use
.rejectsto test async errors and exceptions - Fake timers speed up tests involving setTimeout/setInterval
Promise.alllets you test concurrent operations- Convert callback-based code to promises for easier testing
In the next tutorial in this series, you’ll learn about mocking modules and dependencies - essential skills for testing code that relies on external services or complex implementations.