Part 4.3 — Integration Testing with Supertest: REST Endpoints
Unit tests validate logic in isolation.
Integration tests validate reality.
Supertest lets you make real HTTP requests against your NestJS backend — without running the server on a real port — giving you confidence that:
controllers are wired correctly
pipes validate inputs
class-validator catches invalid DTOs
filters return proper error shapes
interceptors log/transform responses
Prisma queries run on a real test PostgreSQL DB
By the end of this post, you'll have fully tested REST endpoints that behave exactly as your frontend expects.
1. Bootstrapping NestJS for Integration Tests
Each integration suite begins by:
Creating a testing module
Compiling it
Initializing a Nest application instance
Getting the HTTP server
Example:
let app;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});This gives Supertest a live API surface to hit.
2. Import Supertest
import request from 'supertest';Use:
await request(app.getHttpServer())
.get('/users')
.expect(200);No need to listen on a port — the server is created in-memory.
3. Ensure the Test Database Is Clean Before Each Suite
You built resetDb() in Part 4.1.
Use:
beforeEach(async () => {
await resetDb();
});Never let tests leak data across suites.
4. Testing a POST Endpoint (Users)
This test ensures:
DTO validation runs
Pipes transform input
Prisma writes to DB
Global exception filter shapes output
describe('POST /users', () => {
it('creates a user', async () => {
const res = await request(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201);
expect(res.body.email).toBe('alice@example.com');
expect(res.body.id).toBeDefined();
});
it('rejects invalid DTOs', async () => {
const res = await request(app.getHttpServer())
.post('/users')
.send({ name: '', email: 'invalid' })
.expect(400);
expect(res.body.message).toContain('email must be an email');
});
});This tests the entire request pipeline.
5. Testing a GET List Endpoint
describe('GET /users', () => {
it('returns all users', async () => {
await prisma.user.create({
data: { name: 'Bob', email: 'bob@example.com' },
});
const res = await request(app.getHttpServer())
.get('/users')
.expect(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe('Bob');
});
});This validates your service → prisma → DB flow.
6. Testing GET by ID With ParseIntPipe
Because you used ParseIntPipe:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { ... }Test both success & failure:
it('rejects non-numeric IDs', async () => {
const res = await request(app.getHttpServer())
.get('/users/abc')
.expect(400);
expect(res.body.message).toContain('Validation failed');
});Properly typed IDs matter for Prisma queries.
7. Testing PATCH Endpoint
describe('PATCH /users/:id', () => {
it('updates a user', async () => {
const user = await prisma.user.create({
data: { name: 'Alice', email: 'alice@example.com' },
});
const res = await request(app.getHttpServer())
.patch(`/users/${user.id}`)
.send({ name: 'Updated' })
.expect(200);
expect(res.body.name).toBe('Updated');
});
});Test invalid updates too.
8. Testing DELETE Endpoint
describe('DELETE /users/:id', () => {
it('deletes a user', async () => {
const user = await prisma.user.create({
data: { name: 'Bob', email: 'bob@example.com' },
});
await request(app.getHttpServer())
.delete(`/users/${user.id}`)
.expect(200);
const exists = await prisma.user.findUnique({
where: { id: user.id },
});
expect(exists).toBeNull();
});
});Validating deletion is surprisingly important — especially for relational tables.
9. Testing Nested Relations (Posts → Author)
describe('GET /posts', () => {
it('includes authors', async () => {
const user = await prisma.user.create({
data: { name: 'Alice', email: 'alice@example.com' },
});
await prisma.post.create({
data: { title: 'Hello', body: 'Test', authorId: user.id },
});
const res = await request(app.getHttpServer())
.get('/posts')
.expect(200);
expect(res.body[0].author.email).toBe('alice@example.com');
});
});This ensures Prisma relations work in full-stack tests.
10. Testing Expected Error Shapes
Because you implemented a global exception filter, test its shape:
it('returns unified error format', async () => {
const res = await request(app.getHttpServer())
.get('/users/999') // nonexistent
.expect(404);
expect(res.body).toHaveProperty('statusCode');
expect(res.body).toHaveProperty('message');
expect(res.body).toHaveProperty('timestamp');
});Consistency is everything.
11. Test Real Validation Flow (DTO + Pipes)
Example:
it('validates DTOs through global pipes', async () => {
const res = await request(app.getHttpServer())
.post('/posts')
.send({ title: '', body: '', authorId: 'abc' })
.expect(400);
expect(res.body.message.some(m => m.includes('must not be empty'))).toBe(true);
});This verifies your DTO → ValidationPipe → ExceptionFilter chain.
12. Best Practices for Integration Tests
isolate database between tests
keep tests small but realistic
avoid mocking (except external services)
test happy paths and failure paths
assert response shape, not just status codes
prefer
.expect({ ... })when shape is stable
Integration tests are the guarantee that your entire backend behaves the way your frontend expects.
13. Summary
You now know how to:
bootstrap NestJS test applications
hit REST endpoints with Supertest
validate entire request pipelines
test DTO validation + pipes
confirm error filters behave consistently
test relational and pagination behaviors
isolate test databases cleanly
Related
Part 8.6 — When (and When Not) to Use GraphQL in Real Systems
GraphQL is powerful—but expensive. This post helps you decide, based on real constraints, whether GraphQL is worth adopting or whether REST will serve you better.
Part 8.5 — Backend Architecture: Controllers vs “Resolvers” in NestJS
If you squint a little, a NestJS controller method already is a resolver. This post shows how to embrace that idea without introducing GraphQL complexity.
Part 8.4 — Client-Driven Data with REST: Avoiding Over-Fetching
Over-fetching is a client problem as much as a server problem. This post shows how React apps can drive data needs intentionally—without turning REST APIs into guesswork.
Comments