Testing NestJS Applications

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


End-to-End (E2E) Testing with Supertest in NestJS

What is End-to-End (E2E) Testing?

End-to-End (E2E) testing is a methodology used to test an application from start to finish, simulating real user scenarios. It verifies that all different components of the system work together correctly, ensuring data integrity and a seamless user experience. In the context of a NestJS application, E2E tests will interact with your API endpoints, database, and potentially even external services, just like a real user would.

Why Use Supertest for E2E Testing in NestJS?

Supertest is a Node.js library that provides a high-level abstraction for making HTTP requests. It's built on top of Superagent and is specifically designed for testing HTTP servers. It's an ideal choice for E2E testing in NestJS because:

  • Easy to Use: Supertest provides a fluent API that makes writing tests concise and readable.
  • Integration with Testing Frameworks: It integrates seamlessly with popular testing frameworks like Jest and Mocha.
  • Simulates HTTP Requests: Allows you to easily simulate various HTTP requests (GET, POST, PUT, DELETE, etc.) with different headers, payloads, and parameters.
  • Assertion Capabilities: Offers built-in assertion capabilities to verify response status codes, headers, and body content.

Setting Up Your NestJS Application for E2E Testing

Before you start writing E2E tests, you need to configure your NestJS application for testing. Here's a typical setup using Jest and Supertest:

  1. Install Dependencies:
    npm install --save-dev supertest @types/supertest @nestjs/testing jest ts-jest @types/jest
  2. Configure Jest:

    Create a jest-e2e.config.js file in the root of your project (or modify your existing Jest config) with the following:

    module.exports = {
      moduleFileExtensions: ['js', 'json', 'ts'],
      rootDir: '.',
      testEnvironment: 'node',
      testRegex: '.e2e-spec.ts$',
      transform: {
        '^.+\\.(t|j)s$': 'ts-jest',
      },
      moduleNameMapper: {
        '^@/(.*)$': '<rootDir>/src/$1',
      },
    };
  3. NestJS Testing Module:

    You'll typically create a separate testing module that imports your main application module. This allows you to easily override dependencies and configure your test environment. For instance, you can mock services to isolate components or use an in-memory database for faster testing.

    Example:

    // src/app.e2e-spec.ts
    import { Test, TestingModule } from '@nestjs/testing';
    import { INestApplication } from '@nestjs/common';
    import * as request from 'supertest';
    import { AppModule } from './app.module';
    
    describe('AppController (e2e)', () => {
      let app: INestApplication;
    
      beforeEach(async () => {
        const moduleFixture: TestingModule = await Test.createTestingModule({
        imports: [AppModule],
      }).compile();
    
      app = moduleFixture.createNestApplication();
      await app.init();
    });
    
    it('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/')
        .expect(200)
        .expect('Hello World!');
    });
    });

Writing E2E Tests with Supertest

Let's break down how to write E2E tests using Supertest, focusing on different aspects of your NestJS application:

1. Testing API Endpoints

This is the most common use case. You'll simulate HTTP requests to your API endpoints and verify the responses.

// src/todos/todos.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module'; // Adjust path if needed

