Stubbing Redis with SinonJS

Stubbing Redis with SinonJS

·

0 min read

If you are familiar with Redis it's a cool in-memory data structure that could be used in different usage like a database, cache, or a message broker. This features allowed developers to develop highly sophisticated application, like games' leadership and any other features that handles data that change frequently.

A few years ago, when I start to learn and implement this, I stumble upon some problems regarding with the testing of an application that has a Redis implementation. And I discover an easy way to mock by not using any 3rd party libraries. So lets get started.

THE SOURCE

So lets start this by creating our project folder structure, it would be simple like this:

.
├── src
│   ├── helper.ts
│   └── main.ts
└── test

We'll be using TypeScript for this example, and you should have the basic knowledge on how to setup the linter and other related TS configuration. Then we will add the following node modules for our project dependencies:

npm install chai sinon mocha redis

Then for our dev dependencies, we're going to install the following @types/:

npm install -D nyc @types/chai @types/sinon @types/redis @types/mocha

CREATING OUR REDIS WRAPPER

By default redis node module doesn't have an async/await feature so we're gonna wrap it on a Promise (note: I'm not using util/promisify at this time), so lets write a simple function on helper.ts:

import * as redis from 'redis';
import { FailResponse, SuccessfulResponse } from './cache-interface';

const url = `redis://127.0.0.1:6379`;

export const addToCache = async (key: string, value: string) => {
  return new Promise((resolve, reject) => {
    const client = redis.createClient({ url });

    client.set(key, value, (error) => {
      client.quit();

      if (error) {
        resolve({
          reason: error.message,
          ...error
        } as FailResponse);
      }

      resolve({
        success: true
      } as SuccessfulResponse);
    });
  });
};

As you can see, it's plain simple function, we created an async/await function then we reject any Error we encounter, then we resolve any successful operation.

Then we'll switch back to our main.ts file, we will create a simple function that will use our wrapped Redis function. Lets say we're creating a function for a Lambda:

import { addToCache } from './helper';

export const saveItem = async (event) => {
  const key = event.key || '';
  const value = event.value || '';

  const result = await addToCache(key, value);

  return result;
};

And again, just a simple function.

Note: We will assume that our code works so we could proceed on creating a unit test.

CREATING A UNIT TEST

If you're familiar with AngularJS standard, they recommend to create a .spec.js file to create a unit test case. So we're gonna use that. We will create first a helper.spec.ts:

import { assert } from 'chai';
import * as sinon from 'sinon';
import { addToCache } from '../src/helper';

describe(`redis`, () => {
  describe(`addToCache`, () => {
    const mock = sinon.createSandbox();

    it(`should add the 'key-value' pair to Redis Cache`, async () => {
      const key = 'sample-key';
      const value = 'sample-value';

      const result = await addToCache(key, value);

      assert.exists(result.success);
    });
  });
});

We'll start from this, if you have a tslint installed, it will prompt that there's an error on line 15 because the variable result is type unknown. To resolve that, let create a new file that will hold our interfaces / types for our project - lets call this: src/cache-interface.ts, and it will be look like this:

export interface SuccessfulResponse {
  success: boolean;
}

export interface FailResponse {
  reason: string;
  [key: string]: string;
}

And then we will update our helper.ts to incorporate those interfaces for possible return values. So the update file will be like this:

import * as redis from 'redis';
import { FailResponse, SuccessfulResponse } from './cache-interface';

const url = `redis://127.0.0.1:6379`;

export const addToCache = async (key: string, value: string): Promise<SuccessfulResponse | FailResponse> => {
  return new Promise((resolve, reject) => {
    const client = redis.createClient({ url });

    client.set(key, value, (error) => {
      client.quit();

      if (error) {
        resolve({
          reason: error.message,
          ...error
        } as FailResponse);
      }

      resolve({
        success: true
      } as SuccessfulResponse);
    });
  });
};

And as we can see, the result will be a type of two possible types, if you hover your mouse on the variable, we're gonna see this (TS Rocks!!!):

resolvedtype.png

So let's run the unit test by using this command:

➜ ./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.spec.ts

And the result will be:

  redis
    addToCache
      ✓ should add the 'key-value' pair to Redis Cache


  1 passing (16ms)

ERROR: Coverage for lines (55.56%) does not meet global threshold (70%)
-----------|----------|----------|----------|----------|-------------------|
File       |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files  |       55 |    16.67 |    57.14 |    55.56 |                   |
 helper.ts |    84.62 |       50 |       80 |    83.33 |             11,18 |
 main.ts   |        0 |        0 |        0 |        0 |       1,3,4,5,7,9 |
-----------|----------|----------|----------|----------|-------------------|

As we see, it runs successfully, our function passed on our test. BUT this is a UNIT TEST all 3rd party dependencies like databases, cache, and other 3rd party API shouldn't be called live! On my scenario, it connects to my local Redis server, so let's turn it off and lets see what will happen:

➜ /etc/init.d/redis-server stop
[ ok ] Stopping redis-server (via systemctl): redis-server.service.

Run the command again:

➜ ./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.spec.ts


  redis
    addToCache
      1) should add the 'key-value' pair to Redis Cache


  0 passing (17ms)
  1 failing

  1) redis
       addToCache
         should add the 'key-value' pair to Redis Cache:
     Error: the object {
  "address": "127.0.0.1"
  "code": "ECONNREFUSED"
  "errno": "ECONNREFUSED"
  "port": 6379
  "reason": "Redis connection to 127.0.0.1:6379 failed - connect ECONNREFUSED 127.0.0.1:6379"
  "syscall": "connect"
} was thrown, throw an Error :)
      at processTicksAndRejections (internal/process/task_queues.js:89:5)



