Master REST API testing using Supertest and Jest — testing HTTP methods, status codes, response schemas, and error handling in NestJS applications.
REST API testing verifies that your API behaves correctly across all conditions — success paths, error paths, edge cases, and security boundaries. Unlike unit tests that mock dependencies, API integration tests make real HTTP requests against a running server connected to a test database, catching issues that unit tests miss: middleware ordering, guard execution, validation pipe behavior, and serializer output.
This lesson establishes the foundation for all API testing: setting up Supertest with NestJS, writing tests for all HTTP methods, and asserting response schemas and error codes.
1npm install --save-dev supertest @types/supertest1// apps/api/src/app.e2e-spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { AppModule } from './app.module';
6import { PrismaService } from './modules/prisma/prisma.service';
7
8describe('Courses API (e2e)', () => {
9 let app: INestApplication;
10 let prisma: PrismaService;
11
12 beforeAll(async () => {
13 const module: TestingModule = await Test.createTestingModule({
14 imports: [AppModule],
15 }).compile();
16
17 app = module.createNestApplication();
18 // Apply exact same configuration as production
19 app.useGlobalPipes(new ValidationPipe({
20 whitelist: true,
21 forbidNonWhitelisted: true,
22 transform: true,
23 }));
24 await app.init();
25 prisma = module.get<PrismaService>(PrismaService);
26 });
27
28 afterAll(async () => {
29 // Clean up test data
30 await prisma.enrollment.deleteMany({});
31 await prisma.course.deleteMany({ where: { slug: { startsWith: 'test-' } } });
32 await app.close();
33 });
34
35 it('GET /courses returns 200 with paginated results', async () => {
36 const res = await request(app.getHttpServer())
37 .get('/courses?limit=10&page=1');
38
39 expect(res.status).toBe(200);
40 expect(res.body).toMatchObject({
41 data: expect.any(Array),
42 meta: expect.objectContaining({
43 total: expect.any(Number),
44 page: 1,
45 limit: 10,
46 }),
47 });
48 });
49});1describe('Courses CRUD', () => {
2 let createdCourseId: string;
3 let adminToken: string;
4
5 beforeAll(async () => {
6 const loginRes = await request(app.getHttpServer())
7 .post('/auth/login')
8 .send({ email: '[email protected]', password: 'AdminPass123!' });
9 adminToken = loginRes.body.accessToken;
10 });
11
12 it('POST /courses creates a course (201)', async () => {
13 const res = await request(app.getHttpServer())
14 .post('/courses')
15 .set('Authorization', `Bearer ${adminToken}`)
16 .send({
17 title: 'Test TypeScript Course',
18 slug: 'test-typescript-course',
19 description: 'A test course',
20 price: 49.99,
21 category: 'typescript',
22 difficulty: 'BEGINNER',
23 });
24
25 expect(res.status).toBe(201);
26 expect(res.body).toMatchObject({
27 id: expect.any(String),
28 title: 'Test TypeScript Course',
29 price: 49.99,
30 });
31 createdCourseId = res.body.id;
32 });
33
34 it('GET /courses/:id returns the course (200)', async () => {
35 const res = await request(app.getHttpServer())
36 .get(`/courses/${createdCourseId}`);
37 expect(res.status).toBe(200);
38 expect(res.body.id).toBe(createdCourseId);
39 });
40
41 it('PATCH /courses/:id updates the course (200)', async () => {
42 const res = await request(app.getHttpServer())
43 .patch(`/courses/${createdCourseId}`)
44 .set('Authorization', `Bearer ${adminToken}`)
45 .send({ price: 59.99 });
46 expect(res.status).toBe(200);
47 expect(res.body.price).toBe(59.99);
48 });
49
50 it('DELETE /courses/:id removes the course (200 or 204)', async () => {
51 const res = await request(app.getHttpServer())
52 .delete(`/courses/${createdCourseId}`)
53 .set('Authorization', `Bearer ${adminToken}`);
54 expect([200, 204]).toContain(res.status);
55 });
56
57 it('GET /courses/:id after delete returns 404', async () => {
58 const res = await request(app.getHttpServer())
59 .get(`/courses/${createdCourseId}`);
60 expect(res.status).toBe(404);
61 });
62});1describe('Input validation', () => {
2 it('POST /courses with missing required fields returns 400', async () => {
3 const res = await request(app.getHttpServer())
4 .post('/courses')
5 .set('Authorization', `Bearer ${adminToken}`)
6 .send({ title: 'No price or category' }); // missing required fields
7
8 expect(res.status).toBe(400);
9 expect(res.body).toMatchObject({
10 statusCode: 400,
11 message: expect.any(Array), // validation errors array
12 error: 'Bad Request',
13 });
14 // Check specific field validation messages
15 const messages = res.body.message.join(' ');
16 expect(messages).toMatch(/price/);
17 expect(messages).toMatch(/category/);
18 });
19
20 it('POST /courses with extra unknown fields returns 400 (whitelist)', async () => {
21 const res = await request(app.getHttpServer())
22 .post('/courses')
23 .set('Authorization', `Bearer ${adminToken}`)
24 .send({
25 title: 'Test',
26 price: 10,
27 category: 'typescript',
28 unknownField: 'hacker input', // forbidden by forbidNonWhitelisted
29 });
30 expect(res.status).toBe(400);
31 expect(res.body.message.join(' ')).toMatch(/unknownField/);
32 });
33
34 it('POST without auth returns 401', async () => {
35 const res = await request(app.getHttpServer())
36 .post('/courses')
37 .send({ title: 'Test', price: 10, category: 'typescript' });
38 expect(res.status).toBe(401);
39 });
40
41 it('GET non-existent ID returns 404', async () => {
42 const res = await request(app.getHttpServer())
43 .get('/courses/non-existent-id');
44 expect(res.status).toBe(404);
45 });
46});1// Verify response schema without hardcoding exact values
2describe('Response schema validation', () => {
3 it('course list response has correct structure', async () => {
4 const res = await request(app.getHttpServer()).get('/courses?limit=5');
5
6 expect(res.body).toMatchObject({
7 data: expect.arrayContaining([
8 expect.objectContaining({
9 id: expect.any(String),
10 title: expect.any(String),
11 slug: expect.any(String),
12 price: expect.any(Number),
13 rating: expect.any(Number),
14 category: expect.any(String),
15 // thumbnailUrl may be null — use expect.anything() or skip
16 }),
17 ]),
18 meta: {
19 total: expect.any(Number),
20 page: expect.any(Number),
21 limit: 5,
22 totalPages: expect.any(Number),
23 },
24 });
25 });
26
27 it('single course has instructor data', async () => {
28 const listRes = await request(app.getHttpServer()).get('/courses?limit=1');
29 const courseId = listRes.body.data[0]?.id;
30 if (!courseId) return; // skip if no courses
31
32 const res = await request(app.getHttpServer()).get(`/courses/${courseId}`);
33
34 expect(res.body).toMatchObject({
35 id: courseId,
36 instructor: expect.objectContaining({
37 id: expect.any(String),
38 name: expect.any(String),
39 }),
40 _count: expect.objectContaining({
41 lessons: expect.any(Number),
42 }),
43 });
44 // Verify sensitive fields are not included
45 expect(res.body.instructor.passwordHash).toBeUndefined();
46 expect(res.body.instructor.twoFactorSecret).toBeUndefined();
47 });
48});1// Strategy 1: Use a dedicated test database
2// In .env.test:
3// DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_test
4
5// Strategy 2: Transaction rollback (fastest — no cleanup needed)
6// apps/api/src/test/prisma-test-helper.ts
7import { PrismaClient } from '@prisma/client';
8
9export async function createTestingModule() {
10 // Use test database with isolated transactions
11 const prisma = new PrismaClient({
12 datasources: { db: { url: process.env.TEST_DATABASE_URL } },
13 });
14 return prisma;
15}
16
17// Strategy 3: Seed + cleanup with unique identifiers
18describe('API Tests', () => {
19 const TEST_PREFIX = `test-${Date.now()}`; // unique per test run
20
21 beforeAll(async () => {
22 // Seed specific test data
23 await prisma.course.create({
24 data: {
25 title: `${TEST_PREFIX} TypeScript Course`,
26 slug: `${TEST_PREFIX}-typescript`,
27 price: 49.99,
28 // ...
29 },
30 });
31 });
32
33 afterAll(async () => {
34 // Clean up only this test run's data
35 await prisma.course.deleteMany({
36 where: { slug: { startsWith: TEST_PREFIX } },
37 });
38 });
39});
40
41// Strategy 4: Use testcontainers for isolated PostgreSQL
42// npm install testcontainers
43import { PostgreSqlContainer } from '@testcontainers/postgresql';
44
45let container: StartedPostgreSqlContainer;
46beforeAll(async () => {
47 container = await new PostgreSqlContainer().start();
48 process.env.DATABASE_URL = container.getConnectionUri();
49});
50afterAll(() => container.stop());Full integration test suite for a courses API covering CRUD, validation, auth, and response schema assertions.
1// apps/api/src/modules/courses/courses.e2e-spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { AppModule } from '../../app.module';
6import { PrismaService } from '../prisma/prisma.service';
7
8describe('Courses API Integration Tests', () => {
9 let app: INestApplication;
10 let prisma: PrismaService;
11 let adminToken: string;
12 let userToken: string;
13 let testCourseId: string;
14
15 const TEST_RUN = `test-${Date.now()}`;
16
17 beforeAll(async () => {
18 const module: TestingModule = await Test.createTestingModule({
19 imports: [AppModule],
20 }).compile();
21
22 app = module.createNestApplication();
23 app.useGlobalPipes(new ValidationPipe({
24 whitelist: true,
25 forbidNonWhitelisted: true,
26 transform: true,
27 }));
28 await app.init();
29 prisma = module.get(PrismaService);
30
31 // Authenticate both users
32 const [adminLogin, userLogin] = await Promise.all([
33 request(app.getHttpServer()).post('/auth/login')
34 .send({ email: '[email protected]', password: 'AdminPass123!' }),
35 request(app.getHttpServer()).post('/auth/login')
36 .send({ email: '[email protected]', password: 'UserPass123!' }),
37 ]);
38 adminToken = adminLogin.body.accessToken;
39 userToken = userLogin.body.accessToken;
40 });
41
42 afterAll(async () => {
43 await prisma.course.deleteMany({ where: { slug: { startsWith: TEST_RUN } } });
44 await app.close();
45 });
46
47 describe('Course creation (admin)', () => {
48 it('POST /courses creates with valid data (201)', async () => {
49 const res = await request(app.getHttpServer())
50 .post('/courses')
51 .set('Authorization', 'Bearer ' + adminToken)
52 .send({
53 title: TEST_RUN + ' TypeScript Mastery',
54 slug: TEST_RUN + '-typescript-mastery',
55 description: 'Master TypeScript from fundamentals to advanced patterns',
56 price: 79.99,
57 category: 'typescript',
58 difficulty: 'INTERMEDIATE',
59 });
60
61 expect(res.status).toBe(201);
62 expect(res.body).toMatchObject({
63 id: expect.any(String),
64 title: TEST_RUN + ' TypeScript Mastery',
65 price: 79.99,
66 status: 'draft',
67 });
68 expect(res.body.passwordHash).toBeUndefined();
69 testCourseId = res.body.id;
70 });
71
72 it('POST /courses with missing title returns 400', async () => {
73 const res = await request(app.getHttpServer())
74 .post('/courses')
75 .set('Authorization', 'Bearer ' + adminToken)
76 .send({ price: 49.99, category: 'typescript' });
77 expect(res.status).toBe(400);
78 expect(res.body.message.join(' ')).toMatch(/title/);
79 });
80
81 it('POST /courses with invalid price (negative) returns 400', async () => {
82 const res = await request(app.getHttpServer())
83 .post('/courses')
84 .set('Authorization', 'Bearer ' + adminToken)
85 .send({ title: 'Test', price: -10, category: 'typescript', slug: 'test-neg' });
86 expect(res.status).toBe(400);
87 expect(res.body.message.join(' ')).toMatch(/price/);
88 });
89
90 it('POST /courses as regular user returns 403', async () => {
91 const res = await request(app.getHttpServer())
92 .post('/courses')
93 .set('Authorization', 'Bearer ' + userToken)
94 .send({ title: 'Test', price: 10, category: 'typescript', slug: 'test-user' });
95 expect(res.status).toBe(403);
96 });
97 });
98
99 describe('Course retrieval', () => {
100 it('GET /courses returns paginated list', async () => {
101 const res = await request(app.getHttpServer())
102 .get('/courses?limit=10&page=1');
103 expect(res.status).toBe(200);
104 expect(res.body.data).toBeInstanceOf(Array);
105 expect(res.body.meta).toMatchObject({
106 page: 1, limit: 10,
107 total: expect.any(Number),
108 });
109 });
110
111 it('GET /courses/:id returns single course', async () => {
112 const res = await request(app.getHttpServer())
113 .get('/courses/' + testCourseId);
114 expect(res.status).toBe(200);
115 expect(res.body.id).toBe(testCourseId);
116 });
117
118 it('GET /courses/:id with non-existent ID returns 404', async () => {
119 const res = await request(app.getHttpServer())
120 .get('/courses/non-existent-cuid-12345');
121 expect(res.status).toBe(404);
122 });
123 });
124
125 describe('Course update and delete', () => {
126 it('PATCH /courses/:id updates price (200)', async () => {
127 const res = await request(app.getHttpServer())
128 .patch('/courses/' + testCourseId)
129 .set('Authorization', 'Bearer ' + adminToken)
130 .send({ price: 89.99 });
131 expect(res.status).toBe(200);
132 expect(res.body.price).toBe(89.99);
133 });
134
135 it('DELETE /courses/:id removes course (200 or 204)', async () => {
136 const res = await request(app.getHttpServer())
137 .delete('/courses/' + testCourseId)
138 .set('Authorization', 'Bearer ' + adminToken);
139 expect([200, 204]).toContain(res.status);
140 });
141 });
142});This integration test suite demonstrates production API testing patterns: unique TEST_RUN prefix for all test data (preventing collisions across parallel test runs), authenticated requests for both admin and user roles, schema validation with toMatchObject (verifying structure without hardcoding exact values), and cleanup in afterAll that only removes test-run-specific data. The tests follow a logical flow: create → retrieve → update → delete, with each step building on the previous.
The next lesson covers Postman and Newman — designing a Postman collection with environment variables, chaining requests to test multi-step workflows, and running collections in CI with Newman.
Your progress is saved in this browser, on this device only.