Use Supertest to send real HTTP requests to Express applications in tests, assert on status codes, response headers, JSON bodies, and test error handling without starting a real server.
Supertest is a Node.js library that lets you make HTTP requests to an Express (or any Node.js HTTP) application in tests without binding to a real port. It wraps the app in a temporary test server, sends the request, waits for the response, and shuts down — all within a single test. The result is fast, reliable HTTP testing that exercises your real routing, middleware, and error handling.
This lesson covers everything you need to test a REST API with Supertest: all HTTP methods, chaining assertions on the response, testing error paths, and setting authentication headers. These patterns work for Express, Fastify, Hapi, and any other Node.js HTTP framework.
Supertest wraps your Express app and returns a test agent. Chain .expect() calls to assert on the response.
1import request from 'supertest';
2import express from 'express';
3
4// Build the app (no app.listen — Supertest handles the server lifecycle)
5const app = express();
6app.use(express.json());
7
8app.get('/health', (req, res) => {
9 res.json({ status: 'ok', version: '1.0.0' });
10});
11
12app.get('/users/:id', (req, res) => {
13 if (req.params.id === '1') {
14 return res.json({ id: '1', name: 'Alice', email: '[email protected]' });
15 }
16 return res.status(404).json({ error: 'User not found' });
17});
18
19app.post('/users', (req, res) => {
20 const { name, email } = req.body;
21 if (!name || !email) {
22 return res.status(400).json({ error: 'name and email are required' });
23 }
24 return res.status(201).json({ id: '2', name, email });
25});
26
27// Tests
28describe('GET /health', () => {
29 it('returns 200 with status ok', async () => {
30 const res = await request(app).get('/health');
31
32 expect(res.status).toBe(200);
33 expect(res.body.status).toBe('ok');
34 expect(res.body.version).toBeDefined();
35 });
36});
37
38describe('GET /users/:id', () => {
39 it('returns 200 with user data for a known id', async () => {
40 const res = await request(app).get('/users/1');
41
42 expect(res.status).toBe(200);
43 expect(res.body).toMatchObject({ id: '1', name: 'Alice' });
44 expect(res.headers['content-type']).toMatch(/json/);
45 });
46
47 it('returns 404 for an unknown id', async () => {
48 const res = await request(app).get('/users/999');
49
50 expect(res.status).toBe(404);
51 expect(res.body.error).toBe('User not found');
52 });
53});
54
55describe('POST /users', () => {
56 it('returns 201 with created user', async () => {
57 const res = await request(app)
58 .post('/users')
59 .send({ name: 'Bob', email: '[email protected]' });
60
61 expect(res.status).toBe(201);
62 expect(res.body).toMatchObject({ name: 'Bob', email: '[email protected]' });
63 expect(res.body.id).toBeDefined();
64 });
65
66 it('returns 400 when name is missing', async () => {
67 const res = await request(app)
68 .post('/users')
69 .send({ email: '[email protected]' });
70
71 expect(res.status).toBe(400);
72 expect(res.body.error).toContain('name');
73 });
74});Use .set() to add request headers — essential for testing endpoints that require authentication tokens or specific content types.
1import jwt from 'jsonwebtoken';
2
3const SECRET = 'test-secret';
4
5function generateTestToken(userId: string, role = 'user'): string {
6 return jwt.sign({ userId, role }, SECRET, { expiresIn: '1h' });
7}
8
9// Middleware that protects routes
10function authMiddleware(req: any, res: any, next: any) {
11 const authHeader = req.headers['authorization'];
12 if (!authHeader?.startsWith('Bearer ')) {
13 return res.status(401).json({ error: 'Missing or invalid token' });
14 }
15 try {
16 const payload = jwt.verify(authHeader.slice(7), SECRET);
17 req.user = payload;
18 next();
19 } catch {
20 return res.status(401).json({ error: 'Invalid token' });
21 }
22}
23
24app.get('/profile', authMiddleware, (req: any, res) => {
25 res.json({ userId: req.user.userId, role: req.user.role });
26});
27
28// Tests
29describe('GET /profile', () => {
30 it('returns 401 when no Authorization header is sent', async () => {
31 const res = await request(app).get('/profile');
32 expect(res.status).toBe(401);
33 expect(res.body.error).toContain('Missing');
34 });
35
36 it('returns 401 for an expired or tampered token', async () => {
37 const res = await request(app)
38 .get('/profile')
39 .set('Authorization', 'Bearer invalid.token.here');
40 expect(res.status).toBe(401);
41 });
42
43 it('returns 200 with user data for a valid token', async () => {
44 const token = generateTestToken('user-001', 'admin');
45 const res = await request(app)
46 .get('/profile')
47 .set('Authorization', `Bearer ${token}`);
48
49 expect(res.status).toBe(200);
50 expect(res.body.userId).toBe('user-001');
51 expect(res.body.role).toBe('admin');
52 });
53});
54
55// Helper for authenticated requests (reusable across tests)
56const authedRequest = (app: any, token: string) => ({
57 get: (url: string) => request(app).get(url).set('Authorization', `Bearer ${token}`),
58 post: (url: string) => request(app).post(url).set('Authorization', `Bearer ${token}`),
59 put: (url: string) => request(app).put(url).set('Authorization', `Bearer ${token}`),
60 delete: (url: string) => request(app).delete(url).set('Authorization', `Bearer ${token}`),
61});All HTTP methods work identically in Supertest. The request body and expected response shape differ, but the API is uniform.
1describe('PUT /users/:id', () => {
2 it('replaces the user resource and returns 200', async () => {
3 const token = generateTestToken('user-001', 'admin');
4 const res = await request(app)
5 .put('/users/1')
6 .set('Authorization', `Bearer ${token}`)
7 .send({ name: 'Alice Updated', email: '[email protected]' });
8
9 expect(res.status).toBe(200);
10 expect(res.body.name).toBe('Alice Updated');
11 });
12
13 it('returns 404 when the user does not exist', async () => {
14 const token = generateTestToken('user-001', 'admin');
15 const res = await request(app)
16 .put('/users/nonexistent')
17 .set('Authorization', `Bearer ${token}`)
18 .send({ name: 'Ghost', email: '[email protected]' });
19
20 expect(res.status).toBe(404);
21 });
22});
23
24describe('PATCH /users/:id', () => {
25 it('partially updates the user and returns 200', async () => {
26 const token = generateTestToken('user-001');
27 const res = await request(app)
28 .patch('/users/1')
29 .set('Authorization', `Bearer ${token}`)
30 .send({ name: 'Alice Patched' });
31
32 expect(res.status).toBe(200);
33 expect(res.body.name).toBe('Alice Patched');
34 expect(res.body.email).toBe('[email protected]'); // email unchanged
35 });
36});
37
38describe('DELETE /users/:id', () => {
39 it('deletes the user and returns 204 with no body', async () => {
40 const token = generateTestToken('user-001', 'admin');
41 const res = await request(app)
42 .delete('/users/1')
43 .set('Authorization', `Bearer ${token}`);
44
45 expect(res.status).toBe(204);
46 expect(res.body).toEqual({});
47 });
48
49 it('returns 403 when a non-admin tries to delete', async () => {
50 const token = generateTestToken('user-002', 'student'); // not admin
51 const res = await request(app)
52 .delete('/users/1')
53 .set('Authorization', `Bearer ${token}`);
54
55 expect(res.status).toBe(403);
56 });
57});Production APIs must return consistent error shapes. Integration tests are the best place to verify that error middleware, validation, and exception handlers produce the right HTTP responses.
1// Global error handler in Express
2app.use((err: Error, req: any, res: any, next: any) => {
3 console.error(err);
4 res.status(500).json({
5 error: 'Internal server error',
6 // Never expose stack traces in production
7 });
8});
9
10// ZOD validation middleware
11import { z } from 'zod';
12
13const createUserSchema = z.object({
14 name: z.string().min(2).max(100),
15 email: z.string().email(),
16 role: z.enum(['student', 'instructor']).default('student'),
17});
18
19// Tests for error responses
20describe('Input validation errors', () => {
21 it('returns 422 with field errors for invalid input', async () => {
22 const res = await request(app)
23 .post('/users')
24 .send({ name: 'A', email: 'not-an-email' }); // fails both validations
25
26 expect(res.status).toBe(422);
27 expect(res.body.errors).toBeDefined();
28 expect(res.body.errors).toEqual(
29 expect.arrayContaining([
30 expect.objectContaining({ field: 'name' }),
31 expect.objectContaining({ field: 'email' }),
32 ])
33 );
34 });
35
36 it('returns 422 with specific message for invalid email format', async () => {
37 const res = await request(app)
38 .post('/users')
39 .send({ name: 'Alice Smith', email: 'not-an-email' });
40
41 expect(res.status).toBe(422);
42 const emailError = res.body.errors.find((e: any) => e.field === 'email');
43 expect(emailError).toBeDefined();
44 expect(emailError.message).toMatch(/email/);
45 });
46
47 it('returns 500 for unhandled server errors without stack trace', async () => {
48 // Inject error via a test route
49 app.get('/test-error', () => { throw new Error('Unhandled!'); });
50 const res = await request(app).get('/test-error');
51
52 expect(res.status).toBe(500);
53 expect(res.body.error).toBe('Internal server error');
54 expect(res.body.stack).toBeUndefined(); // security: no stack trace
55 });
56});For test files that send many authenticated requests, a reusable agent with pre-set headers reduces repetition.
1import request, { SuperTest, Test } from 'supertest';
2
3// Persistent agent — maintains cookies between requests (for session auth)
4const agent = request.agent(app);
5
6// Login once, then all subsequent agent requests include the session cookie
7beforeAll(async () => {
8 await agent
9 .post('/auth/login')
10 .send({ email: '[email protected]', password: 'AdminPass1!' })
11 .expect(200);
12});
13
14test('GET /admin/users uses session from login', async () => {
15 const res = await agent.get('/admin/users');
16 expect(res.status).toBe(200);
17});
18
19// Helper for Bearer token auth
20function apiClient(token: string) {
21 const base = request(app);
22 return {
23 get: (url: string) => base.get(url).set('Authorization', `Bearer ${token}`),
24 post: (url: string, body?: object) =>
25 base.post(url).set('Authorization', `Bearer ${token}`).send(body),
26 put: (url: string, body?: object) =>
27 base.put(url).set('Authorization', `Bearer ${token}`).send(body),
28 del: (url: string) => base.delete(url).set('Authorization', `Bearer ${token}`),
29 };
30}
31
32// Usage:
33test('admin can list all users', async () => {
34 const client = apiClient(generateTestToken('admin-1', 'admin'));
35 const res = await client.get('/admin/users');
36 expect(res.status).toBe(200);
37 expect(Array.isArray(res.body.data)).toBe(true);
38});
39
40test('student cannot access admin routes', async () => {
41 const client = apiClient(generateTestToken('student-1', 'student'));
42 const res = await client.get('/admin/users');
43 expect(res.status).toBe(403);
44});A complete Express product API tested with Supertest covering CRUD operations, authentication, pagination, and error responses.
1// src/app.ts
2import express from 'express';
3import { z } from 'zod';
4
5const products: Array<{ id: string; name: string; price: number; category: string }> = [
6 { id: '1', name: 'TypeScript Course', price: 49.99, category: 'programming' },
7 { id: '2', name: 'React Mastery', price: 59.99, category: 'frontend' },
8 { id: '3', name: 'Node.js Pro', price: 44.99, category: 'backend' },
9];
10
11const createProductSchema = z.object({
12 name: z.string().min(3),
13 price: z.number().positive(),
14 category: z.string().min(1),
15});
16
17export const app = express();
18app.use(express.json());
19
20app.get('/products', (req, res) => {
21 const { page = '1', limit = '10', category } = req.query as Record<string, string>;
22 let result = products;
23 if (category) result = result.filter(p => p.category === category);
24 const total = result.length;
25 const offset = (parseInt(page) - 1) * parseInt(limit);
26 const data = result.slice(offset, offset + parseInt(limit));
27 res.json({ data, total, page: parseInt(page), limit: parseInt(limit) });
28});
29
30app.get('/products/:id', (req, res) => {
31 const product = products.find(p => p.id === req.params.id);
32 if (!product) return res.status(404).json({ error: 'Product not found' });
33 return res.json(product);
34});
35
36app.post('/products', (req, res) => {
37 const result = createProductSchema.safeParse(req.body);
38 if (!result.success) {
39 return res.status(422).json({ errors: result.error.issues.map(i => ({ message: i.message })) });
40 }
41 const product = { id: String(products.length + 1), ...result.data };
42 products.push(product);
43 return res.status(201).json(product);
44});
45
46app.delete('/products/:id', (req, res) => {
47 const index = products.findIndex(p => p.id === req.params.id);
48 if (index === -1) return res.status(404).json({ error: 'Product not found' });
49 products.splice(index, 1);
50 return res.status(204).send();
51});
52
53// src/app.integration.test.ts
54import request from 'supertest';
55import { app } from './app';
56
57describe('Product Catalogue API', () => {
58 describe('GET /products', () => {
59 it('returns paginated product list with total count', async () => {
60 const res = await request(app).get('/products');
61 expect(res.status).toBe(200);
62 expect(res.body.data).toBeInstanceOf(Array);
63 expect(res.body.total).toBeDefined();
64 expect(res.body.page).toBe(1);
65 });
66
67 it('filters products by category', async () => {
68 const res = await request(app).get('/products?category=frontend');
69 expect(res.status).toBe(200);
70 expect(res.body.data.every((p: any) => p.category === 'frontend')).toBe(true);
71 });
72
73 it('respects limit and page parameters', async () => {
74 const res = await request(app).get('/products?page=1&limit=2');
75 expect(res.body.data).toHaveLength(2);
76 expect(res.body.limit).toBe(2);
77 });
78 });
79
80 describe('GET /products/:id', () => {
81 it('returns a product by id', async () => {
82 const res = await request(app).get('/products/1');
83 expect(res.status).toBe(200);
84 expect(res.body.id).toBe('1');
85 expect(res.body.name).toBe('TypeScript Course');
86 });
87
88 it('returns 404 for unknown id', async () => {
89 const res = await request(app).get('/products/999');
90 expect(res.status).toBe(404);
91 expect(res.body.error).toBe('Product not found');
92 });
93 });
94
95 describe('POST /products', () => {
96 it('creates a product and returns 201', async () => {
97 const res = await request(app)
98 .post('/products')
99 .send({ name: 'Vue Masterclass', price: 39.99, category: 'frontend' });
100
101 expect(res.status).toBe(201);
102 expect(res.body.id).toBeDefined();
103 expect(res.body.name).toBe('Vue Masterclass');
104 });
105
106 it('returns 422 when name is too short', async () => {
107 const res = await request(app)
108 .post('/products')
109 .send({ name: 'Xy', price: 10, category: 'misc' });
110
111 expect(res.status).toBe(422);
112 expect(res.body.errors).toBeInstanceOf(Array);
113 });
114
115 it('returns 422 when price is negative', async () => {
116 const res = await request(app)
117 .post('/products')
118 .send({ name: 'Free Course', price: -1, category: 'misc' });
119
120 expect(res.status).toBe(422);
121 });
122 });
123
124 describe('DELETE /products/:id', () => {
125 it('deletes a product and returns 204', async () => {
126 // Create one first so we have a known id to delete
127 const created = await request(app)
128 .post('/products')
129 .send({ name: 'Temp Course', price: 9.99, category: 'misc' });
130 const { id } = created.body;
131
132 const res = await request(app).delete(`/products/${id}`);
133 expect(res.status).toBe(204);
134
135 const check = await request(app).get(`/products/${id}`);
136 expect(check.status).toBe(404);
137 });
138 });
139});This suite covers the full CRUD surface of a REST API: list with pagination and filtering, get by id, create with Zod validation, and delete with follow-up verification. Each test uses a fresh request — Supertest does not maintain state between tests. The delete test chains two requests: one to create a product (to get a known id) and one to delete it, followed by a GET to verify it is gone. This end-to-end verification is only possible in an integration test.
The next lesson covers NestJS testing module setup: creating isolated testing modules with Test.createTestingModule(), overriding providers with mock implementations, and testing NestJS controllers end-to-end.
Your progress is saved in this browser, on this device only.