Testing NestJS Applications

Writing unit tests, integration tests, and end-to-end tests using Jest and Supertest.


Best Practices for Writing Testable NestJS Code

This document discusses best practices for designing and writing NestJS code that is easy to test. This includes dependency injection, separation of concerns, and using interfaces to promote testability.

Introduction to Testability in NestJS

Testable code is crucial for maintaining application quality, facilitating refactoring, and ensuring confidence in your application's functionality. NestJS, built on TypeScript and leveraging Dependency Injection, provides an excellent foundation for writing testable applications.

Key Principles for Testable NestJS Code

1. Dependency Injection (DI)

Dependency Injection is a core principle of NestJS and is paramount for testability. It allows you to easily replace real dependencies with mock implementations during testing.

Why it matters:

  • Isolation: Isolates the unit under test, preventing tests from relying on external systems or complex dependencies.
  • Control: Provides complete control over the dependencies used by the unit under test.
  • Predictability: Makes tests more predictable and repeatable.

Example:

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

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

    async findOne(id: number): Promise<User> {
      return this.userRepository.findById(id);
    }
  }

  // src/users/user.repository.ts
  import { Injectable } from '@nestjs/common';
  import { DataSource } from 'typeorm'; // Assuming TypeORM

  @Injectable()
  export class UserRepository {
    constructor(private dataSource: DataSource) {}

    async findById(id: number): Promise<User | null> {
      // Database logic to find user by id
      return this.dataSource.getRepository(User).findOneBy({ id });
    }
  } 

In this example, `UsersService` depends on `UserRepository`. During testing, we can inject a mock `UserRepository` instead of the real one.

2. Separation of Concerns (SoC)

Separating your application into distinct modules, services, and components with clear responsibilities makes each unit easier to understand and test.

Why it matters:

  • Simplified Logic: Smaller, focused units of code are easier to reason about and test.
  • Reduced Coupling: Changes in one part of the application are less likely to affect other parts, making testing more targeted.
  • Improved Maintainability: Well-defined modules are easier to maintain and refactor.

How to achieve SoC in NestJS:

  • Modules: Use NestJS modules to group related components (controllers, services, repositories).
  • Services: Encapsulate business logic within services.
  • Controllers: Handle request routing and delegate to services.
  • Repositories/Data Access Objects (DAOs): Abstract data access logic.

3. Using Interfaces for Abstraction

Defining interfaces for your services and repositories allows you to easily swap out implementations during testing. This is closely related to Dependency Injection.

Why it matters:

  • Loose Coupling: Components depend on interfaces, not concrete classes.
  • Mocking Made Easy: You can create mock implementations of interfaces that adhere to the same contract as the real implementations.
  • Test Doubles: Easier to create stubs, spies, and mocks for testing purposes.

Example:

 // src/users/user.repository.interface.ts
  export interface UserRepositoryInterface {
    findById(id: number): Promise<User | null>;
    // Other repository methods
  }

  // src/users/user.repository.ts
  import { Injectable } from '@nestjs/common';
  import { UserRepositoryInterface } from './user.repository.interface';
  import { DataSource } from 'typeorm';

  @Injectable()
  export class UserRepository implements UserRepositoryInterface {
    constructor(private dataSource: DataSource) {}

    async findById(id: number): Promise<User | null> {
      // Database logic to find user by id
      return this.dataSource.getRepository(User).findOneBy({ id });
    }
  }

  // src/users/users.service.ts
  import { Injectable, Inject } from '@nestjs/common';
  import { UserRepositoryInterface } from './user.repository.interface';

  @Injectable()
  export class UsersService {
    constructor(@Inject('UserRepositoryInterface') private readonly userRepository: UserRepositoryInterface) {}

    async findOne(id: number): Promise<User> {
      return this.userRepository.findById(id);
    }
  }

  // In your module definition you will need to register UserRepositoryInterface
  // imports: [
  //   {
  //     provide: 'UserRepositoryInterface',
  //     useClass: UserRepository,
  //   },
  // ], 

Now, in your test, you can easily inject a mock implementation of `UserRepositoryInterface`.

4. Using DTOs (Data Transfer Objects)

Use DTOs to define the shape of data that is passed between layers of your application. This makes it easier to validate data and mock data for testing.

Why it matters:

  • Data Validation: DTOs can include validation rules to ensure data integrity.
  • Type Safety: DTOs provide strong typing, making it easier to catch errors early.
  • Simplified Mocking: Easier to create valid mock data based on DTO definitions.

Example:

 // src/users/dto/create-user.dto.ts
  import { IsString, IsEmail, IsNotEmpty } from 'class-validator';

  export class CreateUserDto {
    @IsString()
    @IsNotEmpty()
    readonly firstName: string;

    @IsString()
    @IsNotEmpty()
    readonly lastName: string;

    @IsEmail()
    @IsNotEmpty()
    readonly email: string;
  } 

5. Consider Asynchronous Testing

NestJS often involves asynchronous operations (e.g., database calls, external API requests). Use `async/await` and proper test frameworks to handle asynchronous testing effectively.

Why it matters:

  • Accuracy: Ensures that tests wait for asynchronous operations to complete before making assertions.
  • Avoid Race Conditions: Prevents tests from passing or failing inconsistently due to timing issues.

Example:

 it('should find a user by ID', async () => {
      const user = await service.findOne(1);
      expect(user).toBeDefined();
    }); 

Testing Strategies in NestJS

Unit Testing

Focuses on testing individual components (e.g., services, controllers, repositories) in isolation.

Tools:

  • Jest
  • Mocha
  • Chai
  • Sinon (for spies and stubs)

Integration Testing

Tests the interaction between multiple components or modules. This verifies that different parts of your application work together correctly.

End-to-End (E2E) Testing

Simulates real user interactions with the application. This tests the entire application stack, from the user interface to the database.

Tools:

  • Supertest (for making HTTP requests)
  • Puppeteer/Playwright (for browser automation)

Conclusion

By following these best practices, you can write NestJS code that is more maintainable, reliable, and easier to test. Embracing dependency injection, separation of concerns, and interfaces are key to achieving testability in your NestJS applications. Remember to choose the appropriate testing strategy (unit, integration, E2E) based on the specific requirements of your project.