Mastering Asynchronous Testing with Jest: Promises, Async/Await, and Mocking
Asynchronous code is the foundation of modern JavaScript development. Whether fetching data from REST APIs, reading from files, or executing timers, handling operations out of the main thread is unavoidable. However, testing asynchronous code can be a common source of frustration, leading to false positives, silent failures, and flaky test suites.
In this guide, we will break down how JavaScript's popular testing framework, Jest, handles asynchronous code. We will cover legacy callback patterns, modern async/await syntax, handling rejected promises, and mocking external requests for robust, deterministic unit tests.
1. The Core Pitfall of Async Testing
By default, Jest executes test functions synchronously. Once the test function reaches its closing curly brace, Jest considers the test finished and evaluates the results. If your assertions live inside an asynchronous callback or unawaited promise, Jest will complete the test before those assertions ever run.
Consider this broken test example:
// ❌ BROKEN: Jest finishes before the timeout resolves
test('broken async test', () => {
setTimeout(() => {
expect(true).toBe(false); // This assertion will NEVER run!
}, 1000);
}); // Jest passes this test instantly!
Because Jest doesn't wait for the timer, the failing assertion is never executed, yielding a false positive. To fix this, you must explicitly tell Jest to wait for your asynchronous logic to resolve.
2. Strategy 1: The done Callback (Legacy)
For node-style callbacks or event listeners that don't return Promises, Jest provides a single argument to the test function usually named done. Jest will pause and wait until done() is explicitly called before concluding the test.
// ✅ CORRECT: Using done() callback
function fetchUserData(callback) {
setTimeout(() => {
callback({ id: 1, name: 'Alice' });
}, 500);
}
test('fetches user data via callback', (done) => {
function callback(data) {
try {
expect(data.name).toBe('Alice');
done(); // Tell Jest the async work is complete
} catch (error) {
done(error); // Pass errors to Jest so the test fails cleanly
}
}
fetchUserData(callback);
});
Note: Always wrap assertions inside a try...catch block when using done. If an expect fails inside a callback without catching the error, Jest will wait until the test times out rather than failing immediately with a clear error stack.
3. Strategy 2: Returning Promises
If your code returns a Promise, testing becomes much simpler. You can return the Promise directly from your test block. Jest will wait for the Promise to resolve or reject before marking the test as passed or failed.
function getUserPromise(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, role: 'admin' }), 200);
});
}
// ✅ Return the promise directly to Jest
test('resolves user role correctly', () => {
return getUserPromise(42).then((user) => {
expect(user.role).toBe('admin');
});
});
Crucial Rule: Do not omit the return statement! If you forget to return the promise, your test will pass before the .then() callback executes.
4. Strategy 3: Async / Await (Recommended Standard)
Using async/await is the cleanest and most readable way to write asynchronous tests in modern JavaScript. Mark the test function as async and use await before calling asynchronous functions.
test('fetches user with async/await', async () => {
const user = await getUserPromise(42);
expect(user.id).toBe(42);
expect(user.role).toBe('admin');
});
Testing Rejections and Errors
Testing that an async function rejects properly requires extra attention. If an error is expected, you must verify that the catch block was actually reached. Use expect.assertions(number) to verify that a specific number of assertions are run during the test.
function fetchProduct(id) {
return new Promise((_, reject) => {
if (!id) reject(new Error('Product ID required'));
});
}
test('fails when product ID is missing', async () => {
// Verifies that 1 assertion runs (prevents false passes if promise resolves)
expect.assertions(1);
try {
await fetchProduct(null);
} catch (error) {
expect(error.message).toMatch('Product ID required');
}
});
5. Strategy 4: The .resolves and .rejects Matchers
Jest includes built-in matchers specifically designed to unwrap promises without requiring try/catch blocks or manual .then() chains.
// Testing successful resolution
test('resolves to user data', async () => {
await expect(getUserPromise(1)).resolves.toEqual({ id: 1, role: 'admin' });
});
// Testing rejection
test('rejects with error on missing ID', async () => {
await expect(fetchProduct(null)).rejects.toThrow('Product ID required');
});
6. Mocking Async Network Calls (Axios / Fetch)
In unit testing, you should never make actual network requests to external servers. Real network calls slow down test suites and introduce external dependencies that cause instability. Instead, mock network libraries like axios or fetch.
import axios from 'axios';
import { getPosts } from './api';
// Mock axios module
jest.mock('axios');
test('fetches posts successfully from API', async () => {
const dummyPosts = [{ id: 1, title: 'Jest Testing' }];
// Define mock response for axios.get
axios.get.mockResolvedValue({ data: dummyPosts });
const posts = await getPosts();
expect(axios.get).toHaveBeenCalledWith('/api/posts');
expect(posts).toEqual(dummyPosts);
});
test('handles API network failure', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(getPosts()).rejects.toThrow('Network Error');
});
Summary Matrix: Async Testing Patterns
| Pattern | Best Used For | Key Requirement |
|---|---|---|
done() Callback |
Node callbacks, stream listeners | Call done() and wrap assertions in try/catch |
| Return Promise | Promise-based code without async/await | Must return the promise chain from the test |
async / await |
Modern asynchronous code (Standard) | Mark test block as async, use await |
.resolves / .rejects |
Clean assertion on promise states | Must await or return the expect() expression |
Conclusion
Asynchronous testing in Jest doesn't need to be complex. By adopting async/await along with built-in matchers like .resolves and .rejects, you can build clean, reliable test suites that eliminate false positives. Always remember to mock external API requests to keep your test suites fast, deterministic, and scalable.
Happy Testing! 🚀
Comments
Post a Comment