Mocking our MongoDB while testing in NestJS

Sometimes when we are creating our tests files we start thinking of how are we going to test our database models, some people say that we just need to mock the driver (really difficult to mock all the driver I think), others just say that we need to use a real DB which is created just for testing... I like to do both but in this guide I will tell you how to do the second option without the need of a dedicated DB for testing.

This approach that I'm going to use works really well with the framework itself AND in a continuous integration flow.

Is important that you know a little about testing in NestJS (if not, you can learn more about it in their website here). NestJS uses Jest as default so we are going to use it and we will handle our connection with TypeORM since is the best fit in our structure.

Dependencies

NestJS almost includes everything we need to do our tests, we only need to install the library we are going to use to work with MongoDB (also the MongoDB driver of course) and one extra library to mock MongoDB.

First we start installing TypeORM (is possible to use Mongoose too but as I said, I think TypeORM is better for this structure) and the NestJS package for it.

npm i @nestjs/typeorm typeorm mongodb

Now we will install a package to mock MongoDB

npm i -D mongodb-memory-server

Actually, we are not currently mocking MongoDB... We are installing a MongoDB in our node_modules folder. If you want you can share the same DB with others projects (to save disk space), check here for more info here

Start our configuration

We will create a DB module only for testing (this way we follow the same structure of NestJS), in this module we are going to start our MongoDB and also we will have some objects that will help us to mock some processes.

Let's start with the DB module:

db-test.module.ts

import { TypeOrmModule } from '@nestjs/typeorm';
import { MongoMemoryServer } from 'mongodb-memory-server';

const mongod = new MongoMemoryServer();

export default (customOpts: any = {}) => TypeOrmModule.forRootAsync({
  useFactory: async () => {
    const port = await mongod.getPort();
    const database = await mongod.getDbName();

    return {
      type: 'mongodb',
      host: '127.0.0.1',
      port,
      database,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      ...customOpts,
    };
  },
});

This default export do two things: It creates a dynamic module with the help of the module TypeOrmModule exported from @nestjs/typeorm, and at the same time it creates a new connection to the MongoDB that lives in our node_modules with the package mongodb-memory-server.

Something important to mention, I'm returning a function because we will need to send a parameter to the connection so we can do parallel testing, I will explain that later in this article.

Let's create a model and a service to test:

user.entity.ts

import { Entity, Column, ObjectIdColumn, BeforeInsert, ObjectID, BeforeUpdate } from 'typeorm';

export interface IUser {
  id?: ObjectID;
  firstName: string;
  lastName: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

@Entity()
export class UserEntity {
  @ObjectIdColumn()
  id: IUser['id'];

  @Column()
  firstName: IUser['firstName'];

  @Column()
  lastName: IUser['lastName'];

  @Column()
  email: IUser['email'];

  @Column()
  createdAt: IUser['createdAt'];

  @Column()
  updatedAt: IUser['updatedAt'];

  @BeforeInsert()
  setDefaultValue() {
    const newDate = new Date();
    this.createdAt = newDate;
    this.updatedAt = newDate;
  }

  @BeforeUpdate()
  updateDefaultValues() {
    this.updatedAt = new Date();
  }
}

user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import { IUser, UserEntity } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(UserEntity) private readonly userRepository: Repository<UserEntity>,
  ) { }

  create(initialValue: Partial<IUser> = {}) {
    return this.userRepository.create(initialValue);
  }

  async save(userEntity: UserEntity) {
    return this.userRepository.save(userEntity);
  }
}

Here we have both a basic service and a basic entity for our Users . To keep things simple we are just creating and saving our users but we can do everything we want and test it.

Creating our test file

Here I'm going to paste all the file and then we will review each part of the file to understand what we are doing.

users.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UserEntity } from './user.entity';
import DbModule from '../db-test.module';

const testUser = {
  email: 'test@test.com',
  firstName: 'earrieta',
  lastName: 'dev',
};

const wait = time => new Promise(resolve => setTimeout(() => resolve(time), time));

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [
        DbModule({
          name: (new Date().getTime() * Math.random()).toString(16), // <-- This is to have a "unique" name for the connection
        }),
        TypeOrmModule.forFeature([
          UserEntity,
        ]),
      ],
      providers: [
        UsersService,
      ],
    }).compile();

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

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

  it('should return a new entity', () => {
    const user = service.create(testUser);

    expect(user).toEqual(testUser);
    expect(user instanceof UserEntity).toBe(true);
  });

  it('should save the user and add the createdAt and savedAt fields', async () => {
    const user = service.create(testUser);
    await service.save(user);

    expect(user.createdAt).toBeTruthy();
    expect(user.updatedAt).toBeTruthy();
  });

  it('should update the updatedAt field after an update (with the save method of the service)', async () => {
    const user = service.create(testUser);
    expect(user.updatedAt).not.toBeTruthy();

    await service.save(user);
    expect(user.updatedAt).toBeTruthy();
    expect(user.createdAt.getTime()).toBe(user.updatedAt.getTime());

    const actualUpdate = user.updatedAt;
    user.email = 'test2@test2.com';
    expect(user.updatedAt.getTime()).toBe(actualUpdate.getTime());

    await service.save(user);
    await wait(20); // <-- this is just to simulate an update after "some time"
    expect(user.updatedAt.getTime()).toBeGreaterThan(actualUpdate.getTime());
  });
});

Now let's see what we have:

We have our test module for the DB
const module: TestingModule = await Test.createTestingModule({
  imports: [
    DbModule({
      name: (new Date().getTime() * Math.random()).toString(16),
    }),
    TypeOrmModule.forFeature([
      UserEntity,
    ]),
  ],
  ....
}).compile();

As you can see, we define a parameter name with some random values; We are giving a name to our connection and that way we avoid issues with duplicated names in our tests. At the same time this module give us the access to MongoDB so we can use it just like we do in our normal (non-testing) flow.

Then we define our Entity normally because we are using a real Database so in our application this works like a non-test environment.

But... Why?

The reason of this solution comes in this block of code (and the next one too):

it('should save the user and add the createdAt and savedAt fields', async () => {
  const user = service.create(testUser);
  await service.save(user);

  expect(user.createdAt).toBeTruthy();
  expect(user.updatedAt).toBeTruthy();
});

Here we are fully testing our model because the fields createdAt and updatedAt only will be in our object after we save it with our service... In theses cases were we want a full test is where this method shines.

Last words

As always in software, this is not the perfect solution for all the cases, we always need to understand how our site/flow works and then we will know what is the best in our process.

I use this method for end-2-end testing and for unit testing I only mock some basic functionality of the models.

Happy coding!

No Comments Yet