NestJS: Mocking Databases for Efficient Tests

shubham_kadam

Shubham Kadam

Posted on November 4, 2023

NestJS: Mocking Databases for Efficient Tests

Introduction

In NestJS, a popular Node.js framework, unit testing plays a crucial role in maintaining the reliability and quality of your codebase.

One common challenge when writing unit tests for NestJS applications is dealing with database interactions, particularly when using TypeORM as the Object-Relational Mapping (ORM) library or Mongoose module for MongoDB. It's essential to isolate your tests from the actual database to make them fast, reliable, and independent of external dependencies.

To address this challenge, you need to mock database calls in your unit tests. This involves simulating database interactions without actually hitting the database. By doing so, you can control the data used in your tests, improve test performance, and ensure that your tests remain consistent, even if the database schema or content changes.

In our upcoming blog post, we will explore the techniques and best practices for effectively mocking Mongoose database calls in NestJS unit tests, enabling you to write tests that are efficient, maintainable, and dependable.


Prerequisite

Since our primary focus here is to talk about unit tests and database mocking, this post will assume that you have a NestJS project setup ready, with one or more endpoints communicating with underlying Mongo database (or it could be any other SQL/NoSQL db).


Lets get started

Consider we have an Order management system, so it would naturally have an orders module which handles orders. In NestJS, orders controller will create api endpoints to allow users to perform db operations.

orders.controler.ts

import {
  Controller,
  Get,
  UseFilters,
  UseInterceptors,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseIntPipe,
  DefaultValuePipe,
} from '@nestjs/common';
import {ApiBearerAuth, ApiQuery, ApiTags} from '@nestjs/swagger';
import {OrdersService} from './orders.service';
import {Order} from './schemas/order.schema';
import {CreateOrderDto} from './dto/create-order.dto';
import {UpdateOrderDto} from './dto/update-order.dto';

@ApiBearerAuth()
@ApiTags('orders')
@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  /**
   * /POST endpoint
   * Used to add new order info
   * @param {CreateOrderDto} createOrderDto
   */
  @Post()
  async create(@Body() createOrderDto: CreateOrderDto): Promise<Order> {
    return await this.ordersService.create(createOrderDto);
  }

  /**
   * /GET endpoint
   * Used to get all orders data
   */
  @Get('all')
  async findAll(): Promise<{data: Orders[]}> {
    return await this.ordersService.findAll();
  }

  /**
   * /GET endpoint
   * Used to get order data by id
   * @param {string} id
   */
  @Get(':id')
  async findOne(@Param('id') id: string): Promise<Order> {
    return await this.ordersService.findOne(id);
  }

  /**
   * /PATCH endpoint
   * Used to update order info
   * @param {string} id
   */
  @Patch(':id')
  async update(@Param('id') id: string, @Body() updateOrderDto: UpdateOrderDto): Promise<Order> {
    return await this.ordersService.update(id, updateOrderDto);
  }

  /**
   * /DELETE endpoint
   * Used to delete order
   * @param {string} id
   */
  @Delete(':id')
  async delete(@Param('id') id: string): Promise<Order> {
    return await this.ordersService.delete(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

Since controller does not have any databse calls, writing unit test for it is straight forward.

orders.controller.spec.ts

import {Test, TestingModule} from '@nestjs/testing';
import {LoggerModule} from 'nestjs-pino';
import {OrdersController} from './orders.controller';
import {OrdersService} from './orders.service';
import {mockOrder, mockId} from '../utils/test-utils';

const mockId = '123';
const mockOrder: CreateOrderDto = {
  // ...fake order values
}

const mockOrderService = {
  create: jest.fn().mockReturnValue(mockOrder),
  findAll: jest.fn().mockReturnValue([mockOrder]),
  findOne: jest.fn().mockReturnValue(mockOrder),
  update: jest.fn().mockReturnValue(mockOrder),
  delete: jest.fn().mockReturnValue(mockOrder),
};
describe('OrderController', () => {
  let controller: OrdersController;
  let service: OrdersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [LoggerModule.forRoot()],
      controllers: [OrdersController],
      providers: [
        {
          provide: OrdersService,
          useValue: mockOrderService,
        },
      ],
    }).compile();

    controller = module.get<OrdersController>(OrdersController);
    service = module.get<OrdersService>(OrdersService);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it('should create order data', async () => {
    const expectedOutput = await controller.create(mockOrder);
    expect(service.create).toHaveBeenCalledTimes(1);
    expect(service.create).toHaveBeenCalledWith(mockOrder);
    expect(expectedOutput).toEqual(mockOrder);
  });

  it('should find all order data', async () => {
    const expectedOutput = await controller.findAll();
    expect(service.findAll).toHaveBeenCalledTimes(1);
    expect(expectedOutput).toEqual([mockOrder]);
  });

  it('should find order data by id', async () => {
    const expectedOutput = await controller.findOne(mockId);
    expect(service.findOne).toHaveBeenCalledTimes(1);
    expect(service.findOne).toHaveBeenCalledWith(mockId);
    expect(expectedOutput).toEqual(mockOrder);
  });

  it('should update order data by id and payload', async () => {
    const expectedOutput = await controller.update(mockId, mockOrder);
    expect(service.update).toHaveBeenCalledTimes(1);
    expect(service.update).toHaveBeenCalledWith(mockId, mockOrder);
    expect(expectedOutput).toEqual(mockOrder);
  });

  it('should delete order data by id', async () => {
    const expectedOutput = await controller.delete(mockId);
    expect(service.delete).toHaveBeenCalledTimes(1);
    expect(service.delete).toHaveBeenCalledWith(mockId);
    expect(expectedOutput).toEqual(mockOrder);
  });
});
Enter fullscreen mode Exit fullscreen mode

