Test Promises, async/await functions, and rejected errors correctly in Jest, avoiding the silent false-positive trap where forgotten awaits let failing tests appear green.
Modern JavaScript is asynchronous by default. API calls, database queries, file reads, timers — they all return Promises. Testing async code correctly requires understanding one critical trap: Jest does not know your test is async unless you tell it. If you forget to await an assertion, Jest marks the test as passed before the Promise settles, giving you a false green that hides real bugs.
This lesson covers every pattern you need for reliable async testing: async/await in test bodies, the .resolves and .rejects matchers, testing rejection errors, and fake timers for eliminating real delays. Mastering these patterns is non-negotiable for any codebase that touches the network, the database, or the filesystem.
The most dangerous async testing mistake is a test that appears to pass but never actually runs its assertions:
1// src/user-api.ts
2export async function fetchUser(id: string): Promise<{ name: string }> {
3 const response = await fetch(`/api/users/${id}`);
4 if (!response.ok) throw new Error(`User ${id} not found`);
5 return response.json();
6}
7
8// DANGEROUS: forgotten await — test always passes!
9test('fetches user by id', () => { // <-- no async keyword
10 expect(fetchUser('1')).resolves.toEqual({ name: 'Alice' });
11 // This line returns a Promise. Jest does NOT wait for it.
12 // The test ends immediately. No assertion ever runs. GREEN. BUG HIDDEN.
13});
14
15// SAFE: async keyword + return OR await
16test('fetches user by id', async () => {
17 await expect(fetchUser('1')).resolves.toEqual({ name: 'Alice' });
18 // ^^^^^ always await .resolves/.rejects assertions
19});
20
21// ALSO SAFE: return the promise (older style)
22test('fetches user by id', () => {
23 return expect(fetchUser('1')).resolves.toEqual({ name: 'Alice' });
24 // ^^^^^^ returning the promise forces Jest to wait
25});The safest rule: always use async/await in test functions. The async keyword serves as documentation that the test contains asynchronous code. Configure ESLint with jest/valid-expect-in-promise to catch forgotten returns and awaits automatically.
The cleanest pattern for testing async code is treating the test body as an async function. Jest handles the returned Promise automatically.
1import { fetchUser, createUser, deleteUser } from './user-api';
2
3// Mock fetch globally
4global.fetch = jest.fn();
5
6beforeEach(() => {
7 jest.clearAllMocks();
8});
9
10test('fetchUser returns parsed user data on success', async () => {
11 // Arrange
12 const mockUser = { id: '1', name: 'Alice', email: '[email protected]' };
13 (global.fetch as jest.Mock).mockResolvedValueOnce({
14 ok: true,
15 json: async () => mockUser,
16 });
17
18 // Act
19 const result = await fetchUser('1');
20
21 // Assert
22 expect(result).toEqual(mockUser);
23 expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
24});
25
26test('fetchUser throws when response is not ok', async () => {
27 // Arrange
28 (global.fetch as jest.Mock).mockResolvedValueOnce({
29 ok: false,
30 status: 404,
31 });
32
33 // Act + Assert — await the rejection
34 await expect(fetchUser('999')).rejects.toThrow('User 999 not found');
35});
36
37test('createUser sends POST and returns created user', async () => {
38 const newUser = { name: 'Bob', email: '[email protected]' };
39 const created = { id: '2', ...newUser };
40
41 (global.fetch as jest.Mock).mockResolvedValueOnce({
42 ok: true,
43 json: async () => created,
44 });
45
46 const result = await createUser(newUser);
47
48 expect(result.id).toBe('2');
49 expect(global.fetch).toHaveBeenCalledWith(
50 '/api/users',
51 expect.objectContaining({
52 method: 'POST',
53 body: JSON.stringify(newUser),
54 })
55 );
56});Jest provides .resolves and .rejects as chainable matchers that unwrap Promises before applying the assertion. They produce cleaner error messages than try/catch blocks.
1// src/order-service.ts
2export async function processOrder(orderId: string): Promise<{ status: string; total: number }> {
3 if (!orderId.startsWith('ORD-')) {
4 return Promise.reject(new Error(`Invalid order ID format: ${orderId}`));
5 }
6 // ... real logic
7 return Promise.resolve({ status: 'confirmed', total: 99.99 });
8}
9
10// src/order-service.test.ts
11import { processOrder } from './order-service';
12
13test('resolves with confirmed status for valid order', async () => {
14 await expect(processOrder('ORD-001')).resolves.toMatchObject({
15 status: 'confirmed',
16 total: expect.any(Number),
17 });
18});
19
20test('resolves with the correct total amount', async () => {
21 await expect(processOrder('ORD-001')).resolves.toHaveProperty('total', 99.99);
22});
23
24test('rejects with an Error for invalid order ID format', async () => {
25 await expect(processOrder('INVALID-001')).rejects.toThrow(
26 'Invalid order ID format: INVALID-001'
27 );
28});
29
30test('rejects with an Error instance (not just any rejection)', async () => {
31 await expect(processOrder('BAD')).rejects.toBeInstanceOf(Error);
32});
33
34// Alternative: try/catch style — useful when you need to inspect the error
35test('error has the order id in its message', async () => {
36 try {
37 await processOrder('BAD-FORMAT');
38 // If we reach this line, the function did NOT reject — fail the test explicitly
39 throw new Error('Expected processOrder to reject');
40 } catch (error) {
41 expect(error).toBeInstanceOf(Error);
42 expect((error as Error).message).toContain('BAD-FORMAT');
43 }
44});Real timers make tests slow and non-deterministic. Jest's fake timer system replaces setTimeout, setInterval, Date, and other time APIs with synchronous mocks you control.
1// src/debounce.ts
2export function debounce<T extends (...args: unknown[]) => void>(
3 fn: T,
4 delayMs: number
5): (...args: Parameters<T>) => void {
6 let timer: ReturnType<typeof setTimeout> | null = null;
7 return (...args: Parameters<T>) => {
8 if (timer) clearTimeout(timer);
9 timer = setTimeout(() => fn(...args), delayMs);
10 };
11}
12
13// src/debounce.test.ts
14import { debounce } from './debounce';
15
16beforeEach(() => {
17 jest.useFakeTimers();
18});
19
20afterEach(() => {
21 jest.useRealTimers();
22});
23
24test('does not call fn before the delay has elapsed', () => {
25 const fn = jest.fn();
26 const debounced = debounce(fn, 300);
27
28 debounced('hello');
29 jest.advanceTimersByTime(299); // advance 299 ms — not enough
30
31 expect(fn).not.toHaveBeenCalled();
32});
33
34test('calls fn exactly once after the delay', () => {
35 const fn = jest.fn();
36 const debounced = debounce(fn, 300);
37
38 debounced('hello');
39 jest.advanceTimersByTime(300); // exactly the delay
40
41 expect(fn).toHaveBeenCalledTimes(1);
42 expect(fn).toHaveBeenCalledWith('hello');
43});
44
45test('resets the timer when called multiple times within the delay', () => {
46 const fn = jest.fn();
47 const debounced = debounce(fn, 300);
48
49 debounced('first');
50 jest.advanceTimersByTime(200);
51 debounced('second'); // resets the timer
52 jest.advanceTimersByTime(300); // 300 ms after second call
53
54 expect(fn).toHaveBeenCalledTimes(1);
55 expect(fn).toHaveBeenCalledWith('second');
56});
57
58// For async timers (Node 18+):
59test('async timer test', async () => {
60 const fn = jest.fn();
61 const debounced = debounce(fn, 500);
62 debounced('test');
63 await jest.advanceTimersByTimeAsync(500);
64 expect(fn).toHaveBeenCalledTimes(1);
65});When Jest prints A worker process has failed to exit gracefully or open handles, it means your async code left something running after the test finished. Common culprits: unclosed database connections, unresolved Promises, or setInterval calls.
1// PROBLEMATIC: setInterval never cleared
2test('polls every second', () => {
3 const poll = jest.fn();
4 const id = setInterval(poll, 1000); // never cleared!
5 jest.advanceTimersByTime(3000);
6 expect(poll).toHaveBeenCalledTimes(3);
7 // Test ends, interval still running → open handle warning
8});
9
10// FIXED: clear the interval in the test
11test('polls every second', () => {
12 jest.useFakeTimers();
13 const poll = jest.fn();
14 const id = setInterval(poll, 1000);
15 jest.advanceTimersByTime(3000);
16 expect(poll).toHaveBeenCalledTimes(3);
17 clearInterval(id); // explicit cleanup
18 jest.useRealTimers();
19});
20
21// PATTERN: Use afterEach for consistent cleanup
22describe('PollingService', () => {
23 let service: PollingService;
24
25 beforeEach(() => {
26 jest.useFakeTimers();
27 service = new PollingService();
28 });
29
30 afterEach(() => {
31 service.stop(); // calls clearInterval internally
32 jest.useRealTimers();
33 });
34
35 test('triggers callback every 5 seconds', () => {
36 const callback = jest.fn();
37 service.start(callback, 5000);
38 jest.advanceTimersByTime(15000);
39 expect(callback).toHaveBeenCalledTimes(3);
40 });
41});
42
43// For database connections in integration tests:
44afterAll(async () => {
45 await dataSource.destroy(); // TypeORM
46 await prisma.$disconnect(); // Prisma
47 await mongoose.connection.close(); // Mongoose
48});Run jest --detectOpenHandles to get detailed stack traces pointing to the exact code that left handles open.
An async notification service that sends emails and schedules retries, tested with async/await, .resolves/.rejects, and fake timers.
1// src/notification-service.ts
2export interface Notification {
3 to: string;
4 subject: string;
5 body: string;
6}
7
8export interface EmailProvider {
9 send(notification: Notification): Promise<{ messageId: string }>;
10}
11
12export class NotificationService {
13 private retryQueue: Array<{ notification: Notification; attempts: number }> = [];
14
15 constructor(private readonly emailProvider: EmailProvider) {}
16
17 async send(notification: Notification): Promise<{ messageId: string }> {
18 if (!notification.to.includes('@')) {
19 return Promise.reject(new Error(`Invalid email address: ${notification.to}`));
20 }
21 return this.emailProvider.send(notification);
22 }
23
24 async sendWithRetry(
25 notification: Notification,
26 maxAttempts = 3
27 ): Promise<{ messageId: string }> {
28 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
29 try {
30 return await this.emailProvider.send(notification);
31 } catch (error) {
32 if (attempt === maxAttempts) throw error;
33 await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
34 }
35 }
36 throw new Error('Unreachable');
37 }
38
39 getRetryQueueLength(): number {
40 return this.retryQueue.length;
41 }
42}
43
44// src/notification-service.test.ts
45import { NotificationService, EmailProvider, Notification } from './notification-service';
46
47const validNotification: Notification = {
48 to: '[email protected]',
49 subject: 'Welcome!',
50 body: 'Thanks for signing up.',
51};
52
53function buildMockProvider(overrides: Partial<EmailProvider> = {}): EmailProvider {
54 return {
55 send: jest.fn().mockResolvedValue({ messageId: 'msg-001' }),
56 ...overrides,
57 };
58}
59
60describe('NotificationService', () => {
61 describe('send', () => {
62 it('resolves with a messageId when email is valid', async () => {
63 const provider = buildMockProvider();
64 const service = new NotificationService(provider);
65
66 await expect(service.send(validNotification)).resolves.toEqual({
67 messageId: 'msg-001',
68 });
69 });
70
71 it('calls the email provider with the notification', async () => {
72 const provider = buildMockProvider();
73 const service = new NotificationService(provider);
74
75 await service.send(validNotification);
76
77 expect(provider.send).toHaveBeenCalledWith(validNotification);
78 });
79
80 it('rejects with an error when email address is invalid', async () => {
81 const provider = buildMockProvider();
82 const service = new NotificationService(provider);
83
84 await expect(
85 service.send({ ...validNotification, to: 'not-an-email' })
86 ).rejects.toThrow('Invalid email address: not-an-email');
87 });
88
89 it('propagates provider errors to the caller', async () => {
90 const provider = buildMockProvider({
91 send: jest.fn().mockRejectedValue(new Error('SMTP connection failed')),
92 });
93 const service = new NotificationService(provider);
94
95 await expect(service.send(validNotification)).rejects.toThrow(
96 'SMTP connection failed'
97 );
98 });
99 });
100
101 describe('sendWithRetry', () => {
102 beforeEach(() => jest.useFakeTimers());
103 afterEach(() => jest.useRealTimers());
104
105 it('returns on the first attempt when provider succeeds', async () => {
106 const provider = buildMockProvider();
107 const service = new NotificationService(provider);
108
109 const promise = service.sendWithRetry(validNotification);
110 await jest.runAllTimersAsync();
111 const result = await promise;
112
113 expect(provider.send).toHaveBeenCalledTimes(1);
114 expect(result).toEqual({ messageId: 'msg-001' });
115 });
116
117 it('retries after failure and succeeds on second attempt', async () => {
118 const provider = buildMockProvider({
119 send: jest.fn()
120 .mockRejectedValueOnce(new Error('Temporary failure'))
121 .mockResolvedValueOnce({ messageId: 'msg-002' }),
122 });
123 const service = new NotificationService(provider);
124
125 const promise = service.sendWithRetry(validNotification);
126 await jest.runAllTimersAsync();
127 const result = await promise;
128
129 expect(provider.send).toHaveBeenCalledTimes(2);
130 expect(result).toEqual({ messageId: 'msg-002' });
131 });
132
133 it('throws after exhausting all retry attempts', async () => {
134 const provider = buildMockProvider({
135 send: jest.fn().mockRejectedValue(new Error('Persistent failure')),
136 });
137 const service = new NotificationService(provider);
138
139 const promise = service.sendWithRetry(validNotification, 3);
140 await jest.runAllTimersAsync();
141
142 await expect(promise).rejects.toThrow('Persistent failure');
143 expect(provider.send).toHaveBeenCalledTimes(3);
144 });
145 });
146});This example covers: (1) .resolves/.rejects for clean async assertions without try/catch, (2) jest.fn().mockResolvedValue and mockRejectedValue for faking async provider behaviour, (3) fake timers with jest.runAllTimersAsync() for testing retry delays without real waits, (4) mockRejectedValueOnce chained with mockResolvedValueOnce to simulate a failure-then-success retry scenario. Notice beforeEach/afterEach symmetry for fake timer setup and teardown to prevent timer state leaking between tests.
The next lesson introduces mocking: jest.fn() for creating mock functions, jest.spyOn() for wrapping real methods, and the difference between mocks, stubs, and spies in the context of isolating dependencies.
Your progress is saved in this browser, on this device only.