describe('TodosController (e2e)', () => {
  let app: INestApplication;

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

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

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

it('/todos (GET) - Should return an array of todos', () => {
  return request(app.getHttpServer())
    .get('/todos')
    .expect(200)
    .expect('Content-Type', /json/)
    .then((response) => {
      expect(Array.isArray(response.body)).toBe(true);
      // Add more specific assertions about the todo objects here
    });
});

it('/todos (POST) - Should create a new todo', () => {
  const newTodo = { title: 'Buy groceries', description: 'Need milk, eggs, and bread' };

  return request(app.getHttpServer())
    .post('/todos')
    .send(newTodo)
    .expect(201)
    .expect('Content-Type', /json/)
    .then((response) => {
      expect(response.body).toBeDefined();
      expect(response.body.title).toBe(newTodo.title);
      // You might also want to check if the ID is a valid UUID
    });
});


it('/todos/:id (GET) - Should return a specific todo', async () => {
  // First, create a todo (POST) so we have an ID to retrieve
  const newTodo = { title: 'Test Todo', description: 'For getting by id test' };
  const createResponse = await request(app.getHttpServer())
    .post('/todos')
    .send(newTodo);

  expect(createResponse.status).toBe(201);

  const todoId = createResponse.body.id; // Assuming the POST request returns an ID
  return request(app.getHttpServer())
    .get(`/todos/${todoId}`)
    .expect(200)
    .expect('Content-Type', /json/)
    .then((response) => {
      expect(response.body).toBeDefined();
      expect(response.body.id).toBe(todoId);
      expect(response.body.title).toBe(newTodo.title);
    });
});

it('/todos/:id (DELETE) - Should delete a specific todo', async () => {
      // First, create a todo (POST) so we have an ID to retrieve
      const newTodo = { title: 'Todo to delete', description: 'For deletion test' };
      const createResponse = await request(app.getHttpServer())
        .post('/todos')
        .send(newTodo);

      expect(createResponse.status).toBe(201);

      const todoId = createResponse.body.id; // Assuming the POST request returns an ID

      return request(app.getHttpServer())
        .delete(`/todos/${todoId}`)
        .expect(204); // Expect a 204 No Content response on successful deletion

});

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

2. Testing Database Interactions

E2E tests should also verify that your application correctly interacts with the database. This means ensuring data is created, read, updated, and deleted as expected. Common strategies include:

  • Setting up a test database: Use a separate database (e.g., a dedicated test database or an in-memory database like SQLite) for your E2E tests to prevent data corruption in your development or production environments.
  • Cleaning up after tests: Implement a mechanism to clean up the database after each test or test suite to ensure a consistent starting state for subsequent tests. This is typically done in the afterEach or afterAll hooks.

Example (Illustrative):

// (Illustrative - Adjust to your database setup)

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Todo } from '../src/todos/entities/todo.entity'; // Adjust path

describe('TodosController (e2e) - Database Tests', () => {
  let app: INestApplication;
  let todoRepository: Repository<Todo>;

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

  app = moduleFixture.createNestApplication();
  await app.init();

  todoRepository = moduleFixture.get(getRepositoryToken(Todo));
});

beforeEach(async () => {
    //Clear todos before each test.
    await todoRepository.delete({});
});


it('Should save a todo to the database and retrieve it correctly', async () => {
  const newTodo = { title: 'Database Test Todo', description: 'Testing database save and retrieve' };

  const createResponse = await request(app.getHttpServer())
    .post('/todos')
    .send(newTodo)
    .expect(201);

  const todoId = createResponse.body.id;

  const retrievedTodo = await todoRepository.findOneBy({ id: todoId }); // or findOne({ where: {id: todoId}})  for older TypeORM

  expect(retrievedTodo).toBeDefined();
  expect(retrievedTodo.title).toBe(newTodo.title);
  expect(retrievedTodo.description).toBe(newTodo.description);
});

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

3. Testing Overall Application Flow

E2E tests should also simulate complete user flows, ensuring that different parts of your application work together seamlessly. For example:

  • User Registration and Login: Test the entire flow of user registration, email verification (if applicable), login, and authentication.
  • Order Placement: Test the flow of adding items to a cart, proceeding to checkout, providing shipping information, making a payment, and confirming the order.

Example (Conceptual):

// (Conceptual Example - Adapt to your application's flow)
    it('Should successfully register a user, log them in, and access a protected resource', async () => {
    // 1. Register User
    const registrationData = { /* ... user registration details ... */ };
    const registrationResponse = await request(app.getHttpServer())
      .post('/auth/register')
      .send(registrationData)
      .expect(201);

    // 2. Login User
    const loginData = { /* ... user login credentials ... */ };
    const loginResponse = await request(app.getHttpServer())
      .post('/auth/login')
      .send(loginData)
      .expect(200);

    const accessToken = loginResponse.body.access_token;

    // 3. Access Protected Resource
    await request(app.getHttpServer())
      .get('/protected')
      .set('Authorization', `Bearer ${accessToken}`)
      .expect(200)
      .expect('Content-Type', /json/)
      .then((response) => {
        // Assertions about the protected resource
      });
  });

Best Practices for E2E Testing

  • Keep Tests Isolated: Each test should be independent and not rely on the state of previous tests. Use beforeEach and afterEach hooks to set up and tear down the test environment.
  • Use Meaningful Test Names: Give your tests descriptive names that clearly explain what they are testing.
  • Test Edge Cases and Error Handling: Don't just test the happy path. Test error conditions, invalid input, and other edge cases to ensure your application handles them gracefully.
  • Use Environment Variables: Store sensitive information like database credentials in environment variables and access them in your tests.
  • Run Tests in a CI/CD Pipeline: Integrate your E2E tests into your CI/CD pipeline to ensure that they are run automatically whenever code changes are made.
  • Mock External Services: If your application relies on external services (e.g., payment gateways, email services), consider mocking them in your E2E tests to avoid relying on their availability and to speed up your tests. NestJS provides powerful dependency injection and testing utilities to facilitate this.