ERROR: Coverage for lines (68.42%) does not meet global threshold (70%)
-----------|----------|----------|----------|----------|-------------------|
File       |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files  |    66.67 |    16.67 |    71.43 |    68.42 |                   |
 helper.ts |      100 |       50 |      100 |      100 |                22 |
 main.ts   |        0 |        0 |        0 |        0 |       1,3,4,5,7,9 |
-----------|----------|----------|----------|----------|-------------------|

Okay, so we got an error, it refused to connect for the server being unavailable after we shut it down.

Lets mock the 3rd party dependency then. To do this we need to use our stub variable that we declared:

// in javascript
mock.stub(redis, 'createClient').returns({});

If you wrote the test case on javascript, you might put an empty object on the returns method, which you won't have any idea what would be the type of return. And we will bump to an error if we run our unit test:

TypeError: client.on is not a function
      at /home/blog/redist-test/src/helper.ts:1:6610
      at new Promise (<anonymous>)
      at Object.<anonymous> (src/helper.ts:1:6463)
      at Generator.next (<anonymous>)
      at /home/blog/redist-test/src/helper.ts:1:6053
      at new Promise (<anonymous>)
      at __awaiter (src/helper.ts:1:5188)
      at Object.exports.addToCache (src/helper.ts:1:6370)
      at Object.<anonymous> (test/helper.spec.ts:16:28)
      at Generator.next (<anonymous>)
      at /home/blog/redist-test/test/helper.spec.ts:7:71
      at new Promise (<anonymous>)
      at __awaiter (test/helper.spec.ts:3:12)
      at Context.<anonymous> (test/helper.spec.ts:10:69)
      at processImmediate (internal/timers.js:439:21)

We got an TypeError: client.on is not a function error, because we pass an empty object on the returns method. So if we wrote it on typescript it will prompt us which specific type to return:

resolvedtype_01.png

It needs to be a type of RedisClient. We don't have to specify everything on the type but we all need the following methods - set and quit, here's the updated helper.spec.ts:

import { assert } from 'chai';
import * as redis from 'redis';
import * as sinon from 'sinon';
import { addToCache } from '../src/helper';

describe(`redis`, () => {
  describe(`addToCache`, () => {
    const mock = sinon.createSandbox();

    it(`should add the 'key-value' pair to Redis Cache`, async () => {
      mock.stub(redis, 'createClient').returns({
        set: (key: string, value: string, cb: (e: Error | null) => void) => {
          console.log(`mocked set, request: ${key} -> ${value}`);
          return cb(null);
        },
        quit: (cb: () => void) => {
          console.log(`mocked quit method`);
        }
      } as any);

      const key = 'sample-key';
      const value = 'sample-value';

      const result = await addToCache(key, value);

      assert.exists(result.success);
    });
  });
});

So by running again, the output will be:

