Writing test cases is all about making your system error-proof and validating for errors or exceptions before they occur. However, I believe that test cases also reflect how well-structured and organized your code is.
While writing test cases, if you ever end up needing to access a private member or having to change conditions just to ensure that test cases find their way in, that's your cue that you need to isolate the dependency further.
Most of the time, we can find our way around by using patterns or by composing the module, especially using Dependency Injection to inject the needed dependencies. This allows you to easily stub or mock them whenever needed.
However, sometimes, you can't do further isolation. Today, we are going to discuss one such case where you can't simply isolate the dependency and end up in a situation where you need to find your way around.
This typically happens when your service is closely linked with a third-party dependency that you need to unit test.
Here's an example: There's a service called RedisService. The RedisService uses the instance of Redis. Now, other services, like CacheService, use the RedisService. Let's see how that's structured.
import { RedisService } from "./redis";
export class CacheService {
private redisService: RedisService;
constructor(redisService: RedisService) {
this.redisService = redisService;
}
set(key: string, value: any) {
this.redisService.set(key, value);
}
get(key: string) {
return this.redisService.get(key);
}
}
import {Redis} from "ioredis"
export class RedisService {
private client: Redis;
constructor() {
this.client = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
});
}
async set(key: string, value: string): Promise<void> {
this.client.set(key, value);
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
}
When writing test cases for CacheService, you will either stub the entire redisService or stub the calls to the set and get of the redisService. This is because you're only interested in covering the lines for CacheService. It doesn't matter if the breakpoints hit redisService or not, as long as the tests are passing.
import { expect } from "chai";
import { SinonStubbedInstance, createStubInstance } from "sinon";
import { RedisService } from "./redis";
import { CacheService } from "./cache";
describe("CacheService", function () {
let redisService: SinonStubbedInstance<RedisService>
beforeEach(function () {
redisService = createStubInstance(RedisService);
redisService.get.resolves("value");
redisService.set.resolves();
})
it("should set and get a value", async function () {
const cacheService = new CacheService(redisService);
cacheService.set("key", "value");
const value = await cacheService.get("key");
expect(value).to.equal("value");
})
});
We're stubbing the Redis instance and allowing requests to go to the actual function of the cache service, which can call the stubbed functions.
While this is good, we're covering the lines of cacheService with RedisService being stubbed, so the lines of RedisService aren't actually hit.
We can observe this in our coverage.
Our unit tests currently cover a lot of our CacheService, but they aren't covering our RedisService. The images above show this clearly. We've been stubbing RedisService when testing CacheService, which is why it's not covered. We need to create separate unit tests for RedisService to make sure all of its code is tested.
We need to make sure we're testing the actual lines of code in RedisService, not just mocking it.
Initially, we need to ensure that the code inside 'set' and 'get' is actually executed. This requires having an instance of Redis ready. While this is manageable in your local environment, it becomes challenging in a remote environment or on a virtual instance running your test cases during the build process before deployment. This challenge arises due to the necessity of having access to the Redis server to accurately run this test.
One solution is to install the Redis server while building the Docker image. Although this could resolve the issue, it would result in a larger Docker image, half of which would be the Redis server that's only needed during test case execution.
Therefore, we should explore other alternatives that don't involve patchwork solutions like downloading the Redis server on the fly.
One possible approach is to stub the Redis instance itself. But before we delve into that, let's examine what happens to our Redis service test case if we don't have the Redis server running.
import { expect } from "chai";
import { RedisService } from "./redis";
import dotenv from "dotenv";
dotenv.config();
describe("CacheService", function () {
it("should set and get a value", async function () {
const redisService = new RedisService();
redisService.set("key", "value");
const value = await redisService.get("key");
expect(value).to.equal("value");
})
});
❯ npm run test
> vscode-typescript-debugging@1.0.0 test
> mocha --require ts-node/register src/*.spec.ts --exit
CacheService
✔ should set and get a value
CacheService
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
You'll notice the test case failure is due to an unsuccessful attempt to connect to Redis.
We've already examined this situation when stubbing the get and set functions of the Redis server during the RedisService stubbing for CacheService.
import { expect } from "chai";
import { stub } from "sinon";
import { RedisService } from "./redis";
import dotenv from "dotenv";
dotenv.config();
describe("CacheService", function () {
it("should set and get a value", async function () {
const redisService = new RedisService();
redisService.get = stub().resolves("value");
redisService.set = stub().resolves();
redisService.set("key", "value");
const value = await redisService.get("key");
expect(value).to.equal("value");
})
});
Though the test cases will pass, they won't yield any results as we can't cover or hit the lines within 'set' and 'get' due to stubbing.
One way to ensure the code gets executed is to stub the Redis 'get' and 'set' itself. However, that's not happening as the Redis instance is private to the service file.
Libraries such as ioredis-mock can replace the actual ioredis instance. However, this is not feasible without altering the original code or introducing a condition in the actual Redis service.
A common solution is to mock the internals that the Redis server uses to establish a connection. This is similar to integrating a Facebook login, then using a tool like nock to mock the actual Facebook API.
Thankfully, the maintainer of ioredis has provided code to mock the WebSocket connection to Redis: https://github.com/redis/ioredis/blob/main/test/helpers/mock_server.ts
With some adjustments, you can get it working for your needs. You must ensure that you initialize the mock Redis server with the same port you intend to use to connect your Redis instance.
This not only passes the test cases but also meets the code coverage requirements.
This is a curious case of writing test cases for Redis.