Search code examples
typescriptexpressredismocha.jssinon

mocha test with redis sinon stub throwing uncaught error outside suite


I'm having trouble getting Sinon to mock redis correctly for unit testing. The test cases are passing but Mocha is still crashing with a redis connection error every time, and I can't get to the bottom of it. After several days of working on this and combing through Stackoverflow I still haven't been able to solve it, so it's time to ask better minds than mine.

I started with a server.ts file that will export the app, so it can be loaded for final runtime configuration externally or loaded into a test suite:

import express, { Application } from "express";
import bodyParser from "body-parser";
import auth from "./routes/authRoutes";

const createServer = () => {
  const app: Application = express();

  app.use(bodyParser.json());

  app.get("/", (_req, res: Response, _next) => {
    res.json({
      message: "Hello World",
    });
  });

  app.use("/auth", auth);

  return app;
};

export default createServer;

The authRoutes.ts file is fairly simple and I merged the validation middleware and endpoint logic for brevity:

import { Router, Request, Response, NextFunction } from "express";
import { check, validationResult } from "express-validator";
import redisCache from "../data/redis-cache";

const router = Router();

const postAuth = async (req: Request, res: Response, _next: NextFunction) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json(errors.array());
  }
  const sessionId = Math.random().toString();
  await redisCache.setAsync(sessionId, "session1");
  return res.status(200).json({ sessionId: sessionId });
};

router.post(
  "/login",
  [
    check("body").exists().withMessage("No Post Body"),
  ],
  postAuth
);

export default router;

I also set up a redis-cache.ts file to promisify and export just the redis functions that I need:

import redis from "redis";

import { promisify } from "util";

const client = redis.createClient({
  host: process.env.REDIS_HOST || "localhost",
  port: process.env.REDIS_PORT ? +process.env.REDIS_PORT : 6379,
});

const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);

export default {
  getAsync,
  setAsync,
};

The test suite index.spec.ts is simple and sets up 3 tests:

import chai, { expect } from "chai";
import chaiHttp from "chai-http";
import * as sinon from "sinon";
import createServer from "../src/server";
import redis from "redis";

chai.use(chaiHttp);
chai.should();

describe("auth routes", function () {
  const mock = sinon.createSandbox();

  before(function () {
    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);
      },
      get: (key: string, cb: (e: Error | null) => void) => {
        console.log(`mocked get, request: ${key} `);
        return cb(null);
      },
      quit: (_cb: () => void) => {
        console.log(`mocked quit method`);
      },
    } as any);
  });

  after(function () {
    mock.restore();
  });

  describe("#GET/login", function () {
    it("responds with 404", function (done) {
      const app = createServer();
      chai
        .request(app)
        .get("/auth/login")
        .end((err: any, res: any) => {
          expect(err).to.be.null;
          expect(res.status).to.equal(404);
          done();
        });
    });
  });

  describe("#POST/login", function () {
    const app = createServer();
    function requestSend() {
      return chai.request(app).post("/auth/login");
    }

    describe("without body", function () {
      it("responds with 422", function (done) {
        requestSend().end(function (err: any, res: any) {
          if (err) return done(err);
          expect(res.status).to.equal(422);
          done();
        });
      });

      it("returns error when no post body sent", function (done) {
        requestSend().end(function (err: any, res: any) {
          if (err) return done(err);
          expect(res.body).to.be.a("array");
          const body = res.body as { msg: string }[];
          const messageIndex = body.findIndex(
            (el) => el.msg === "No Post Body"
          );
          expect(messageIndex).to.not.equal(-1);
          done();
        });
      });
    });
  });
});

Now I run the tests with Mocha and get the following:

auth routes
    Uncaught error outside test suite
    #GET/login
      ✔ responds with 404
    #POST/login
      without body
        ✔ responds with 422 (45ms)
        ✔ returns error when no post body sent


  3 passing (217ms)
  1 failing

  1) auth routes
       Uncaught error outside test suite:
     Uncaught Redis connection to localhost:6379 failed - connect ECONNREFUSED 127.0.0.1:6379
  Error: connect ECONNREFUSED 127.0.0.1:6379

As you can see, all the tests are passing but redis is still trying to connect outside of the test suite, and I can't figure out how to get around it. If I comment out the call to await redisCache.setAsync in my authRoutes.ts file and run the tests, all green and no errors.

I've spent hours googling and trying things with no luck and I'm pretty sure I'm missing something but I can't find it. Any help would be appreciated.

I followed this blog post to create a similar setup and it works, which leads me to believe this can be done and the error is on my part.


Solution

  • I was able to answer my own question with this one (finally!) It appears that I needed to scope the creation of the redis client per request and make sure it quit in the callback when the request finished. I borrowed from the blog post above and rebuilt the redis-cache.ts file to turn the redis calls into promises another way. My new redis-cache.ts looks like this:

    import * as redis from "redis";
    import {
      SuccessfulSetResponse,
      FailResponse,
      SuccessfulGetResponse,
    } from "../types";
    
    const url = `${process.env.REDIS_HOST || "localhost"}:${
      process.env.REDIS_PORT ? +process.env.REDIS_PORT : 6379
    }`;
    
    const getAsync = async (
      key: string
    ): Promise<SuccessfulGetResponse | FailResponse> => {
      return new Promise((resolve, _reject) => {
        const client = redis.createClient({ url });
    
        client.get(key, (error, value) => {
          // note the client quits in the callback
          client.quit();
    
          if (error) {
            resolve({
              reason: error.message,
              ...error,
            } as FailResponse);
          }
          if (value === null) {
            resolve({
              success: false,
            } as SuccessfulGetResponse);
          }
    
          resolve({
            success: true,
            value: value,
          } as SuccessfulGetResponse);
        });
      });
    };
    
    const setAsync = async (
      key: string,
      value: string
    ): Promise<SuccessfulSetResponse | FailResponse> => {
      return new Promise((resolve, _reject) => {
        const client = redis.createClient({ url });
    
        client.set(key, value, (error) => {
          // note the client quits in the callback
          client.quit();
    
          if (error) {
            resolve({
              reason: error.message,
              ...error,
            } as FailResponse);
          }
    
          resolve({
            success: true,
          } as SuccessfulSetResponse);
        });
      });
    };
    
    export default {
      getAsync,
      setAsync,
    };
    

    I also added a few type definitions the src/types.d.ts file:

    export interface SuccessfulSetResponse {
      success: boolean;
    }
    
    export interface SuccessfulGetResponse {
      success: boolean;
      value?: string;
    }
    
    export interface FailResponse {
      reason: string;
      [key: string]: string;
    }
    

    With these changes, I just had to chase a couple places where this is used (in an auth middleware for caching session tokens is the only thing so far) and all tests are green, and running the app manually and using postman also works as expected.

    I would still love to know exactly what is going on, please comment if anybody knows and / or edit this answer to provide insight as to why this worked this way.