Back to blog

NestJS Core Concepts & Dependency Injection

nestjstypescriptbackendnodejsrest-api
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:

AngularNestJSPurpose
@Component()@Controller()Handle incoming requests / render views
@Injectable()@Injectable()Mark class as DI-injectable
@NgModule()@Module()Organize application structure
ServicesProvidersBusiness logic containers
PipesPipesData transformation and validation
GuardsGuardsAuthorization / access control
InterceptorsInterceptorsCross-cutting concerns (logging, caching)
MiddlewareMiddlewarePre-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/cli

Create a New Project

nest new my-nestjs-app
cd my-nestjs-app

The 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 point

The 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:dev

Visit 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:

PropertyPurpose
importsOther modules whose exported providers this module needs
controllersControllers that handle incoming requests
providersServices and other injectable classes
exportsProviders 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:

DecoratorExpress EquivalentPurpose
@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()reqFull request object
@Res()resFull 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:

TypeUse WhenExample
useClassSwap implementations (testing, feature flags)MockService in test, RealService in prod
useValueInject constants or config objectsAPI keys, feature flags, mock objects
useFactoryDynamic creation with async logicDatabase connections, conditional setup
useExistingAlias one provider to anotherBackward 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
}
ScopeBehaviorUse Case
DEFAULTSingle instance shared everywhereMost services, stateless logic
REQUESTNew instance per HTTP requestRequest-specific data (user context, tenant)
TRANSIENTNew instance per injectionStateful 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:

LayerPurposeExample
MiddlewareRaw request processing, loggingRequest logger, CORS, body parser
GuardsAccess control (yes/no decision)Auth check, role-based access
InterceptorsTransform request/response, timingCaching, response mapping, timing
PipesValidate/transform input dataDTO validation, type coercion
Exception FiltersFormat error responsesCustom 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.

HookInterfaceWhen Called
onModuleInit()OnModuleInitAfter the host module's dependencies are resolved
onApplicationBootstrap()OnApplicationBootstrapAfter all modules are initialized, before listening
onModuleDestroy()OnModuleDestroyWhen the module is being destroyed (shutdown signal)
beforeApplicationShutdown()BeforeApplicationShutdownAfter onModuleDestroy(), before connections close
onApplicationShutdown()OnApplicationShutdownAfter 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.ts

task.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

FeatureExpressNestJSSpring Boot
ArchitectureMinimal, flexibleOpinionated, modularOpinionated, modular
LanguageJavaScript/TypeScriptTypeScript (first-class)Java/Kotlin
DIManual or third-partyBuilt-in containerBuilt-in container
DecoratorsNoneExtensiveAnnotations
ValidationManual or middlewareBuilt-in pipes + class-validatorBean Validation (JSR-380)
ORMSequelize, Prisma, etc.TypeORM, Prisma, MikroORMJPA/Hibernate
TestingMocha/Jest + manual DIJest + @nestjs/testingJUnit + Spring Test
Learning CurveLowMediumMedium-High
PerformanceHighGood (Express underneath)Good
Best ForSimple APIs, prototypesEnterprise Node.jsEnterprise 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:

  • User entity with id, email, name, role fields
  • UsersService with findAll, findByEmail, create, update, delete
  • UsersController with full CRUD routes
  • DTOs with class-validator decorators
  • Wire everything into AppModule

Exercise 2: JWT Authentication Guard

Create a real JWT authentication system:

  • Install @nestjs/jwt and @nestjs/passport
  • AuthModule with JwtModule.register({ secret, expiresIn })
  • JwtAuthGuard that validates Bearer tokens
  • /auth/login endpoint returning a signed JWT
  • Apply @UseGuards(JwtAuthGuard) to all task routes

Exercise 3: Caching Interceptor

Build a CacheInterceptor that:

  • Stores GET responses in a Map<string, any> keyed by URL
  • Returns cached data if available (skip the route handler)
  • Invalidates cache entries for a route when a POST/PUT/DELETE request 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 .env file with DB_HOST, DB_PORT, JWT_SECRET, PORT
  • Register ConfigModule.forRoot({ isGlobal: true }) in AppModule
  • Inject ConfigService into TasksService and read DB_HOST
  • Add config validation schema using joi or class-validator

Exercise 5: Full Test Suite

Write a complete test suite for the tasks feature:

  • Unit tests for TasksService covering 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


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.