
Asynchronous code is notoriously tricky to test because timing and order of execution become unpredictable. Unlike synchronous functions, where you call and immediately get a result, asynchronous functions return control before the work is done. This means your tests can either finish too early or wait forever if not handled correctly.
One of the biggest challenges is knowing when an async operation has completed. Callbacks, promises, or async/await syntax often wrap the operation, but the test framework needs explicit signals to know when to proceed. Without these, tests either falsely pass by not waiting long enough or hang indefinitely.
Consider a simple example where a function fetches data and calls a callback once done. Naively, you might write:
function fetchData(callback) {
setTimeout(() => {
callback("data");
}, 1000);
}
test("fetchData returns data", () => {
fetchData((result) => {
expect(result).toBe("data");
});
});
Here, the test framework has no idea that fetchData is async. It runs fetchData, immediately finishes the test, and never verifies the callback’s expectations. The test will falsely pass or silently fail depending on your test runner.
One way to fix that is by telling the test framework to wait, often by returning a promise or using a done callback parameter:
test("fetchData returns data", (done) => {
fetchData((result) => {
expect(result).toBe("data");
done();
});
});
But this can quickly become cumbersome with multiple async calls or nested callbacks. Promises and async/await syntax simplify this, but only if the test harness supports them properly.
Another challenge is handling flaky tests caused by timing issues. Async operations might depend on external systems like databases or network calls, which introduce latency and variability. Tests that pass locally may fail on CI or in different environments.
To mitigate this, you need to isolate async behavior, mock external dependencies, or control timers to make execution deterministic. Without controlling time and dependencies, tests become unreliable and erode trust in your test suite.
Asynchronous testing complexities boil down to coordination: when does the async code finish, how do you signal completion to the test framework, and how do you keep tests fast and reliable despite real-world unpredictability? The next step is mastering promises and async/await to write clean, maintainable tests that explicitly handle async flow without callback hell or flaky waits.
Philips 24 inch 100Hz Computer Monitor, Frameless Full HD (1920 x 1080), VESA, HDMI x1, VGA Port x1, Eye Care, 4 Year Advance Replacement Warranty, 241V8LB
$79.99 (as of June 2, 2026 22:39 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Mastering promises and async await in test scenarios
Promises provide a simpler way to represent eventual completion or failure of asynchronous operations. Instead of passing callbacks, functions return promises that resolve or reject, allowing chaining and composition. When testing, returning a promise from the test function signals to the framework that it must wait for resolution before considering the test done.
For example, rewriting fetchData to return a promise:
function fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("data");
}, 1000);
});
}
test("fetchData returns data", () => {
return fetchData().then(result => {
expect(result).toBe("data");
});
});
This pattern is a significant improvement. The test runner now knows to wait until the promise resolves before finishing. If the promise rejects, the test fails automatically. You’ve eliminated the need for explicit done callbacks and nested callback pyramids.
However, promise chaining can still become verbose and harder to read, especially with multiple sequential or parallel asynchronous operations. That’s where async/await shines by allowing asynchronous code to look and behave more like synchronous code.
Using async/await in the test, the previous example becomes:
test("fetchData returns data", async () => {
const result = await fetchData();
expect(result).toBe("data");
});
Marking the test function async causes it to return a promise implicitly. The test framework waits for the await expression to resolve before continuing. This makes the test code much cleaner, easier to understand, and less error-prone.
When testing multiple async operations in sequence, async/await allows writing linear code instead of nested .then() chains:
async function getUserData() {
const user = await fetchUser();
const posts = await fetchPosts(user.id);
return { user, posts };
}
test("getUserData returns user and posts", async () => {
const data = await getUserData();
expect(data.user).toHaveProperty("id");
expect(Array.isArray(data.posts)).toBe(true);
});
For parallel async operations, Promise.all combined with await keeps things concise and efficient:
test("fetch multiple resources in parallel", async () => {
const [users, comments] = await Promise.all([fetchUsers(), fetchComments()]);
expect(users.length).toBeGreaterThan(0);
expect(comments.length).toBeGreaterThan(0);
});
Beware of forgetting to return or await promises in tests, as this causes premature test completion and false positives. Always ensure that any asynchronous call you want the test to wait for is either returned or awaited.
When handling rejected promises, use try/catch blocks inside async tests to assert on errors explicitly:
test("fetchData fails with error", async () => {
try {
await fetchDataWithError();
} catch (e) {
expect(e.message).toMatch(/network error/);
}
});
Alternatively, many test frameworks support assertion helpers that expect promises to reject:
test("fetchData fails with error", () => {
return expect(fetchDataWithError()).rejects.toThrow(/network error/);
});
Mastering these patterns lets you write robust, readable async tests that integrate naturally with your test runner’s lifecycle and error handling. The next frontier is controlling the asynchronous flow itself using mocks and timers, which lets you eliminate real delays and external dependencies from your tests, making them fast and deterministic.
Using mocks and timers to control asynchronous flow
Mocking is a powerful technique that allows you to simulate the behavior of complex dependencies in your tests, making it easier to isolate the code under test. By replacing real implementations with mocks, you can control how external systems behave, ensuring your tests run quickly and reliably without depending on the actual implementation of those systems.
Consider a scenario where you have a function that relies on an API call. Instead of making the actual call during your tests, you can create a mock function that simulates the API response:
function fetchDataFromAPI() {
return fetch("https://api.example.com/data")
.then(response => response.json());
}
test("fetchDataFromAPI returns mock data", async () => {
const mockData = { key: "value" };
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockData),
})
);
const data = await fetchDataFromAPI();
expect(data).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith("https://api.example.com/data");
});
This example uses Jest to mock the global fetch function. By controlling the behavior of fetch, you eliminate reliance on the actual network call, reducing test flakiness and execution time. When using mocks, always ensure you restore the original implementation after the test to prevent side effects on other tests.
Another useful technique is controlling timers when testing functions that involve delays. JavaScript’s timers can be manipulated using libraries like Jest, which provides methods to advance timers or mock their implementation:
jest.useFakeTimers();
function delayedGreeting() {
return new Promise(resolve => {
setTimeout(() => {
resolve("Hello, World!");
}, 1000);
});
}
test("delayedGreeting resolves after 1 second", async () => {
const promise = delayedGreeting();
jest.advanceTimersByTime(1000);
const result = await promise;
expect(result).toBe("Hello, World!");
});
In this case, jest.useFakeTimers() replaces the real timers with mock timers. This allows you to fast-forward time using jest.advanceTimersByTime(), making your tests run instantaneously without actual delays.
However, be cautious when using fake timers, as they can lead to confusion if not properly managed. Ensure you reset timers after each test or when switching between real and fake timers to maintain test isolation.
Combining mocks and timers provides a robust framework for controlling asynchronous behavior in your tests. By isolating dependencies and removing real delays, you can create a suite of tests that are fast, reliable, and deterministic, allowing for confident refactoring and development.
As you delve deeper into asynchronous testing, remember that the key is to maintain clarity and control over the flow of execution. With the right tools and techniques, you can navigate the complexities of async code with ease, ensuring that your tests remain effective and maintainable.
