How can you test asynchronous code in JavaScript?
TL;DR
Return or await the asynchronous work so the test runner knows when the test finishes. Test fulfilled and rejected paths with controlled dependencies, use fake timers for timer-driven code, and avoid real networks and arbitrary sleeps in unit tests. Restore timers, mocks, listeners, servers, and other global state after each test.
Vitest, Jest, and Mocha all support Promise-returning and async tests. Callback-style tests can use a done callback when maintaining callback APIs, but Promise-based tests are usually easier to compose and fail correctly.
Await the result and assert behavior
The examples use Vitest; Jest has a similar test / expect interface.
// load-user.jsexport async function loadUser(fetchData, userId) {const response = await fetchData(`/api/users/${userId}`);if (!response.ok) throw new Error(`Request failed (${response.status})`);return response.json();}
import { expect, test, vi } from 'vitest';import { loadUser } from './load-user.js';test('returns the decoded user', async () => {const fetchData = vi.fn().mockResolvedValue({ok: true,json: async () => ({ id: 42, name: 'Avery' }),});await expect(loadUser(fetchData, 42)).resolves.toEqual({id: 42,name: 'Avery',});expect(fetchData).toHaveBeenCalledWith('/api/users/42');});test('rejects when the server returns an error status', async () => {const fetchData = vi.fn().mockResolvedValue({ ok: false, status: 503 });await expect(loadUser(fetchData, 42)).rejects.toThrow('Request failed (503)');});
This test is deterministic and does not send data to a remote API. A separate integration test can exercise the real HTTP boundary against a controlled test server.
Return Promises when not using async
A runner also waits when the test returns a Promise:
test('loads a user', () => {return loadUser(fetchData, 42).then((user) => {expect(user.id).toBe(42);});});
Forgetting both return and await is a common false positive: the test finishes before its assertions run.
Test timer-driven code with fake timers
Do not make a unit test sleep for a real second. Advance the clock deliberately and restore it afterward:
import { afterEach, expect, test, vi } from 'vitest';afterEach(() => {vi.useRealTimers();vi.restoreAllMocks();});test('retries after one second', async () => {vi.useFakeTimers();const operation = vi.fn().mockRejectedValueOnce(new Error('temporary')).mockResolvedValue('saved');const result = retryAfterDelay(operation, 1000);await vi.advanceTimersByTimeAsync(1000);await expect(result).resolves.toBe('saved');expect(operation).toHaveBeenCalledTimes(2);});
Fake timers are not always appropriate for APIs whose behavior depends on the real event loop or browser. Use the runner's documented timer APIs and keep at least one integration test when timing behavior crosses real platform boundaries.
Callback APIs
When an API genuinely uses callbacks, signal completion exactly once and route assertion failures to the runner:
test('reads a value through a callback', (done) => {readValue((error, value) => {try {expect(error).toBeNull();expect(value).toBe('ready');done();} catch (assertionError) {done(assertionError);}});});
Do not mix a returned Promise and done in the same test; runners generally treat that as ambiguous or erroneous.
Further reading
- Vitest: Testing asynchronous code
- Vitest: Mocking timers
- Jest: Testing asynchronous code
- Mocha: Asynchronous code