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!!!):
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:
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.