Order module's service file is responsible for handling business logic, which also means managing calls to mongo database.

From the documentation

Model injection
With Mongoose, everything is derived from a Schema. Each schema maps to a MongoDB collection and defines the shape of the documents within that collection. Schemas are used to define Models. Models are responsible for creating and reading documents from the underlying MongoDB database.

Below, we have an Order schema and we are going to define a model using this schema. This model is responsible for performing read/write operations in MongoDB.

orders.service.ts

import {Injectable, NotFoundException} from '@nestjs/common';
import {InjectModel} from '@nestjs/mongoose';
import {Model} from 'mongoose';
import {Order, OrderDocument} from './schemas/order.schema';
import {CreateOrderDto} from './dto/create-order.dto';
import {UpdateOrderDto} from './dto/update-order.dto';
import {IOrders} from '../interface/data-interface/orders.interface';

// fields to exclude in api response
export const EXCLUDE_FIELDS = '-_id -__v';

@Injectable()
export class OrdersService {
  constructor(@InjectModel(Order.name) private orderModel: Model<OrderDocument>) {}

  async create(createOrderDto: CreateOrderDto): Promise<Order> {
    const order = await this.orderModel.create(createOrderDto);
    return order;
  }

  async findAll(): Promise<IOrders> {
    const data = await this.orderModel.find().select(EXCLUDE_FIELDS).exec();
    return {data};
  }

  async findOne(orderId: string): Promise<Order> {
    const doc = await this.orderModel.findOne({orderId}).select(EXCLUDE_FIELDS).exec();
    if (!doc) throw new NotFoundException(`Order with id '${orderId}' not found!`);
    return doc;
  }

  async update(orderId: string, updateOrderDto: UpdateOrderDto): Promise<Order> {
    const doc = await this.orderModel.findOne({orderId}).exec();

    if (!doc) throw new NotFoundException(`Failed to update order! Order with id '${orderId}' not found.`);

    Object.assign(doc, updateOrderDto);
    const order = await doc.save();
    return order;
  }

  async delete(orderId: string): Promise<Order> {
    const doc = await this.orderModel.findOneAndDelete({orderId}).select(EXCLUDE_FIELDS).exec();
    if (!doc) throw new NotFoundException(`Failed to delete order! Order with id '${orderId}' not found.`);
    return doc;
  }
}
Enter fullscreen mode Exit fullscreen mode

Like I said before, we have defined a Mongoose Model orderModel which performs db operations and this is the model we are going to mock while writing unit test

Here's how you would mock this model with all the necessary methods

