Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create a mockable class to connect to mongoDB?

I've tried to create a class to connect to a mongoDB (and get a gridFS connection using (gridfs-stream). But with that I do get two problems:

  1. I do get sometimes the mongo Error server instance in invalid state connected
  2. It is impossible for me to mock this class out - using jestJS

So I would be very thankful if someone can help me to optimize this class to get a really solid working class. For example I don't like the let that = this in the connect() function.

Example repo

DB class

const mongo = require('mongodb')
const Grid = require('gridfs-stream')
const { promisify } = require('util')

export default class Db {
  constructor (uri, callback) {
    this.db = null
    this.gfs = null
    const server = process.env.MONGO_SERVER || 'localhost'
    const port = process.env.MONGO_PORT || 27017
    const db = process.env.MONGO_DB || 'test'

    // Is this the correct way to connect (using mongo native driver)?
    this.connection = new mongo.Db(db, new mongo.Server(server, port))
    this.connection.open = promisify(this.connection.open)
    this.connected = false
    return this
  }

  async connect (msg) {
    let that = this
    if (!this.db) {
      try {
        await that.connection.open()
        that.gfs = Grid(that.connection, mongo)
        this.connected = true
      } catch (err) {
        console.error('mongo connection error', err)
      }
    }
    return this
  }

  isConnected () {
    return this.connected
  }
}

Example

This function will add a new user to the DB using the class above:

import bcrypt from 'bcrypt'
import Db from './lib/db'
const db = new Db()

export async function createUser (obj, { username, password }) {
  if (!db.isConnected()) await db.connect()
  const Users = db.connection.collection('users')
  return Users.insert({
    username,
    password: bcrypt.hashSync(password, 10),
    createdAt: new Date()
  })
}

Unit test

I need to create a unit test to test if the mongoDB method is called. No integration test for testing the method. So I need to mock the DB connection, collection and insert method.

import bcrypt from 'bcrypt'
import { createUser } from '../../user'

import Db from '../../lib/db'
const db = new Db()
jest.mock('bcrypt')

describe('createUser()', () => {
  test('should call mongoDB insert()', async () => {
    bcrypt.hashSync = jest.fn(() => SAMPLE.BCRYPT)
    // create somekind of mock for the insert method...
    db.usersInsert = jest.fn(() => Promise.resolve({ _id: '507f1f77bcf86cd799439011' }))
    await createUser({}, {
      username: 'username',
      password: 'password'
    }).then((res) => {
      // test if mocked insert method has been called
      expect(db.usersInsert).toHaveBeenCalled()
      // ... or better test for the returned promise value
    })
  })
})
like image 891
user3142695 Avatar asked Mar 18 '18 07:03

user3142695


2 Answers

There are multiple ways to go about this. I will list few of them

  • Mock the DB class using a Jest manual mock. This could be cumbersome if you are using too many mongo functions. But since you are encapsulating most through the DB class it may still be manageable
  • Use a mocked mongo instance. This project allows you to simulate a MongoDB and persist data using js file
  • Use a in-memory mongodb
  • Use a actual mongodb

I will showcase the first case here, which you posted about with code and how to make it work. So first thing we would do is update the __mocks__/db.js file to below

jest.mock('mongodb');
const mongo = require('mongodb')
var mock_collections = {};
var connectError = false;
var connected = false;
export default class Db {
    constructor(uri, callback) {
        this.__connectError = (fail) => {
            connected = false;
            connectError = fail;
        };

        this.clearMocks = () => {
            mock_collections = {};
            connected = false;
        };

        this.connect = () => {
            return new Promise((resolve, reject) => {
                process.nextTick(
                    () => {
                        if (connectError)
                            reject(new Error("Failed to connect"));
                        else {
                            resolve(true);
                            this.connected = true;
                        }
                    }
                );
            });
        };

        this.isConnected = () => connected;

        this.connection = {
            collection: (name) => {
                mock_collections[name] = mock_collections[name] || {
                    __collection: name,
                    insert: jest.fn().mockImplementation((data) => {
                        const ObjectID = require.requireActual('mongodb').ObjectID;

                        let new_data = Object.assign({}, {
                            _id: new ObjectID()
                        },data);
                        return new Promise((resolve, reject) => {
                                process.nextTick(
                                    () =>
                                        resolve(new_data))
                            }
                        );
                    })
                    ,
                    update: jest.fn(),
                    insertOne: jest.fn(),
                    updateOne: jest.fn(),
                };
                return mock_collections[name];
            }
        }
    }

}

