NestJS Core Concepts & Dependency Injection

NestJS is a progressive Node.js framework for building efficient, scalable server-side applications. It uses TypeScript by default, embraces decorators, and ships with a powerful dependency injection container — all inspired by Angular's architecture.
If you've used Express.js, you already know the HTTP engine underneath NestJS. If you've used Angular, NestJS will feel immediately familiar. And if you've read about Dependency Injection & IoC, you'll see those patterns applied in practice throughout every NestJS application.
In this post, we'll go deep into the core concepts that make NestJS tick — not just how to use them, but why they exist and how they fit together.
What You'll Learn
Learning Outcomes:
✅ Understand the Angular-NestJS connection (decorators, modules, DI)
✅ Master the three building blocks: Modules, Controllers, and Providers
✅ Deep dive into NestJS's DI container and custom providers
✅ Trace the full request lifecycle (Middleware → Guards → Interceptors → Pipes → Controller → Filters)
✅ Implement lifecycle hooks (OnModuleInit, OnModuleDestroy, etc.)
✅ Test NestJS applications with @nestjs/testing and mocked providers
✅ Build a complete REST API demonstrating all concepts together
Prerequisites
Before you start:
TypeScript fundamentals: Phase 1: TypeScript Fundamentals
DI theory: Dependency Injection & IoC Explained
Express basics: Getting Started with Express.js
NestJS and Angular: The Connection
NestJS was explicitly modeled after Angular. If you're an Angular developer moving to backend work, this table will look very familiar:
| Angular | NestJS | Purpose |
|---|---|---|
@Component() | @Controller() | Handle incoming requests / render views |
@Injectable() | @Injectable() | Mark class as DI-injectable |
@NgModule() | @Module() | Organize application structure |
| Services | Providers | Business logic containers |
| Pipes | Pipes | Data transformation and validation |
| Guards | Guards | Authorization / access control |
| Interceptors | Interceptors | Cross-cutting concerns (logging, caching) |
| Middleware | Middleware | Pre-route request processing |
The architectural DNA is the same:
The key difference: Angular runs in the browser and renders UI. NestJS runs on the server and handles HTTP requests, WebSockets, microservices, and more. But the mental model — modules organizing controllers and injectable services — is identical.
If you're coming from a frontend background, NestJS is the smoothest transition to backend development you'll find.
Setting Up a NestJS Project
Install the CLI
npm install -g @nestjs/cliCreate a New Project
nest new my-nestjs-app
cd my-nestjs-appThe CLI generates a clean project structure:
src/
├── app.controller.ts # Root controller
├── app.controller.spec.ts # Controller tests
├── app.module.ts # Root module
├── app.service.ts # Root service
└── main.ts # Application entry pointThe Entry Point: main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();NestFactory.create() takes the root module, resolves all dependencies, wires up the DI container, and starts the HTTP server (Express by default, or Fastify if configured).
Run the Development Server
npm run start:devVisit http://localhost:3000 — you'll see "Hello World!" served by the default controller.
Core Building Blocks
Every NestJS application is built from three fundamental pieces: Modules, Controllers, and Providers.
Modules
A module is a class decorated with @Module(). It organizes related controllers and providers into a cohesive unit.
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService], // make available to other modules
})
export class TasksModule {}The @Module() decorator takes an object with four properties:
| Property | Purpose |
|---|---|
imports | Other modules whose exported providers this module needs |
controllers | Controllers that handle incoming requests |
providers | Services and other injectable classes |
exports | Providers that should be available to importing modules |
The root module (AppModule) imports all feature modules:
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [TasksModule, UsersModule],
})
export class AppModule {}Controllers
Controllers handle incoming HTTP requests and return responses. They are decorated with @Controller() and use method decorators for specific routes.
import {
Controller, Get, Post, Put, Delete,
Param, Body, HttpCode, HttpStatus,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@Controller('tasks') // route prefix: /tasks
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll() {
return this.tasksService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tasksService.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createTaskDto: CreateTaskDto) {
return this.tasksService.create(createTaskDto);
}
@Put(':id')
update(@Param('id') id: string, @Body() updateTaskDto: UpdateTaskDto) {
return this.tasksService.update(id, updateTaskDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string) {
return this.tasksService.remove(id);
}
}Notice the constructor: private readonly tasksService: TasksService. This is constructor injection — the DI container automatically creates and injects a TasksService instance. If you've worked with Spring Boot's @Autowired, this is the same pattern with TypeScript syntax.
Common parameter decorators:
| Decorator | Express Equivalent | Purpose |
|---|---|---|
@Param(key) | req.params[key] | Route parameters |
@Body(key) | req.body[key] | Request body |
@Query(key) | req.query[key] | Query parameters |
@Headers(key) | req.headers[key] | Request headers |
@Req() | req | Full request object |
@Res() | res | Full response object |
Providers (Services)
Providers are the workhorses of NestJS. Any class decorated with @Injectable() can be managed by the DI container.
The most common type of provider is a service — a class that encapsulates business logic:
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
export interface Task {
id: string;
title: string;
description: string;
completed: boolean;
}
@Injectable()
export class TasksService {
private tasks: Task[] = [];
findAll(): Task[] {
return this.tasks;
}
findOne(id: string): Task {
const task = this.tasks.find((t) => t.id === id);
if (!task) {
throw new NotFoundException(`Task with ID "${id}" not found`);
}
return task;
}
create(createTaskDto: CreateTaskDto): Task {
const task: Task = {
id: Date.now().toString(),
...createTaskDto,
completed: false,
};
this.tasks.push(task);
return task;
}
update(id: string, updateTaskDto: UpdateTaskDto): Task {
const task = this.findOne(id);
Object.assign(task, updateTaskDto);
return task;
}
remove(id: string): void {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) {
throw new NotFoundException(`Task with ID "${id}" not found`);
}
this.tasks.splice(index, 1);
}
}The @Injectable() decorator tells NestJS: "this class can participate in the DI system." Without it, the container won't manage the class.
Dependency Injection Deep Dive
If you've read Dependency Injection & IoC Explained, you know the theory — Martin Fowler's assembler, constructor injection, and the Hollywood Principle. Now let's see exactly how NestJS implements it.
How the DI Container Works
When NestJS bootstraps your application with NestFactory.create(AppModule), this is what happens under the hood:
The container uses TypeScript's metadata reflection to discover what each class needs. When you write:
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
}TypeScript (with emitDecoratorMetadata: true in tsconfig.json) emits metadata about the constructor parameter types. The container reads this metadata and knows: "TasksController needs a TasksService — let me find or create one."
This is why @Injectable() is required on providers: it triggers TypeScript to emit the metadata that makes automatic injection possible.
Custom Providers
The standard way to register a provider is shorthand:
@Module({
providers: [TasksService], // shorthand for { provide: TasksService, useClass: TasksService }
})But NestJS supports four types of custom providers for more control:
1. Class Providers (useClass)
Replace one implementation with another:
@Module({
providers: [
{
provide: TasksService,
useClass:
process.env.NODE_ENV === 'test'
? MockTasksService
: TasksService,
},
],
})
export class TasksModule {}2. Value Providers (useValue)
Inject constants, configuration objects, or mock implementations:
@Module({
providers: [
{
provide: 'API_KEY',
useValue: process.env.API_KEY,
},
{
provide: 'CONFIG',
useValue: {
database: 'postgres',
port: 5432,
},
},
],
})
export class AppModule {}Inject using @Inject() with the token:
@Injectable()
export class ApiService {
constructor(@Inject('API_KEY') private readonly apiKey: string) {}
}3. Factory Providers (useFactory)
Create providers dynamically with access to other injected dependencies:
@Module({
providers: [
ConfigService,
{
provide: 'DATABASE_CONNECTION',
useFactory: async (configService: ConfigService) => {
const options = configService.getDatabaseConfig();
return createConnection(options);
},
inject: [ConfigService], // dependencies for the factory
},
],
})
export class DatabaseModule {}4. Alias Providers (useExisting)
Create an alias pointing to an existing provider:
@Module({
providers: [
TasksService,
{
provide: 'AliasedTasksService',
useExisting: TasksService, // same instance as TasksService
},
],
})
export class TasksModule {}Quick reference:
| Type | Use When | Example |
|---|---|---|
useClass | Swap implementations (testing, feature flags) | MockService in test, RealService in prod |
useValue | Inject constants or config objects | API keys, feature flags, mock objects |
useFactory | Dynamic creation with async logic | Database connections, conditional setup |
useExisting | Alias one provider to another | Backward compatibility, interface tokens |
Injection Scopes
By default, NestJS providers are singletons — one instance shared across the entire application. But you can change this:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
// New instance created for each incoming request
}
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
// New instance for every consumer that injects it
}| Scope | Behavior | Use Case |
|---|---|---|
DEFAULT | Single instance shared everywhere | Most services, stateless logic |
REQUEST | New instance per HTTP request | Request-specific data (user context, tenant) |
TRANSIENT | New instance per injection | Stateful helpers, per-consumer counters |
Performance note: REQUEST and TRANSIENT scopes disable certain optimizations and propagate through the injection chain. If a singleton depends on a request-scoped provider, the singleton becomes request-scoped too. Use them sparingly.
Request Lifecycle
Every HTTP request in NestJS passes through a well-defined pipeline. Understanding this pipeline is critical for knowing where to put your logic.
Middleware
Middleware runs first, before any NestJS-specific logic. It has access to the raw request and response objects — just like Express middleware.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`[${req.method}] ${req.url}`);
next();
}
}Register middleware in a module:
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
@Module({ /* ... */ })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('*');
}
}Guards
Guards decide whether a request should proceed. They return true (allow) or false (deny). Perfect for authentication and authorization.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
return this.validateToken(token);
}
private validateToken(token: string): boolean {
// validate JWT or API key
return !!token;
}
}Apply with @UseGuards():
@Controller('tasks')
@UseGuards(AuthGuard)
export class TasksController { /* ... */ }Interceptors
Interceptors wrap the route handler, giving you control over both the request (before) and the response (after). They use RxJS observables.
import {
Injectable, NestInterceptor,
ExecutionContext, CallHandler,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now();
return next
.handle()
.pipe(
tap(() => {
const duration = Date.now() - start;
console.log(`Request took ${duration}ms`);
}),
);
}
}Pipes
Pipes transform or validate incoming data before it reaches the route handler. NestJS ships with several built-in pipes:
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id is guaranteed to be a number
return this.tasksService.findOne(id);
}
}For request body validation, use ValidationPipe with class-validator:
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
}
export class UpdateTaskDto {
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
completed?: boolean;
}Enable globally in main.ts:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip unknown properties
forbidNonWhitelisted: true, // throw on unknown properties
transform: true, // auto-transform to DTO types
}));
await app.listen(3000);
}Exception Filters
Exception filters catch errors thrown during request processing and transform them into user-friendly responses.
import {
ExceptionFilter, Catch,
ArgumentsHost, HttpException,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}Where to put each type of logic:
| Layer | Purpose | Example |
|---|---|---|
| Middleware | Raw request processing, logging | Request logger, CORS, body parser |
| Guards | Access control (yes/no decision) | Auth check, role-based access |
| Interceptors | Transform request/response, timing | Caching, response mapping, timing |
| Pipes | Validate/transform input data | DTO validation, type coercion |
| Exception Filters | Format error responses | Custom error format, error logging |
Lifecycle Hooks
NestJS providers can implement lifecycle hooks — methods that are called at specific points during the application's startup and shutdown process.
| Hook | Interface | When Called |
|---|---|---|
onModuleInit() | OnModuleInit | After the host module's dependencies are resolved |
onApplicationBootstrap() | OnApplicationBootstrap | After all modules are initialized, before listening |
onModuleDestroy() | OnModuleDestroy | When the module is being destroyed (shutdown signal) |
beforeApplicationShutdown() | BeforeApplicationShutdown | After onModuleDestroy(), before connections close |
onApplicationShutdown() | OnApplicationShutdown | After all connections are closed |
Execution Order
Practical Example: Database Connection
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name);
private connection: any;
async onModuleInit() {
this.logger.log('Connecting to database...');
this.connection = await this.createConnection();
this.logger.log('Database connected');
}
async onModuleDestroy() {
this.logger.log('Closing database connection...');
await this.connection?.close();
this.logger.log('Database connection closed');
}
private async createConnection() {
// your database connection logic
return { close: async () => {} };
}
}Important: To receive shutdown signals (SIGTERM, SIGINT), you must enable shutdown hooks:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // required for OnModuleDestroy and shutdown hooks
await app.listen(3000);
}Putting It All Together
Let's build a complete Tasks API that demonstrates modules, controllers, providers, DI, validation pipes, and lifecycle hooks working together.
Project Structure
src/
├── main.ts
├── app.module.ts
├── tasks/
│ ├── tasks.module.ts
│ ├── tasks.controller.ts
│ ├── tasks.service.ts
│ ├── dto/
│ │ ├── create-task.dto.ts
│ │ └── update-task.dto.ts
│ └── interfaces/
│ └── task.interface.tstask.interface.ts
export interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: Date;
}DTOs with Validation
// create-task.dto.ts
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
}
// update-task.dto.ts
import { IsString, IsOptional, IsBoolean } from 'class-validator';
export class UpdateTaskDto {
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
completed?: boolean;
}TasksService (Provider)
import { Injectable, NotFoundException, OnModuleInit, Logger } from '@nestjs/common';
import { Task } from './interfaces/task.interface';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
@Injectable()
export class TasksService implements OnModuleInit {
private readonly logger = new Logger(TasksService.name);
private tasks: Task[] = [];
onModuleInit() {
this.logger.log('TasksService initialized — ready to manage tasks');
}
findAll(): Task[] {
return this.tasks;
}
findOne(id: string): Task {
const task = this.tasks.find((t) => t.id === id);
if (!task) {
throw new NotFoundException(`Task "${id}" not found`);
}
return task;
}
create(dto: CreateTaskDto): Task {
const task: Task = {
id: Date.now().toString(),
title: dto.title,
description: dto.description ?? '',
completed: false,
createdAt: new Date(),
};
this.tasks.push(task);
return task;
}
update(id: string, dto: UpdateTaskDto): Task {
const task = this.findOne(id);
Object.assign(task, dto);
return task;
}
remove(id: string): void {
const index = this.tasks.findIndex((t) => t.id === id);
if (index === -1) {
throw new NotFoundException(`Task "${id}" not found`);
}
this.tasks.splice(index, 1);
}
}TasksController (with DI)
import {
Controller, Get, Post, Put, Delete,
Param, Body, HttpCode, HttpStatus,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { Task } from './interfaces/task.interface';
@Controller('tasks')
export class TasksController {
// DI: NestJS injects TasksService automatically
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(): Task[] {
return this.tasksService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): Task {
return this.tasksService.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateTaskDto): Task {
return this.tasksService.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateTaskDto): Task {
return this.tasksService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: string): void {
this.tasksService.remove(id);
}
}TasksModule (Wiring)
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}main.ts (Bootstrap)
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
app.enableShutdownHooks();
await app.listen(3000);
console.log('Application running on http://localhost:3000');
}
bootstrap();Testing with curl
# Create a task
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn NestJS", "description": "Master core concepts"}'
# List all tasks
curl http://localhost:3000/tasks
# Get a specific task
curl http://localhost:3000/tasks/<task-id>
# Update a task
curl -X PUT http://localhost:3000/tasks/<task-id> \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete a task
curl -X DELETE http://localhost:3000/tasks/<task-id>
# Validation error (missing title)
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"description": "no title provided"}'
# → 400 Bad Request: "title should not be empty"NestJS vs Express vs Spring Boot
| Feature | Express | NestJS | Spring Boot |
|---|---|---|---|
| Architecture | Minimal, flexible | Opinionated, modular | Opinionated, modular |
| Language | JavaScript/TypeScript | TypeScript (first-class) | Java/Kotlin |
| DI | Manual or third-party | Built-in container | Built-in container |
| Decorators | None | Extensive | Annotations |
| Validation | Manual or middleware | Built-in pipes + class-validator | Bean Validation (JSR-380) |
| ORM | Sequelize, Prisma, etc. | TypeORM, Prisma, MikroORM | JPA/Hibernate |
| Testing | Mocha/Jest + manual DI | Jest + @nestjs/testing | JUnit + Spring Test |
| Learning Curve | Low | Medium | Medium-High |
| Performance | High | Good (Express underneath) | Good |
| Best For | Simple APIs, prototypes | Enterprise Node.js | Enterprise Java |
Express gives you freedom. NestJS gives you structure. Spring Boot gives you the Java ecosystem. Choose based on your team's language preference and the level of architectural guidance you want.
Testing NestJS Applications
Testing is where NestJS's DI architecture really pays off. The @nestjs/testing package lets you spin up a lightweight testing module that replaces real providers with mocks — no running server needed.
Unit Testing a Service
// tasks.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';
import { NotFoundException } from '@nestjs/common';
describe('TasksService', () => {
let service: TasksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TasksService],
}).compile();
service = module.get<TasksService>(TasksService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create a task', () => {
const task = service.create({ title: 'Buy milk', description: 'Whole milk' });
expect(task.id).toBeDefined();
expect(task.title).toBe('Buy milk');
expect(task.completed).toBe(false);
});
it('should throw NotFoundException for missing task', () => {
expect(() => service.findOne('non-existent-id')).toThrow(NotFoundException);
});
it('should delete a task', () => {
const task = service.create({ title: 'Buy milk', description: '' });
service.remove(task.id);
expect(() => service.findOne(task.id)).toThrow(NotFoundException);
});
});Unit Testing a Controller (with Mocked Service)
// tasks.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
// Create a typed mock of the service
const mockTasksService = {
findAll: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
describe('TasksController', () => {
let controller: TasksController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TasksController],
providers: [
{
provide: TasksService,
useValue: mockTasksService, // Inject mock instead of real service
},
],
}).compile();
controller = module.get<TasksController>(TasksController);
});
it('should call findAll on GET /', async () => {
const tasks = [{ id: '1', title: 'Test', completed: false }];
mockTasksService.findAll.mockReturnValue(tasks);
const result = controller.findAll();
expect(result).toEqual(tasks);
expect(mockTasksService.findAll).toHaveBeenCalledTimes(1);
});
it('should call create with correct DTO on POST /', () => {
const dto = { title: 'New task', description: 'details' };
const created = { id: '2', ...dto, completed: false };
mockTasksService.create.mockReturnValue(created);
const result = controller.create(dto);
expect(result).toEqual(created);
expect(mockTasksService.create).toHaveBeenCalledWith(dto);
});
});Integration Testing with Supertest
For end-to-end tests that spin up the full HTTP layer:
// tasks.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Tasks API (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
});
afterAll(async () => {
await app.close();
});
it('POST /tasks — creates a task', () => {
return request(app.getHttpServer())
.post('/tasks')
.send({ title: 'Integration test task', description: 'Testing the full stack' })
.expect(201)
.expect((res) => {
expect(res.body.id).toBeDefined();
expect(res.body.title).toBe('Integration test task');
expect(res.body.completed).toBe(false);
});
});
it('POST /tasks — returns 400 for missing title', () => {
return request(app.getHttpServer())
.post('/tasks')
.send({ description: 'No title' })
.expect(400);
});
it('GET /tasks — returns array of tasks', () => {
return request(app.getHttpServer())
.get('/tasks')
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body)).toBe(true);
});
});
it('GET /tasks/:id — returns 404 for unknown id', () => {
return request(app.getHttpServer())
.get('/tasks/non-existent')
.expect(404);
});
});Testing Guards and Interceptors
// auth.guard.spec.ts
import { Test } from '@nestjs/testing';
import { AuthGuard } from './auth.guard';
import { ExecutionContext } from '@nestjs/common';
describe('AuthGuard', () => {
let guard: AuthGuard;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [AuthGuard],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
});
const createMockContext = (token?: string): ExecutionContext => ({
switchToHttp: () => ({
getRequest: () => ({
headers: { authorization: token },
}),
}),
} as unknown as ExecutionContext);
it('should allow request with valid token', () => {
expect(guard.canActivate(createMockContext('Bearer valid-token'))).toBe(true);
});
it('should reject request without token', () => {
expect(guard.canActivate(createMockContext())).toBe(false);
});
});Testing philosophy in NestJS: Unit test services (pure business logic), integration test controllers (HTTP layer + DI wiring), and e2e test critical user flows. The DI container makes swapping real implementations for mocks trivial.
Practice Exercises
Exercise 1: Add a Users Module
Build a UsersModule from scratch:
Userentity withid,email,name,rolefieldsUsersServicewith findAll, findByEmail, create, update, deleteUsersControllerwith full CRUD routes- DTOs with
class-validatordecorators - Wire everything into
AppModule
Exercise 2: JWT Authentication Guard
Create a real JWT authentication system:
- Install
@nestjs/jwtand@nestjs/passport AuthModulewithJwtModule.register({ secret, expiresIn })JwtAuthGuardthat validates Bearer tokens/auth/loginendpoint returning a signed JWT- Apply
@UseGuards(JwtAuthGuard)to all task routes
Exercise 3: Caching Interceptor
Build a CacheInterceptor that:
- Stores
GETresponses in aMap<string, any>keyed by URL - Returns cached data if available (skip the route handler)
- Invalidates cache entries for a route when a
POST/PUT/DELETErequest hits the same base path - Has a configurable TTL via a custom decorator
@CacheTTL(seconds)
Exercise 4: Global Configuration Module
Set up proper configuration management:
- Install
@nestjs/config - Create a
.envfile withDB_HOST,DB_PORT,JWT_SECRET,PORT - Register
ConfigModule.forRoot({ isGlobal: true })inAppModule - Inject
ConfigServiceintoTasksServiceand readDB_HOST - Add config validation schema using
joiorclass-validator
Exercise 5: Full Test Suite
Write a complete test suite for the tasks feature:
- Unit tests for
TasksServicecovering all methods and edge cases - Controller tests using a mocked
TasksService - E2E tests for the full CRUD flow with
supertest - Guard test verifying authentication logic
Summary and Key Takeaways
- Dependency Injection & IoC Explained — the theory behind NestJS's DI container
- Getting Started with Express.js — the HTTP engine underneath NestJS
- Express Middleware Deep Dive — middleware concepts that carry into NestJS
- SOLID Principles Explained — the design principles NestJS embodies
- Getting Started with Spring Boot — compare NestJS with Java's enterprise framework
- Layered Architecture Guide — the architectural pattern NestJS's module system follows
Next: NestJS Database Integration — TypeORM, Prisma, and repository patterns
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.