class MockedOrderModel {
  constructor(private _: any) {}
  new = jest.fn().mockResolvedValue({});
  static save = jest.fn().mockResolvedValue(mockOrder);
  static find = jest.fn().mockReturnThis();
  static create = jest.fn().mockReturnValue(mockOrder);
  static findOneAndDelete = jest.fn().mockImplementation((id: string) => {
    if (id == mockIdError) throw new NotFoundException();
    return this;
  });
  static exec = jest.fn().mockReturnValue(mockOrder);
  static select = jest.fn().mockReturnThis();
  static findOne = jest.fn().mockImplementation((id: string) => {
    if (id == mockIdError) throw new NotFoundException();
    return this;
  });
}
Enter fullscreen mode Exit fullscreen mode

And since we are mocking the DB methods, when a save method gets called, save method from MockedOrderModel would be invoked. We have also added few more conditions while mocking these methods to invoke erroneous conditions and have better test coverage.

We need to pass MockedOrderModel in Test.createTestingModule as a provider and we are all set.

Note: Refer NestJS testing doc for more info on Test module

Here's the unit test file
orders.service.spec.ts

import {Test, TestingModule} from '@nestjs/testing';
import {getModelToken} from '@nestjs/mongoose';
import {HttpStatus, NotFoundException} from '@nestjs/common';
import {OrdersService} from './orders.service';
import {Order} from './schemas/order.schema';

const mockOrder = {} as Order;
const mockAllOrder = {data: []};
const mockId = '123';
const mockIdError = 'error';
export const EXCLUDE_FIELDS = '-_id -__v';

class MockedOrderModel {
  constructor(private _: any) {}
  new = jest.fn().mockResolvedValue({});
  static save = jest.fn().mockResolvedValue(mockOrder);
  static find = jest.fn().mockReturnThis();
  static create = jest.fn().mockReturnValue(mockOrder);
  static findOneAndDelete = jest.fn().mockImplementation((id: string) => {
    if (id == mockIdError) throw new NotFoundException();
    return this;
  });
  static exec = jest.fn().mockReturnValue(mockOrder);
  static select = jest.fn().mockReturnThis();
  static findOne = jest.fn().mockImplementation((id: string) => {
    if (id == mockIdError) throw new NotFoundException();
    return this;
  });
}
describe('OrdersService', () => {
  let service: OrdersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        {
          provide: getModelToken(Order.name),
          useValue: MockedOrderModel,
        },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should create new order', async () => {
    const expectedOutput = await service.create(mockOrder);
    expect(MockedOrderModel.create).toHaveBeenCalledTimes(1);
    expect(expectedOutput).toEqual(mockOrder);
  });

  it('should find all orders', async () => {
    const expectedOutput = await service.findAll();
    expect(MockedOrderModel.find).toHaveBeenCalledTimes(1);
    expect(MockedOrderModel.exec).toHaveBeenCalledTimes(1);
    expect(MockedOrderModel.save).toHaveBeenCalledTimes(1);
    expect(MockedOrderModel.save).toBeCalledWith(EXCLUDE_FIELDS);
    expect(expectedOutput).toEqual(mockAllOrder);
  });

  describe('Get Order', () => {
    it('should find order by id', async () => {
      const expectedOutput = await service.findOne(mockId);
      expect(MockedOrderModel.findOne).toHaveBeenCalledTimes(1);
      expect(MockedOrderModel.findOne).toBeCalledWith(mockId);
      expect(MockedOrderModel.exec).toHaveBeenCalledTimes(1);
      expect(MockedOrderModel.save).toHaveBeenCalledTimes(1);
      expect(MockedOrderModel.save).toBeCalledWith(EXCLUDE_FIELDS);
      expect(expectedOutput).toEqual(mockOrder);
    });

    it('should throw NotFoundException', async () => {
      try {
        await service.findOne(mockIdError);
      } catch (error: any) {
        expect(error.message).toEqual('Not Found');
        expect(error.status).toEqual(HttpStatus.NOT_FOUND);
        expect(error.name).toEqual('NotFoundException');
      }
    });
  });
  // ... unit test for update and delete functionality will have similar implementation
});
Enter fullscreen mode Exit fullscreen mode

Conclusion:

Unit testing is a vital part of NestJS development. Mocking database calls is key to making your tests fast, reliable, and independent of external dependencies. Whether you're using TypeORM or Mongoose for MongoDB, the techniques shared in this post help you write efficient, maintainable, and dependable unit tests. By mastering database mocking, you're enhancing the quality and resilience of your NestJS applications. Happy testing!

💖 💪 🙅 🚩
shubham_kadam
Shubham Kadam

Posted on November 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related