NestJS Unit & Integration Tests

Understanding NestJS TDD - unit, integration and e2e testing

NestJS Unit & Integration Tests

Testing is a crucial part of software development, ensuring that your code works as expected and preventing bugs from making their way into production. In this blog post, we will explore how to write unit and integration tests for a NestJS application, covering everything from the basics to more advanced techniques.

Checkout parts 1 & 2 of the NestJS series

Pros and Cons of Writing Tests

Pros

  1. Improved Code Quality:

    • Writing tests ensures that your code works as expected and helps catch bugs early in the development process.
  2. Refactoring Confidence:

    • Tests provide a safety net that allows you to refactor your code with confidence, knowing that any breaking changes will be caught by your tests.
  3. Documentation:

    • Tests serve as documentation for your code, showing how different parts of your application are expected to behave.
  4. Reduced Bugs in Production:

    • By catching issues early, tests reduce the likelihood of bugs making it into production, leading to more reliable software.
  5. Encourages Best Practices:

    • Writing tests encourages best practices in software development, such as modularity and separation of concerns.

Cons

  1. Time-Consuming:

    • Writing tests can be time-consuming, especially for large applications with many components.
  2. Maintenance Overhead:

    • Tests need to be maintained along with the code. Changes in the application may require updates to the tests.
  3. Initial Learning Curve:

    • There is an initial learning curve associated with writing effective tests, particularly for beginners.

Types of Tests

  • Unit Testing: Tests individual components or functions for correctness.

  • Integration Testing: Ensures that different components or systems work together as expected.

  • System Testing: Tests the complete and integrated software system to evaluate its compliance with the specified requirements.

  • End-to-End (E2E) Testing: Simulates real user scenarios to validate the entire application workflow.

  • Acceptance Testing: Validates that the software meets business requirements and is ready for deployment.

  • Performance Testing: Assesses the speed, responsiveness, and stability of the software under various conditions.

  • Security Testing: Identifies vulnerabilities and ensures the software is secure against attacks.

Prerequisites

Before we dive into testing, make sure you have the following set up:

  • Node.js and npm installed

  • A basic understanding of JavaScript/TypeScript

  • A NestJS application (You can create one using the NestJS CLI or go to NestJS 101)

Setting Up the NestJS Application

If you don't already have a NestJS application, you can create one using the NestJS CLI or go to NestJS 101 to get started with a simple API:

npm install -g @nestjs/cli
nest new nestjs-testing-app
cd nestjs-testing-app

Introduction to Testing in NestJS

NestJS uses Jest as the default testing framework. Jest is a powerful and flexible testing framework that provides a great developer experience with features like zero configuration, snapshot testing, and built-in mocking.

Alternatives of Jest you can configure:

  • Mocha - Highly configurable, supports various assertion libraries

  • Jasmine - Behavior-driven, simple, built-in assertions

  • Ava - Concurrent test running, minimalistic, async/await support

Testing Files Structure

By default, NestJS places test files alongside the source files with an .spec.ts extension. This helps in keeping the tests close to the code they are testing.

Writing Unit Tests

Unit tests focus on testing individual units of code, such as functions or classes, in isolation from other parts of the application.

Example: Testing a Service

Let's start by writing a unit test for a simple service. Consider the following cats.service.ts:

import { Injectable } from '@nestjs/common';

@Injectable() // Marks the class as a provider that can be injected into other components.
export class CatsService {
  private readonly cats = []; // Private property to store an array of cats.

  // Method to create a new cat and add it to the array.
  create(cat) {
    this.cats.push(cat); // Adds the new cat to the cats array.
  }

  // Method to retrieve all the cats.
  findAll() {
    return this.cats; // Returns the entire cats array.
  }
}

To test this service, create a cats.service.spec.ts file:

// Import necessary modules and classes from @nestjs/testing
import { Test, TestingModule } from '@nestjs/testing';
import { CatsService } from './cats.service';

