← Back to DevBytes

Testing NestJS Components: From Unit to E2E Tests

Testing NestJS Components: From Unit to E2E Tests

Testing is a cornerstone of building reliable, maintainable applications. NestJS, with its modular architecture and built-in dependency injection, is uniquely designed to be highly testable. In this tutorial, we'll explore the full testing spectrum in NestJS — from isolated unit tests to comprehensive end-to-end (E2E) tests — and learn how to structure, write, and organize them effectively.

What Is Testing in NestJS?

NestJS provides a dedicated testing package, @nestjs/testing, that mirrors the framework's dependency injection system. This allows you to construct a customized module graph for your tests, replacing real providers with mocks, stubs, or fakes as needed. The testing utilities integrate seamlessly with popular frameworks like Jest, which is the default test runner scaffolded by the Nest CLI.

There are three primary levels of testing in NestJS:

Why Testing Matters

Automated tests provide a safety net that enables confident refactoring, faster feedback loops, and fewer production bugs. In a NestJS application, where components are loosely coupled through dependency injection, tests also serve as executable documentation: they describe how each piece of your system behaves under various conditions. Without tests, the cost of change grows exponentially as the codebase expands, and regressions become difficult to detect before they reach users.

Setting Up the Testing Environment

When you scaffold a NestJS project using the CLI, Jest is configured out of the box. The package.json includes scripts for running tests, and a jest.config.js or inline configuration handles file resolution and module mapping. If you're adding tests to an existing project, ensure your configuration looks similar to this:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "jest": {
    "moduleFileExtensions": ["js", "json", "ts"],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": ["**/*.(t|j)s"],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

Notice that E2E tests use a separate configuration file located in a test/ directory. This separation keeps unit and E2E tests organized and allows different root directories and patterns.

Writing Unit Tests

Unit tests focus on a single class and mock everything else. Let's start with a simple service that depends on a repository and an external service.

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { EmailService } from '../email/email.service';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly emailService: EmailService,
  ) {}

  async createUser(name: string, email: string): Promise<User> {
    const existing = await this.userRepository.findByEmail(email);
    if (existing) {
      throw new Error('User with this email already exists');
    }
    const user = await this.userRepository.save({ name, email });
    await this.emailService.sendWelcomeEmail(user.email);
    return user;
  }

  async findById(id: string): Promise<User | null> {
    return this.userRepository.findById(id);
  }
}

To test this service in isolation, we create a testing module using Test.createTestingModule and override the dependencies with mock objects. The .overrideProvider() method combined with .useValue() or .useClass() lets us inject controlled fakes.

// src/users/users.service.spec.ts
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UserRepository } from './user.repository';
import { EmailService } from '../email/email.service';

describe('UsersService', () => {
  let service: UsersService;
  let userRepository: jest.Mocked<UserRepository>;
  let emailService: jest.Mocked<EmailService>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UserRepository,
          useValue: {
            findByEmail: jest.fn(),
            save: jest.fn(),
            findById: jest.fn(),
          },
        },
        {
          provide: EmailService,
          useValue: {
            sendWelcomeEmail: jest.fn(),
          },
        },
      ],
    }).compile();

    service = moduleRef.get(UsersService);
    userRepository = moduleRef.get(UserRepository);
    emailService = moduleRef.get(EmailService);
  });

  describe('createUser', () => {
    it('should create a user and send a welcome email', async () => {
      const savedUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      userRepository.findByEmail.mockResolvedValue(null);
      userRepository.save.mockResolvedValue(savedUser);

      const result = await service.createUser('Alice', 'alice@test.com');

      expect(result).toEqual(savedUser);
      expect(userRepository.save).toHaveBeenCalledWith({
        name: 'Alice',
        email: 'alice@test.com',
      });
      expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith('alice@test.com');
    });

    it('should throw if a user with the email already exists', async () => {
      userRepository.findByEmail.mockResolvedValue({
        id: '2',
        name: 'Bob',
        email: 'bob@test.com',
      });

      await expect(service.createUser('Bob', 'bob@test.com')).rejects.toThrow(
        'User with this email already exists',
      );
      expect(emailService.sendWelcomeEmail).not.toHaveBeenCalled();
    });
  });

  describe('findById', () => {
    it('should return the user when found', async () => {
      const user = { id: '1', name: 'Alice', email: 'alice@test.com' };
      userRepository.findById.mockResolvedValue(user);

      const result = await service.findById('1');

      expect(result).toEqual(user);
      expect(userRepository.findById).toHaveBeenCalledWith('1');
    });

    it('should return null when the user is not found', async () => {
      userRepository.findById.mockResolvedValue(null);

      const result = await service.findById('999');

      expect(result).toBeNull();
    });
  });
});

Each test resets the mocks in beforeEach, ensuring tests remain independent and deterministic. The jest.Mocked type gives you full type safety and autocomplete for mock-specific methods like mockResolvedValue.

Testing Controllers

Controllers are typically thin layers that delegate to services. When unit testing a controller, you mock the service and verify that the controller correctly maps HTTP input to service calls and handles the response.

// src/users/users.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() dto: CreateUserDto) {
    return this.usersService.createUser(dto.name, dto.email);
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return this.usersService.findById(id);
  }
}
// src/users/users.controller.spec.ts
import { Test } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let usersService: jest.Mocked<UsersService>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: {
            createUser: jest.fn(),
            findById: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = moduleRef.get(UsersController);
    usersService = moduleRef.get(UsersService);
  });

  it('should call the service to create a user', async () => {
    const dto = { name: 'Alice', email: 'alice@test.com' };
    const created = { id: '1', ...dto };
    usersService.createUser.mockResolvedValue(created);

    const result = await controller.create(dto);

    expect(result).toEqual(created);
    expect(usersService.createUser).toHaveBeenCalledWith('Alice', 'alice@test.com');
  });

  it('should return a user by id', async () => {
    const user = { id: '1', name: 'Alice', email: 'alice@test.com' };
    usersService.findById.mockResolvedValue(user);

    const result = await controller.findOne('1');

    expect(result).toEqual(user);
  });
});

Testing with Custom Providers and Tokens

NestJS often uses custom injection tokens, especially for configuration or third-party libraries. When testing components that depend on these tokens, you must provide them explicitly in the testing module.

// src/config/config.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CONFIG_TOKEN } from './config.constants';

@Injectable()
export class ConfigService {
  constructor(@Inject(CONFIG_TOKEN) private readonly config: Record<string, any>) {}

  get<T>(key: string): T {
    return this.config[key] as T;
  }
}
// src/config/config.service.spec.ts
import { Test } from '@nestjs/testing';
import { ConfigService } from './config.service';
import { CONFIG_TOKEN } from './config.constants';

describe('ConfigService', () => {
  let service: ConfigService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        ConfigService,
        {
          provide: CONFIG_TOKEN,
          useValue: { port: 3000, databaseUrl: 'postgres://localhost' },
        },
      ],
    }).compile();

    service = moduleRef.get(ConfigService);
  });

  it('should retrieve a config value by key', () => {
    expect(service.get<number>('port')).toBe(3000);
    expect(service.get<string>('databaseUrl')).toBe('postgres://localhost');
  });
});

Writing E2E Tests

End-to-end tests exercise the entire HTTP request lifecycle. Instead of mocking dependencies, you bootstrap the full NestJS application and send real HTTP requests using the supertest library. This validates routing, pipes, guards, interceptors, and controllers all working together.

First, install supertest and its types:

npm install --save-dev supertest @types/supertest

Here is an E2E test for the users module:

// test/users.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('UsersController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /users', () => {
    it('should create a new user and return 201', async () => {
      const dto = { name: 'Alice', email: 'alice@test.com' };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(dto)
        .expect(201);

      expect(response.body).toHaveProperty('id');
      expect(response.body.name).toBe('Alice');
      expect(response.body.email).toBe('alice@test.com');
    });

    it('should return 400 when the email is missing', async () => {
      const dto = { name: 'Alice' };

      await request(app.getHttpServer())
        .post('/users')
        .send(dto)
        .expect(400);
    });
  });

  describe('GET /users/:id', () => {
    it('should return 200 and the user when found', async () => {
      // First create a user
      const createResponse = await request(app.getHttpServer())
        .post('/users')
        .send({ name: 'Bob', email: 'bob@test.com' });

      const userId = createResponse.body.id;

      await request(app.getHttpServer())
        .get(`/users/${userId}`)
        .expect(200)
        .expect((res) => {
          expect(res.body.id).toBe(userId);
          expect(res.body.name).toBe('Bob');
        });
    });
  });
});

Notice that we use beforeAll and afterAll rather than beforeEach. Bootstrapping the entire application is expensive, so it should happen once per test suite. The app.getHttpServer() method returns the underlying Node.js HTTP server that supertest uses to dispatch requests.

Mocking the Database in E2E Tests

Running E2E tests against a real database can be slow and fragile. A common strategy is to override the database provider with an in-memory implementation or to use a dedicated test database that is reset between runs. Here's an example of overriding a repository in an E2E context:

// test/users.e2e-spec.ts (with mocked repository)
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { UserRepository } from '../src/users/user.repository';

describe('UsersController (e2e) with mocked repository', () => {
  let app: INestApplication;
  const mockUsers: any[] = [];

  beforeAll(async () => {
    const moduleRef: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(UserRepository)
      .useValue({
        findByEmail: async (email: string) =>
          mockUsers.find((u) => u.email === email) || null,
        save: async (data: any) => {
          const user = { id: String(mockUsers.length + 1), ...data };
          mockUsers.push(user);
          return user;
        },
        findById: async (id: string) =>
          mockUsers.find((u) => u.id === id) || null,
      })
      .compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('should create and retrieve a user', async () => {
    const createResponse = await request(app.getHttpServer())
      .post('/users')
      .send({ name: 'Charlie', email: 'charlie@test.com' })
      .expect(201);

    const userId = createResponse.body.id;

    await request(app.getHttpServer())
      .get(`/users/${userId}`)
      .expect(200)
      .expect((res) => {
        expect(res.body.name).toBe('Charlie');
      });
  });
});

Testing Guards, Pipes, and Interceptors

Guards, pipes, and interceptors are often tested as standalone classes. For example, a JWT auth guard can be tested by mocking the Reflector and ExecutionContext:

// src/auth/jwt-auth.guard.spec.ts
import { JwtAuthGuard } from './jwt-auth.guard';
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';

describe('JwtAuthGuard', () => {
  let guard: JwtAuthGuard;
  let reflector: jest.Mocked<Reflector>;

  beforeEach(() => {
    reflector = {
      getAllAndOverride: jest.fn(),
    } as any;
    guard = new JwtAuthGuard(reflector);
  });

  it('should allow access when the route is marked public', () => {
    reflector.getAllAndOverride.mockReturnValue(true);
    const context = {
      getHandler: () => ({}),
      getClass: () => ({}),
    } as ExecutionContext;

    expect(guard.canActivate(context)).toBe(true);
  });

  it('should require a valid user when the route is protected', () => {
    reflector.getAllAndOverride.mockReturnValue(false);
    const context = {
      getHandler: () => ({}),
      getClass: () => ({}),
      switchToHttp: () => ({
        getRequest: () => ({ user: { id: '1' } }),
      }),
    } as ExecutionContext;

    expect(guard.canActivate(context)).toBe(true);
  });
});

Best Practices

Conclusion

Testing in NestJS is a natural extension of its modular, dependency-injected architecture. By leveraging the @nestjs/testing package, you can build focused unit tests that isolate individual components, integration tests that verify module boundaries, and E2E tests that validate the entire request lifecycle. The key is to choose the right level of testing for each scenario: mock external dependencies in unit tests, exercise real interactions in integration tests, and treat the application as a black box in E2E tests. By following the patterns and best practices outlined in this tutorial, you'll build a robust test suite that gives you the confidence to iterate quickly and ship reliable software.

— Ad —

Google AdSense will appear here after approval

← Back to all articles