typescriptredisjestjsnestjsioredis

How to set up a RedisService using Redis from `ioredis`?


NestJs v9.0.0, ioredis v5.3.2, jest v29.5.0. I'm unable to properly set up my redis service to get it working in both, jest unit tests or starting the nest app. I have a service RedisService which imports Redis fromĀ 'ioredis'.

Getting either issues when running the unit tests(jest) for the RedisService, or if I fix them then I get the below error when starting Nest:

Error #1

When starting Nest or running the e2e:

    Nest can't resolve dependencies of the RedisService (?). Please make sure that the argument Redis at index [0] is available in the RedisModule context.

    Potential solutions:
    - Is RedisModule a valid NestJS module?
    - If Redis is a provider, is it part of the current RedisModule?
    - If Redis is exported from a separate @Module, is that module imported within RedisModule?
      @Module({
        imports: [ /* the Module containing Redis */ ]
      })

Above error is reproduced when starting the app or running the e2e tests.

This is my RedisService with which the unit tests work fine, but when starting the app or running the e2e tests I get the Error #1:

import { Injectable, OnModuleDestroy } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RedisService implements OnModuleDestroy {
  constructor(private client: Redis) {} // <-- This is possibly the "issue". Unit tests work fine with this DI but app and e2e fail

  async onModuleInit() {
    this.client = new Redis({
      host: process.env.REDIS_HOST,
      port: +process.env.REDIS_PORT,
    });
  }

  async onModuleDestroy() {
    await this.client.quit();
  }

  async set(key: string, value: string, expirationSeconds: number) {
    await this.client.set(key, value, 'EX', expirationSeconds);
  }

  async get(key: string): Promise<string | null> {
    return await this.client.get(key);
  }
}

I have tried different approaches, and this was the one the unit tests finally worked fine, but clearly not when running e2e tests or starting the app.

However I can easily fix it by making my RedisService not to inject Redis from 'ioredis' into the constructor and instead instantiating it in onModuleInit lifecycle hook. BUT if I stop injecting it into the constructor, then its unit tests fail because the redisClient is an empty object instead of the mock I want it to be. Which leads to fix Error #1 but instead get Error #2 described below.

Error #2

In case of the tests failing, I get the following kind of errors:

TypeError: Cannot read properties of undefined (reading 'set') and TypeError: Cannot read properties of undefined (reading 'get')

The unit tests instead fail BUT the e2e and app work successfully if I change the redis.service.ts to:

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';

@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
  private client: Redis; // no injection in the constructor

  async onModuleInit() {
    this.client = new Redis({
      host: process.env.REDIS_HOST,
      port: +process.env.REDIS_PORT,
    });
  }
  // ...
}

Then the tests fail because the redisService is an empty object.

Context

These are the specs, redis.service.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import Redis from 'ioredis';
import * as redisMock from 'redis-mock';
import { RedisService } from './redis.service';

describe('RedisService', () => {
  let service: RedisService;
  let redisClientMock: redisMock.RedisClient;

  beforeEach(async () => {
    redisClientMock = {
      set: jest.fn(),
      get: jest.fn(),
    };
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        RedisService,
        {
          provide: Redis,
          useValue: redisMock.createClient(),
        },
      ],
    }).compile();

    redisClientMock = module.get(Redis);
    service = module.get<RedisService>(RedisService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('set', () => {
    it('should set a value in Redis with expiration date', async () => {
      const spy = jest.spyOn(redisClientMock, 'set');
      await service.set('my-key', 'my-value', 60);
      expect(spy).toHaveBeenCalledWith('my-key', 'my-value', 'EX', 60);
    });
  });

  describe('get', () => {
    it('should return null if the key does not exist', async () => {
      const spy = jest.spyOn(redisClientMock, 'get').mockReturnValue(undefined);
      const value = await service.get('nonexistent-key');
      expect(value).toBeUndefined();
    });
    it('should return the value if the key exists', async () => {
      jest.spyOn(redisClientMock, 'get').mockReturnValue('my-value');
      const value = await service.get('my-key');
      expect(value).toBe('my-value');
    });
  });
});

Here is my redis.module.ts:

import { Module } from '@nestjs/common';
import { RedisService } from './redis.service';

@Module({
  providers: [RedisService],
  exports: [RedisService],
})
export class RedisModule {}

RedisModule is in the imports array of the module where it is a dependency.

I guess using ioredis we just have to avoid injecting it in the constructor, but then how can I fix redis.service.spec.ts so that it gets the redisClient on time? Should it be injected as a dependency in the constructor? In any case, how should Redis be implemented in Nest so that both, e2e and unit tests work smoothly?


Solution

  • Fixed it after trying different things. Running the unit tests with this command NEST_DEBUG=true npm test helped me narrow down the issues in the end until the unit tests run successfully. Things that fixed it:

    1. Create file redis.provider.ts like this:
        import { Provider } from '@nestjs/common';
        import Redis from 'ioredis';
        
        export type RedisClient = Redis;
        
        export const redisProvider: Provider = {
          useFactory: (): RedisClient => {
            return new Redis({
              host: 'localhost',
              port: 6379,
            });
          },
          provide: 'REDIS_CLIENT',
        };
    
    1. Provide it in the module, in my case, redis.module.ts:
    import { Module } from '@nestjs/common';
    import { redisProvider } from './redis.providers';
    import { RedisService } from './redis.service';
    
    @Module({
      providers: [redisProvider, RedisService],
      exports: [RedisService],
    })
    export class RedisModule {}
    
    1. In the service, redis.service.ts, inject it in the constructor like this:
    import { Inject, Injectable } from '@nestjs/common';
    import { RedisClient } from './redis.providers';
    
    @Injectable()
    export class RedisService {
      public constructor(
        @Inject('REDIS_CLIENT')
        private readonly client: RedisClient,
      ) {}
    
      async set(key: string, value: string, expirationSeconds: number) {
        await this.client.set(key, value, 'EX', expirationSeconds);
      }
    
      async get(key: string): Promise<string | null> {
        return await this.client.get(key);
      }
    }
    
    1. Finally the test, redis.service.spec.ts: use the string REDIS_CLIENT instead of Redis imported from ioredis. So now it looks like this:
    import { Test, TestingModule } from '@nestjs/testing';
    import Redis from 'ioredis';
    import * as redisMock from 'redis-mock';
    import { RedisService } from './redis.service';
    
    describe('RedisService', () => {
      let service: RedisService;
      let redisClientMock: redisMock.RedisClient;
    
      beforeEach(async () => {
        redisClientMock = {
          set: jest.fn(),
          get: jest.fn(),
        };
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            RedisService,
            {
              provide: 'REDIS_CLIENT',
              useValue: redisMock.createClient(),
            },
          ],
        }).compile();
    
        redisClientMock = module.get('REDIS_CLIENT');
        service = module.get<RedisService>(RedisService);
      });
    
      it('should be defined', () => {
        expect(service).toBeDefined();
      });