// Describe the test suite for CatsService
describe('CatsService', () => {
  let service: CatsService;

  // Before each test, set up the testing module and get the CatsService instance
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      // Provide the CatsService for testing
      providers: [CatsService],
    }).compile();

    // Get the CatsService instance from the testing module
    service = module.get<CatsService>(CatsService);
  });

  // Test case to check if the service is defined
  it('should be defined', () => {
    // Expect the service to be defined
    expect(service).toBeDefined();
  });

  // Test case to check if a cat can be created
  it('should create a cat', () => {
    // Define a sample cat object
    const cat = { name: 'Tom' };
    // Call the create method to add the cat to the service
    service.create(cat);
    // Expect the created cat to be in the list of all cats
    expect(service.findAll()).toContain(cat);
  });

  // Test case to check if all cats can be returned
  it('should return all cats', () => {
    // Define two sample cat objects
    const cat1 = { name: 'Tom' };
    const cat2 = { name: 'Jerry' };
    // Call the create method to add both cats to the service
    service.create(cat1);
    service.create(cat2);
    // Expect the findAll method to return both cats in an array
    expect(service.findAll()).toEqual([cat1, cat2]);
  });
});

Running the Tests

You can run the tests using the following command:

npm run test

Writing Integration Tests

Integration tests focus on testing the interactions between different parts of the application. In NestJS, this often involves testing the controllers and their interactions with services.

Example: Testing a Controller

Consider the following cats.controller.ts:

import { Controller, Get, Post, Body } from '@nestjs/common';
import { CatsService } from './cats.service'; // Import the CatsService to be used in the controller

// Define the route path for this controller as 'cats'
@Controller('cats')
export class CatsController {
  // Inject CatsService into the controller via dependency injection
  constructor(private readonly catsService: CatsService) {} 

  // Define a POST route to create a new cat
  @Post()
  create(@Body() cat) {
    // Call the create method of CatsService with the provided cat data
    this.catsService.create(cat);
  }

  // Define a GET route to retrieve all cats
  @Get()
  findAll() {
    // Call the findAll method of CatsService to get the list of all cats
    return this.catsService.findAll();
  }
}

To test this controller, create a cats.controller.spec.ts file:

import { Test, TestingModule } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let controller: CatsController;
  let service: CatsService;

  // This block is executed before each test in the describe block
  beforeEach(async () => {
    // Create a testing module with the CatsController and CatsService
    const module: TestingModule = await Test.createTestingModule({
      controllers: [CatsController],
      providers: [CatsService],
    }).compile();

    // Retrieve instances of CatsController and CatsService from the testing module
    controller = module.get<CatsController>(CatsController);
    service = module.get<CatsService>(CatsService);
  });

  // Test to check if the controller is defined
  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  // Test to check if the create method of the controller calls the create method of the service
  it('should create a cat', () => {
    const cat = { name: 'Tom' };
    // Spy on the create method of the service and provide a mock implementation
    jest.spyOn(service, 'create').mockImplementation(() => {});
    // Call the create method of the controller
    controller.create(cat);
    // Check if the create method of the service was called with the correct argument
    expect(service.create).toHaveBeenCalledWith(cat);
  });

  // Test to check if the findAll method of the controller returns all cats
  it('should return all cats', () => {
    const cats = [{ name: 'Tom' }, { name: 'Jerry' }];
    // Spy on the findAll method of the service and provide a mock implementation
    jest.spyOn(service, 'findAll').mockImplementation(() => cats);
    // Check if the findAll method of the controller returns the correct data
    expect(controller.findAll()).toEqual(cats);
  })
})

Advanced Testing Techniques

Mocking Dependencies

In real-world applications, services often depend on other services or databases. To isolate the unit being tested, we can mock these dependencies.

Consider the following cats.service.ts with a dependency on CatsRepository:

import { Injectable } from '@nestjs/common';
import { CatsRepository } from './cats.repository';

// The @Injectable decorator marks the CatsService class as a provider that can be injected into other components or services.
@Injectable()
export class CatsService {
  // Injecting the CatsRepository into the CatsService via the constructor
  constructor(private readonly catsRepository: CatsRepository) {}

  // Method to create a new cat entry in the repository
  create(cat) {
    // Calls the save method on the CatsRepository to persist the cat object
    this.catsRepository.save(cat);
  }

