
Promises are the backbone of modern asynchronous JavaScript. They represent a value that may be available now, later, or never. When writing tests for async code, the biggest trap is treating promises like synchronous calls, which leads to false positives or tests that don’t actually wait for the async operation to complete.
At their core, a promise has three states: pending, fulfilled (resolved), or rejected. When a promise resolves, it returns a value; when it rejects, it throws an error. Understanding that is key to writing effective tests that wait for these outcomes before asserting expectations.
Consider this simple example:
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve("data received"), 100);
});
}
If you write a test without waiting for the promise to settle, your assertions run immediately, ignoring the async operation:
test("fetchData returns data", () => {
const data = fetchData();
expect(data).toBe("data received"); // This fails, because data is a promise, not the resolved value.
});
The mistake here is treating fetchData() as synchronous. Instead, you need to wait for the promise to finish. This means your test function itself must handle asynchronous code properly.
ProCase for iPad 9th/ 8th/ 7th Generation Case 10.2 Inch (2021/2020/2019 Release), 10.2 iPad Case 9th/ 8th/ 7th Gen Cover, Slim Smart Cover with Translucent Hard Shell Back -Black
$16.99 (as of July 18, 2026 03:40 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.)Writing tests that wait for promises to resolve or reject
The simplest way to handle that is by returning the promise from your test function. Most testing frameworks, like Jest or Mocha, recognize a returned promise and wait for it to resolve or reject before marking the test as passed or failed.
test("fetchData returns data", () => {
return fetchData().then(data => {
expect(data).toBe("data received");
});
});
By returning the promise, the test runner knows to pause until the asynchronous operation completes. If the promise rejects, the test will fail automatically, which is exactly what you want.
Another modern approach is to use async/await syntax, which makes asynchronous tests look and behave more like synchronous code:
test("fetchData returns data", async () => {
const data = await fetchData();
expect(data).toBe("data received");
});
That’s easier to read and reason about, especially as your tests grow more complex. Just remember to mark your test function as async to enable the await keyword.
Handling promise rejections is equally important. You want to test that your function throws or rejects correctly when something goes wrong. For example:
function fetchDataWithError() {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("network error")), 100);
});
}
To test that this promise rejects as expected, you can use the .catch() method or try/catch with async/await:
test("fetchDataWithError rejects with an error", () => {
return fetchDataWithError().catch(error => {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("network error");
});
});
Or with async/await:
test("fetchDataWithError rejects with an error", async () => {
expect.assertions(2);
try {
await fetchDataWithError();
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("network error");
}
});
Notice the use of expect.assertions() here. It ensures that the assertions inside the catch block actually run, preventing false positives if the promise unexpectedly resolves.
Alternatively, some frameworks provide helpers to simplify this pattern. Jest, for example, has a .rejects matcher that makes testing rejections concise and readable:
test("fetchDataWithError rejects with an error", async () => {
await expect(fetchDataWithError()).rejects.toThrow("network error");
});
By using these techniques, you can write robust tests that properly wait for asynchronous operations to complete, whether they resolve or reject. This eliminates flaky tests and ensures your async code behaves as expected under test conditions.
But what if your async code involves multiple chained promises or complex flows? Returning or awaiting a single promise might not be enough to cover all edge cases or intermediate states…
Using testing frameworks to handle promise-based code seamlessly
Testing frameworks are designed to make handling promises painless, especially when dealing with complex asynchronous flows. Jest, Mocha, Jasmine, and others provide built-in support for promises, so you don’t have to manually manage callbacks or timers in most cases.
For example, Jest automatically detects if you return a promise from a test, and it will wait for that promise to resolve or reject. This also applies to lifecycle hooks like beforeEach, afterEach, beforeAll, and afterAll. Returning promises from these hooks ensures your setup and teardown code runs sequentially and completes before the next test starts.
beforeEach(() => {
return setupDatabase();
});
test("database returns the expected user", async () => {
const user = await getUserById(42);
expect(user.name).toBe("Douglas");
});
Jasmine follows a similar pattern. If you return a promise from a spec or a lifecycle hook, it waits for the promise to settle. Mocha also supports this, but you can additionally use the done callback for legacy async tests. However, mixing done with promises is discouraged because it can lead to confusing behavior and missed errors.
When you have multiple promises running in parallel, you can use Promise.all() to wait for all of them to complete before asserting. This is especially useful when testing functions that trigger several async operations simultaneously:
test("multiple async operations complete", async () => {
const results = await Promise.all([
fetchData(),
fetchDataWithError().catch(e => e),
anotherAsyncFunction()
]);
expect(results[0]).toBe("data received");
expect(results[1]).toBeInstanceOf(Error);
expect(results[2]).toBe("another result");
});
For testing timeouts or delays, frameworks often provide timer mocks to control the flow of time, which especially important for making your tests deterministic and fast. Jest’s fake timers let you advance time manually, avoiding actual waits:
jest.useFakeTimers();
test("fetchData resolves after timeout", () => {
const promise = fetchData();
jest.advanceTimersByTime(100);
return promise.then(data => {
expect(data).toBe("data received");
});
});
This approach is great for testing code that relies on setTimeout or other timer-based APIs without slowing down your test suite.
When working with async iterators or streams, you can combine for await...of loops with testing frameworks. This allows you to consume asynchronous data lazily in tests:
async function* asyncGenerator() {
yield "first";
yield "second";
yield "third";
}
test("async iterator yields correct values", async () => {
const results = [];
for await (const value of asyncGenerator()) {
results.push(value);
}
expect(results).toEqual(["first", "second", "third"]);
});
Some frameworks also allow you to set a timeout for individual tests, guarding against hanging promises or forgotten awaits. Jest’s default timeout is 5 seconds, but you can customize it per test or globally:
test("long running async test", async () => {
await someLongAsyncOperation();
}, 10000); // 10-second timeout
Good testing frameworks embrace promises natively, freeing you from callback hell and making your asynchronous tests clean, readable, and reliable. They handle waiting, error propagation, and time control so you can focus on asserting the behavior of your code rather than the mechanics of async execution.
