Intercept HTTP calls to third-party APIs in integration tests using Mock Service Worker (MSW) and nock, keeping tests fast and deterministic without real network calls.
Production applications call external services — Stripe for payments, SendGrid for email, AWS S3 for storage, Twilio for SMS. Testing with real external services is slow, costs money, and fails when services are down. But testing with jest.mock() at the module level loses the HTTP layer — you never verify that your code constructs the right request URL, sets the correct headers, or handles rate-limit responses.
MSW (Mock Service Worker) and nock intercept HTTP calls at the network level. Your code runs exactly as it would in production — it makes a real HTTP request — but the request is caught before leaving the process and a controlled response is returned. This gives you the speed and reliability of mocks with the realism of real HTTP handling.
MSW v2 uses a server instance for Node.js tests that intercepts both fetch and Node http module calls.
1npm install --save-dev msw1// src/test/msw-server.ts
2import { setupServer } from 'msw/node';
3import { http, HttpResponse } from 'msw';
4
5// Default handlers — always active unless overridden
6export const defaultHandlers = [
7 // Stripe payment intent
8 http.post('https://api.stripe.com/v1/payment_intents', () => {
9 return HttpResponse.json({
10 id: 'pi_test_001',
11 status: 'succeeded',
12 amount: 4999,
13 currency: 'usd',
14 });
15 }),
16
17 // SendGrid email
18 http.post('https://api.sendgrid.com/v3/mail/send', () => {
19 return new HttpResponse(null, { status: 202 });
20 }),
21
22 // GitHub OAuth
23 http.post('https://github.com/login/oauth/access_token', () => {
24 return HttpResponse.json({ access_token: 'gho_test_token', scope: 'user:email' });
25 }),
26
27 http.get('https://api.github.com/user', () => {
28 return HttpResponse.json({ id: 12345, login: 'testuser', email: '[email protected]' });
29 }),
30];
31
32export const server = setupServer(...defaultHandlers);
33
34// src/test/setup.ts — runs before all integration tests
35import { server } from './msw-server';
36
37beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
38afterEach(() => server.resetHandlers()); // remove per-test overrides
39afterAll(() => server.close());Override handlers in individual tests to simulate error responses, network failures, or service-specific edge cases.
1import { http, HttpResponse } from 'msw';
2import { server } from '../test/msw-server';
3import request from 'supertest';
4import { app } from '../app';
5
6describe('PaymentService integration', () => {
7 it('processes payment successfully via Stripe', async () => {
8 // Uses default handler — Stripe returns pi_test_001
9 const res = await request(app)
10 .post('/payments/checkout')
11 .set('Authorization', `Bearer ${validToken}`)
12 .send({ amount: 4999, currency: 'usd', cardToken: 'tok_visa' });
13
14 expect(res.status).toBe(200);
15 expect(res.body.chargeId).toBe('pi_test_001');
16 });
17
18 it('returns 402 when Stripe declines the card', async () => {
19 server.use(
20 http.post('https://api.stripe.com/v1/payment_intents', () => {
21 return HttpResponse.json(
22 {
23 error: {
24 type: 'card_error',
25 code: 'card_declined',
26 message: 'Your card was declined.',
27 },
28 },
29 { status: 402 }
30 );
31 })
32 );
33
34 const res = await request(app)
35 .post('/payments/checkout')
36 .set('Authorization', `Bearer ${validToken}`)
37 .send({ amount: 4999, currency: 'usd', cardToken: 'tok_chargeDeclined' });
38
39 expect(res.status).toBe(402);
40 expect(res.body.error).toContain('declined');
41 });
42
43 it('returns 503 when Stripe is unavailable', async () => {
44 server.use(
45 http.post('https://api.stripe.com/v1/payment_intents', () => {
46 return HttpResponse.error(); // simulates network failure
47 })
48 );
49
50 const res = await request(app)
51 .post('/payments/checkout')
52 .set('Authorization', `Bearer ${validToken}`)
53 .send({ amount: 4999, currency: 'usd', cardToken: 'tok_visa' });
54
55 expect(res.status).toBe(503);
56 expect(res.body.error).toContain('payment service unavailable');
57 });
58
59 it('handles Stripe rate limiting (429) with retry-after header', async () => {
60 server.use(
61 http.post('https://api.stripe.com/v1/payment_intents', () => {
62 return new HttpResponse(JSON.stringify({ error: { type: 'rate_limit_error' } }), {
63 status: 429,
64 headers: { 'Retry-After': '30' },
65 });
66 })
67 );
68
69 const res = await request(app)
70 .post('/payments/checkout')
71 .set('Authorization', `Bearer ${validToken}`)
72 .send({ amount: 4999, currency: 'usd', cardToken: 'tok_visa' });
73
74 expect(res.status).toBe(429);
75 });
76});nock intercepts Node.js built-in http/https module calls. It is useful for libraries that use the http module directly rather than fetch.
1npm install --save-dev nock1import nock from 'nock';
2import { sendWelcomeEmail } from '../services/email.service';
3
4beforeEach(() => {
5 nock.cleanAll();
6});
7
8afterAll(() => {
9 nock.restore();
10});
11
12describe('EmailService', () => {
13 it('sends POST to SendGrid with correct body', async () => {
14 const scope = nock('https://api.sendgrid.com')
15 .post('/v3/mail/send', (body) => {
16 // Assert on request body inside the interceptor
17 expect(body.to[0].email).toBe('[email protected]');
18 expect(body.subject).toBe('Welcome to DeveloperBootstrap!');
19 return true; // return true to match the request
20 })
21 .reply(202, { message_id: 'sg_test_001' });
22
23 await sendWelcomeEmail('[email protected]', 'Alice');
24
25 expect(scope.isDone()).toBe(true); // verify the request was made
26 });
27
28 it('throws EmailServiceError when SendGrid returns 500', async () => {
29 nock('https://api.sendgrid.com')
30 .post('/v3/mail/send')
31 .reply(500, { errors: [{ message: 'Internal server error' }] });
32
33 await expect(sendWelcomeEmail('[email protected]', 'Alice'))
34 .rejects
35 .toThrow('EmailServiceError');
36 });
37
38 it('sets the Authorization header with the API key', async () => {
39 nock('https://api.sendgrid.com', {
40 reqheaders: {
41 authorization: `Bearer ${process.env['SENDGRID_API_KEY']}`,
42 },
43 })
44 .post('/v3/mail/send')
45 .reply(202);
46
47 await expect(sendWelcomeEmail('[email protected]', 'Alice')).resolves.not.toThrow();
48 });
49});Webhooks arrive as inbound HTTP requests from external services. Integration tests verify that webhook handlers correctly validate signatures, process payloads, and update the database.
1import crypto from 'crypto';
2
3const STRIPE_WEBHOOK_SECRET = 'whsec_test_secret';
4
5function generateStripeSignature(payload: string, secret: string): string {
6 const timestamp = Math.floor(Date.now() / 1000);
7 const signedPayload = `${timestamp}.${payload}`;
8 const sig = crypto
9 .createHmac('sha256', secret)
10 .update(signedPayload)
11 .digest('hex');
12 return `t=${timestamp},v1=${sig}`;
13}
14
15describe('POST /webhooks/stripe', () => {
16 it('processes checkout.session.completed and enrols user', async () => {
17 const payload = JSON.stringify({
18 type: 'checkout.session.completed',
19 data: {
20 object: {
21 id: 'cs_test_001',
22 payment_status: 'paid',
23 metadata: { userId: 'user-001', courseId: 'course-001' },
24 },
25 },
26 });
27
28 const sig = generateStripeSignature(payload, STRIPE_WEBHOOK_SECRET);
29
30 const res = await request(app)
31 .post('/webhooks/stripe')
32 .set('stripe-signature', sig)
33 .set('Content-Type', 'application/json')
34 .send(payload);
35
36 expect(res.status).toBe(200);
37
38 // Verify database side effect
39 const prisma = getTestPrisma();
40 const enrollment = await prisma.enrollment.findUnique({
41 where: { userId_courseId: { userId: 'user-001', courseId: 'course-001' } },
42 });
43 expect(enrollment).toBeDefined();
44 });
45
46 it('returns 400 when Stripe signature is invalid', async () => {
47 const res = await request(app)
48 .post('/webhooks/stripe')
49 .set('stripe-signature', 'invalid-sig')
50 .send(JSON.stringify({ type: 'checkout.session.completed' }));
51
52 expect(res.status).toBe(400);
53 expect(res.body.error).toContain('signature');
54 });
55});Sometimes you need to verify not just that an external API was called, but exactly what was sent — the URL, headers, and body structure.
1import { http, HttpResponse } from 'msw';
2import { server } from '../test/msw-server';
3
4describe('GitHub OAuth flow', () => {
5 it('exchanges code for token with correct client credentials', async () => {
6 let capturedRequest: Request | null = null;
7
8 server.use(
9 http.post('https://github.com/login/oauth/access_token', ({ request }) => {
10 capturedRequest = request; // capture the request for assertion
11 return HttpResponse.json({ access_token: 'gho_test' });
12 })
13 );
14
15 await request(app)
16 .get('/auth/github/callback?code=test-code')
17 .expect(302); // redirect after OAuth
18
19 // Assert on the captured outbound request
20 expect(capturedRequest).not.toBeNull();
21 const body = await capturedRequest!.text();
22 const params = new URLSearchParams(body);
23
24 expect(params.get('client_id')).toBe(process.env['GITHUB_CLIENT_ID']);
25 expect(params.get('client_secret')).toBe(process.env['GITHUB_CLIENT_SECRET']);
26 expect(params.get('code')).toBe('test-code');
27 });
28
29 it('uses Accept: application/json header in GitHub API calls', async () => {
30 let capturedHeaders: Headers | null = null;
31
32 server.use(
33 http.get('https://api.github.com/user', ({ request }) => {
34 capturedHeaders = request.headers;
35 return HttpResponse.json({ id: 1, login: 'alice' });
36 })
37 );
38
39 await request(app)
40 .get('/auth/github/callback?code=test-code')
41 .expect(302);
42
43 expect(capturedHeaders?.get('accept')).toBe('application/json');
44 expect(capturedHeaders?.get('authorization')).toMatch(/^token /);
45 });
46});A notification service that sends emails via SendGrid and SMS via Twilio, tested with MSW interceptors covering success, failure, and retry scenarios.
1// src/services/notification.service.ts
2export interface NotificationPayload {
3 userId: string;
4 type: 'welcome' | 'password-reset' | 'enrollment-confirmed';
5 data: Record<string, string>;
6}
7
8export class NotificationService {
9 async send(payload: NotificationPayload): Promise<void> {
10 const user = await this.fetchUser(payload.userId);
11 await this.sendEmail(user.email, payload.type, payload.data);
12 if (user.phone && payload.type !== 'welcome') {
13 await this.sendSms(user.phone, payload.type, payload.data);
14 }
15 }
16
17 private async fetchUser(userId: string) {
18 const res = await fetch(`${process.env['API_URL']}/users/${userId}`);
19 if (!res.ok) throw new Error(`User ${userId} not found`);
20 return res.json() as Promise<{ email: string; phone?: string; name: string }>;
21 }
22
23 private async sendEmail(to: string, type: string, data: Record<string, string>) {
24 const res = await fetch('https://api.sendgrid.com/v3/mail/send', {
25 method: 'POST',
26 headers: {
27 Authorization: `Bearer ${process.env['SENDGRID_API_KEY']}`,
28 'Content-Type': 'application/json',
29 },
30 body: JSON.stringify({ to: [{ email: to }], templateId: `tmpl-${type}`, dynamicTemplateData: data }),
31 });
32 if (!res.ok) throw new Error(`Email send failed: ${res.status}`);
33 }
34
35 private async sendSms(phone: string, type: string, data: Record<string, string>) {
36 const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${process.env['TWILIO_SID']}/Messages.json`, {
37 method: 'POST',
38 headers: { Authorization: `Basic ${Buffer.from(`${process.env['TWILIO_SID']}:${process.env['TWILIO_TOKEN']}`).toString('base64')}` },
39 body: new URLSearchParams({ To: phone, From: process.env['TWILIO_FROM']!, Body: `${type}: ${JSON.stringify(data)}` }),
40 });
41 if (!res.ok) throw new Error(`SMS send failed: ${res.status}`);
42 }
43}
44
45// src/services/notification.service.integration.test.ts
46import { http, HttpResponse } from 'msw';
47import { server } from '../test/msw-server';
48import { NotificationService } from './notification.service';
49
50const service = new NotificationService();
51
52beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
53afterEach(() => server.resetHandlers());
54afterAll(() => server.close());
55
56const baseHandlers = [
57 http.get(`${process.env['API_URL']}/users/user-001`, () =>
58 HttpResponse.json({ email: '[email protected]', name: 'Alice', phone: '+1234567890' })
59 ),
60 http.post('https://api.sendgrid.com/v3/mail/send', () => new HttpResponse(null, { status: 202 })),
61 http.post(/twilio\.com/, () => HttpResponse.json({ sid: 'SM_test' })),
62];
63
64beforeEach(() => server.use(...baseHandlers));
65
66describe('NotificationService', () => {
67 it('sends email and SMS for enrollment-confirmed to user with phone', async () => {
68 const emailSpy = jest.fn();
69 const smsSpy = jest.fn();
70
71 server.use(
72 http.post('https://api.sendgrid.com/v3/mail/send', ({ request }) => {
73 emailSpy(request);
74 return new HttpResponse(null, { status: 202 });
75 }),
76 http.post(/twilio\.com/, ({ request }) => {
77 smsSpy(request);
78 return HttpResponse.json({ sid: 'SM_001' });
79 })
80 );
81
82 await service.send({ userId: 'user-001', type: 'enrollment-confirmed', data: { courseTitle: 'React Mastery' } });
83
84 expect(emailSpy).toHaveBeenCalledTimes(1);
85 expect(smsSpy).toHaveBeenCalledTimes(1);
86 });
87
88 it('sends only email for welcome type (no SMS)', async () => {
89 const smsSpy = jest.fn();
90 server.use(http.post(/twilio\.com/, () => { smsSpy(); return HttpResponse.json({}); }));
91
92 await service.send({ userId: 'user-001', type: 'welcome', data: { name: 'Alice' } });
93
94 expect(smsSpy).not.toHaveBeenCalled();
95 });
96
97 it('throws when user API returns 404', async () => {
98 server.use(
99 http.get(`${process.env['API_URL']}/users/unknown`, () =>
100 new HttpResponse(null, { status: 404 })
101 )
102 );
103
104 await expect(
105 service.send({ userId: 'unknown', type: 'welcome', data: {} })
106 ).rejects.toThrow('User unknown not found');
107 });
108
109 it('throws when SendGrid returns 500', async () => {
110 server.use(
111 http.post('https://api.sendgrid.com/v3/mail/send', () =>
112 new HttpResponse(null, { status: 500 })
113 )
114 );
115
116 await expect(
117 service.send({ userId: 'user-001', type: 'welcome', data: {} })
118 ).rejects.toThrow('Email send failed: 500');
119 });
120});The MSW server intercepts all outbound fetch calls without modifying the service implementation. baseHandlers in beforeEach sets up the happy-path state; individual tests override specific handlers with server.use() for error scenarios. server.resetHandlers() in afterEach restores the base handlers, preventing test pollution. The smsSpy pattern captures whether a specific external service was called without asserting on implementation details of how the request was built.
The next lesson covers testing component interactions: verifying that two real modules exchange the correct data types and that service-level contracts are upheld when modules are wired together.
Your progress is saved in this browser, on this device only.