Now few explanations

  • jest.mock('mongodb'); will make sure any actual mongodb call gets mocked
  • The connected, connectError, mock_collections are global variables. This is so that we can impact the state of the Db that your user.js loads. If we don't do this, we won't be able to control the mocked Db from within our tests
  • this.connect shows how you can return a promise and also how you can simulate a error connecting to DB when you want
  • collection: (name) => { makes sure that your call to createUser and your test can get the same collection interface and check if the mocked functions were actually called.
  • insert: jest.fn().mockImplementation((data) => { shows how you can return data by creating your own implementation
  • const ObjectID = require.requireActual('mongodb').ObjectID; shows how you can get an actual module object when you have already mocked mongodb earlier

Now comes the testing part. This is the updated user.test.js

jest.mock('../../lib/db');
import Db from '../../lib/db'
import { createUser } from '../../user'

const db = new Db()

describe('createUser()', () => {
  beforeEach(()=> {db.clearMocks();})

  test('should call mongoDB insert() and update() methods 2', async () => {
    let User = db.connection.collection('users');
    let user = await createUser({}, {
      username: 'username',
      password: 'password'
    });
    console.log(user);
    expect(User.insert).toHaveBeenCalled()
  })

    test('Connection failure', async () => {
        db.__connectError(true);
        let ex = null;
        try {
          await createUser({}, {
          username: 'username',
          password: 'password'
        })
      } catch (err) {
        ex= err;
      }
      expect(ex).not.toBeNull();
      expect(ex.message).toBe("Failed to connect");
    })
})

Few pointers again

  • jest.mock('../../lib/db'); will make sure that our manual mock gets loaded
  • let user = await createUser({}, { since you are using async, you will not use then or catch. That is the point of using async function.
  • db.__connectError(true); will set the global variable connected to false and connectError to true. So when createUser gets called in the test it will simulate a connection error
  • ex= err;, see how I capture the exception and take out the expect call. If you do expect in the catch block itself, then when an exception is not raised the test will still pass. That is why I have done exception testing outside the try/catch block

Now comes the part of testing it by running npm test and we get

Jest test results

All of it is committed to below repo you shared

https://github.com/jaqua/mongodb-class

like image 191
Tarun Lalwani Avatar answered Nov 08 '22 07:11

Tarun Lalwani


You are stubbing on an instance of DB, not the actual DB class. Additionally I don't see the db.usersInsert method in your code. We can't write your code for you, but I can point you in the right direction. Also, I don 't use Jest but the concepts from Sinon are the same. The best thing to do in your case I believe is to stub out the prototype of the class method that returns an object you are interacting with.

Something like this:

// db.js
export default class Db {
  getConnection() {}
}

// someOtherFile.js
import Db from './db';

const db = new Db();
export default async () => {
  const conn = await db.getConnection();
  await connection.collection.insert();
}

// spec file
import {
  expect
} from 'chai';
import {
  set
} from 'lodash';
import sinon from 'sinon';
import Db from './db';
import testFn from './someOtherFile';


describe('someOtherFile', () => {
  it('should call expected funcs from db class', async () => {
    const spy = sinon.spy();
    const stub = sinon.stub(Db.prototype, 'getConnection').callsFake(() => {
      return set({}, 'collection.insert', spy);
    });
    await testFn();
    sinon.assert.called(spy);
  });
});
like image 1
Aaron Rumery Avatar answered Nov 08 '22 08:11

Aaron Rumery