Testing environment with a local Mongo database

Federico Gómez - Sep 10 - - Dev Community

Starting with Typescript and NestJS, and learning about testing and TDD. One of the main difficulties I encountered was mocking. When should you use mocks, and when shouldn't you?

Introduction

In my case, I am developing an API in NestJS with the functionalities of a ToDo List App. You can create tasks, list tasks by ID or filters, update tasks, and delete tasks. All necessary data is stored in MongoDB Atlas. But is it a good idea to use the database for testing?

How to set up a testing environment

When testing the API, it's a good idea to use a local database with dummy data. One of the advantages of this practice is avoiding the use of mocks to test functionalities, making the tests as close to production behavior as possible. For this, I am using mongodb-memory-server and node-mongodb-fixtures.
The first package allows us to set up a local MongoDB database, and the second allows us to populate the database with dummy data. Together, both packages enable us to have a local database with data available for testing. The first step is to install the libraries:

npm i mongodb-memory-server node-mongodb-fixtures
Enter fullscreen mode Exit fullscreen mode

Now we can define a class to start the local database and populate it with data. To do this, I will create a new file called TestingDatabase.ts with the following code:

import { MongoMemoryServer } from 'mongodb-memory-server';
import * as mongoose from 'mongoose';
import * as Fixtures from 'node-mongodb-fixtures';

export class TestingDatabase {
  public static createServer = async (): Promise<MongoMemoryServer> =>
    await MongoMemoryServer.create();

  public static async connectAndLoadData(mongodb: MongoMemoryServer) {
    const uri = mongodb.getUri();

    const fixtures = new Fixtures({
      dir: `${__dirname}/fixtures/`,
      filter: '.*',
      mute: true,
    });
    await fixtures
      .connect(uri)
      .then(() => fixtures.unload())
      .then(() => fixtures.load())
      .catch((err) => console.log(err))
      .finally(() => fixtures.disconnect());

    mongoose.set('strictQuery', false);
    return (await mongoose.connect(uri)).connection;
  }

  public static async disconnect(mongodb: MongoMemoryServer) {
    await mongoose.disconnect();
    return await mongodb.stop();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now I can use the connectAndLoadData and disconnect methods to set up and tear down the testing database. But something is missing—populating the database. To do this, I will create a new folder called fixtures where I will define the dummy data. Inside that folder, I will create a new file called tasks.ts with the following code:

import { ObjectId } from 'mongodb';

module.exports = [
  {
    _id: new ObjectId('5c2d57e4a72e8f7843101f30'),
    name: 'Example Task 1',
    description: 'This is an example description for testing',
    code: 'code-example-1',
    statusId: 'CREATED',
    createdAt: '2024-08-23T18:22:19.356+00:00',
    updatedAt: '2024-08-23T18:23:51.225+00:00',
  },
  {
    _id: new ObjectId('66c8d3b7da2c32f49654f2e0'),
    name: 'Example Task 2',
    description: 'This is an example description for testing',
    code: 'code-example-2',
    statusId: 'IN_PROGRESS',
    createdAt: '2024-08-22T18:22:19.356+00:00',
    updatedAt: '2024-08-22T18:23:51.225+00:00',
  },
  {
    _id: new ObjectId('66c8d40bf00bcdef6b997a8a'),
    name: 'Example Task 3',
    description: 'This is an example description for testing',
    code: 'code-example-3',
    statusId: 'FINISHED',
    createdAt: '2024-08-21T18:22:19.356+00:00',
    updatedAt: '2024-08-21T18:23:51.225+00:00',
  },
];
Enter fullscreen mode Exit fullscreen mode

In this case, a collection called tasks will be created (the file name defines this) with 3 documents representing tasks.

Testing an API in NestJS

The testing environment is now ready, with a local database and some sample tasks. All that's left is to write the tests. For this, you need to start the local database and tell NestJS to use it when running the tests. This is where beforeAll, beforeEach and afterAll come into play. If you're not familiar with them, you can check out the Jest Documentation. Here is an example of how to set up the testing environment, both for service and controller tests:

// tasks.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { TasksService } from './tasks.service';
import { Task, TaskSchema } from './schemas/task.schema';
import { TestingDatabase } from './TestingDatabase';

describe('TasksService', () => {
  let service: TasksService;
  let database: MongoMemoryServer;
  let taskModel: Model<Task>;

  beforeAll(async () => {
    database = await TestingDatabase.createServer();
    const connection = await TestingDatabase.connectAndLoadData(database);
    taskModel = connection.model(Task.name, TaskSchema);
  });

  afterAll(async () => await TestingDatabase.disconnect(database));

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

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

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

  describe('when call create method', () => {
    it('should create the new task and return it, when data is correct', async () => {
      const exampleTask = {
        code: 'code-1',
        name: 'Example Task 1',
        description: 'This is an example description',
      };

      const data = await service.create(exampleTask);

      expect(data).toEqual(
        expect.objectContaining({
          id: expect.any(String),
          code: exampleTask.code,
          name: exampleTask.name,
          description: exampleTask.description,
          status: {
            id: 'CREATED',
            name: 'Creada',
          },
          createdAt: expect.stringMatching(/^\d{2}-\d{2}-\d{4} \d{2}:\d{2}$/),
          updatedAt: expect.stringMatching(/^\d{2}-\d{2}-\d{4} \d{2}:\d{2}$/),
        }),
      );

      await taskModel.findOneAndDelete({ code: exampleTask.code });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
// tasks.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { Task, TaskSchema } from './schemas/task.schema';
import { TestingDatabase } from './TestingDatabase';

describe('TasksController', () => {
  let controller: TasksController;
  let service: TasksService;
  let database: MongoMemoryServer;
  let taskModel: Model<Task>;

  beforeAll(async () => {
    database = await TestingDatabase.createServer();
    const connection = await TestingDatabase.connectAndLoadData(database);
    taskModel = connection.model(Task.name, TaskSchema);
  });

  afterAll(async () => await TestingDatabase.disconnect(database));

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [TasksController],
      providers: [
        TasksService,
        {
          provide: getModelToken(Task.name),
          useValue: taskModel,
        },
      ],
    }).compile();

    controller = module.get<TasksController>(TasksController);
    service = module.get<TasksService>(TasksService);
  });

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

  describe('when call /tasks (POST)', () => {
    it('should respond with the created task', async () => {
      const exampleTask = {
        code: 'code-2',
        name: 'Example Task 1',
        description: 'This is an example description for testing',
      };

      expect(await controller.create(exampleTask)).toEqual(
        expect.objectContaining({
          id: expect.any(String),
          code: exampleTask.code,
          name: exampleTask.name,
          description: exampleTask.description,
          status: {
            id: 'CREATED',
            name: 'Creada',
          },
          createdAt: expect.stringMatching(/^\d{2}-\d{2}-\d{4} \d{2}:\d{2}$/),
          updatedAt: expect.stringMatching(/^\d{2}-\d{2}-\d{4} \d{2}:\d{2}$/),
        }),
      );

      await taskModel.findOneAndDelete({ code: exampleTask.code });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

References

.
Terabox Video Player