  // Method to retrieve all cat entries from the repository
  findAll() {
    // Calls the findAll method on the CatsRepository to get all cat objects
    return this.catsRepository.findAll();
  }
}

To test this service, create a cats.service.spec.ts file:

// Import necessary modules from NestJS for testing
import { Test, TestingModule } from '@nestjs/testing';
// Import the service and repository to be tested
import { CatsService } from './cats.service';
import { CatsRepository } from './cats.repository';

// Describe the test suite for the CatsService
describe('CatsService', () => {
  let service: CatsService; // Declare a variable for the service
  let repository: CatsRepository; // Declare a variable for the repository

  // Before each test, set up the testing module and initialize the service and repository
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      // Provide the CatsService and a mock CatsRepository
      providers: [
        CatsService,
        {
          provide: CatsRepository,
          useValue: {
            save: jest.fn(), // Mock implementation for save method
            findAll: jest.fn(), // Mock implementation for findAll method
          },
        },
      ],
    }).compile();

    // Get instances of the service and repository from the testing module
    service = module.get<CatsService>(CatsService);
    repository = module.get<CatsRepository>(CatsRepository);
  });

  // Test to ensure the service is defined
  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  // Test the create method of the service
  it('should create a cat', () => {
    const cat = { name: 'Tom' }; // Define a sample cat object
    service.create(cat); // Call the create method with the sample cat
    expect(repository.save).toHaveBeenCalledWith(cat); // Check if the save method was called with the correct argument
  });

  // Test the findAll method of the service
  it('should return all cats', () => {
    const cats = [{ name: 'Tom' }, { name: 'Jerry' }]; // Define a sample list of cats
    jest.spyOn(repository, 'findAll').mockImplementation(() => cats); // Mock the findAll method to return the sample list
    expect(service.findAll()).toEqual(cats); // Check if the findAll method returns the correct list
  });
});

E2E Testing

End-to-end (E2E) tests verify the complete flow of the application, from the user interface to the backend services. NestJS supports E2E testing with Jest.

Setting Up E2E Tests

NestJS provides a sample E2E test in the test folder. You can modify it to suit your needs.

// Import necessary testing modules and utilities from NestJS
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest'; // Import supertest to make HTTP assertions
import { AppModule } from './../src/app.module'; // Import the main application module

// Describe the E2E test suite for AppController
describe('AppController (e2e)', () => {
  let app: INestApplication; // Define a variable to hold the NestJS application instance

  // Before all tests, initialize the NestJS application
  beforeAll(async () => {
    // Create a testing module with the AppModule
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    // Create a NestJS application from the testing module
    app = moduleFixture.createNestApplication();
    await app.init(); // Initialize the application
  });

  // Define a test case for the root route
  it('/ (GET)', () => {
    return request(app.getHttpServer()) // Use supertest to make a request to the app's HTTP server
      .get('/') // Make a GET request to the root route
      .expect(200) // Expect the HTTP status code to be 200
      .expect('Hello World!'); // Expect the response body to be 'Hello World!'
  });

  // After all tests, close the NestJS application
  afterAll(async () => {
    await app.close(); // Close the application
  });
});

Running E2E Tests

You can run E2E tests using the following command:

npm run test:e2e

Considerations When Writing NestJS Tests

  1. Isolation: Ensure that unit tests isolate the component being tested. Mock dependencies to avoid testing multiple units at once.

  2. Coverage: Aim for high test coverage but focus on testing critical parts of your application first.

  3. Readability: Write tests that are easy to read and understand. Use descriptive test names and clear assertions.

  4. Maintainability: Keep tests maintainable by refactoring them alongside your code. Avoid brittle tests that break with minor changes in the code.

  5. Speed: Keep unit tests fast by avoiding external dependencies like databases or network calls. Integration and E2E tests can be slower but should still be optimized for performance.

Testing is an essential part of software development, ensuring that your code is reliable and maintainable. By following these practices and continuously writing tests, you can build robust NestJS applications that stand the test of time.

Stay tuned for more tutorials and deep dives into the world of NestJS!

Did you find this article valuable?

Support Nicanor Talks Web by becoming a sponsor. Any amount is appreciated!