➜ ./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.spec.ts


  redis
    addToCache
mocked set, request: sample-key -> sample-value
mocked quit method
      ✓ should add the 'key-value' pair to Redis Cache


  1 passing (17ms)

ERROR: Coverage for lines (56.25%) does not meet global threshold (70%)
-----------|----------|----------|----------|----------|-------------------|
File       |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files  |    55.56 |    16.67 |    66.67 |    56.25 |                   |
 helper.ts |    90.91 |       50 |      100 |       90 |                14 |
 main.ts   |        0 |        0 |        0 |        0 |       1,3,4,5,7,9 |
-----------|----------|----------|----------|----------|-------------------|

As we can see, it even logs the into the console the custom log we set from the sandbox including the key -> value pair of our input.

The only error we got is from the nyc plugin shows that we did not hit the global threshold 70%, so we need to finish our unit test case.

Here's the updated helper.spec.ts file:

import { assert } from 'chai';
import * as redis from 'redis';
import * as sinon from 'sinon';
import { FailResponse } from '../src/cache-interface';
import { addToCache } from '../src/helper';

describe(`redis`, () => {
  describe(`addToCache`, () => {
    const mock = sinon.createSandbox();

    afterEach(() => {
      mock.restore();
    });

    it(`should add the 'key-value' pair to Redis Cache`, async () => {
      mock.stub(redis, 'createClient').returns({
        set: (key: string, value: string, cb: (e: Error | null) => void) => {
          console.log(`mocked set, request: ${key} -> ${value}`);
          return cb(null);
        },
        quit: (cb: () => void) => {
          console.log(`mocked quit method`);
        }
      } as any);

      const key = 'sample-key';
      const value = 'sample-value';

      const result = await addToCache(key, value);

      assert.exists(result.success);
    });

    it(`should reject with an 'Error' object if there's something wrong with the operation`, async () => {
      mock.stub(redis, 'createClient').returns({
        set: (key: string, value: string, cb: (e: Error | null) => void) => {
          console.log(`mocked set, request: ${key} -> ${value}`);
          return cb(new Error(`need to reject this`));
        },
        quit: (cb: () => void) => {
          console.log(`mocked quit method`);
        }
      } as any);

      const key = 'sample-key';
      const value = 'sample-value';

      const result = await addToCache(key, value);

      assert.exists((result as FailResponse).reason);
    });
  });
});

Then a unit test case for main.ts, so we will create test/main.spec.ts file:

import { assert } from 'chai';
import * as redis from 'redis';
import * as sinon from 'sinon';
import { saveItem } from '../src/main';

describe(`saveItem`, () => {
  const mock = sinon.createSandbox();

  afterEach(() => {
    mock.restore();
  });

  it(`should save successfully the request`, async () => {
    mock.stub(redis, 'createClient').returns({
      set: (key: string, value: string, cb: (e: Error | null) => void) => {
        console.log(`mocked set, request: ${key} -> ${value}`);
        return cb(null);
      },
      quit: (cb: () => void) => {
        console.log(`mocked quit method`);
      }
    } as any);

    const result = await saveItem({
      key: 'realKey', value: 'realValue'
    });

    assert.exists(result.success);
  });
});

Running both will give us:

➜ ./node_modules/.bin/nyc ./node_modules/.bin/mocha -r ts-node/register test/*.spec.ts


  redis
    addToCache
mocked set, request: sample-key -> sample-value
mocked quit method
      ✓ should add the 'key-value' pair to Redis Cache
mocked set, request: sample-key -> sample-value
mocked quit method
      ✓ should reject with an 'Error' object if there's something wrong with the operation

  saveItem
mocked set, request: realKey -> realValue
mocked quit method
    ✓ should save successfully the request


  3 passing (26ms)

-----------|----------|----------|----------|----------|-------------------|
File       |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files  |      100 |    66.67 |      100 |      100 |                   |
 helper.ts |      100 |      100 |      100 |      100 |                   |
 main.ts   |      100 |       50 |      100 |      100 |               4,5 |
-----------|----------|----------|----------|----------|-------------------|

WRAP IT UP

On this article we cover some basic mocking and unit testing with just using Sinon only, no need to use other 3rd party node_modules if we already know what we want to be mocked.

Any thoughts or opinion about this? Please let me know :) Thank you for reading.