Skip to content

Testing Plugins ​

Because the plugins are driver-agnostic, the same code you ship runs against the in-memory driver. The examples below boot a Nest context with RedisTestingModule and assert on real service behavior.

Cache ​

getOrSet invokes the loader once and serves the cached value afterwards:

typescript
import { NestFactory } from '@nestjs/core';
import { CachePlugin, CACHE_SERVICE, ICacheService } from '@nestjs-redisx/cache';
import { RedisTestingModule } from '@nestjs-redisx/testing';

/**
 * Boots a Nest context backed by the in-memory driver and exercises the real
 * CacheService. No Redis runs — `getOrSet` invokes the loader once and serves
 * the cached value on the second call. Returns the loader call count (1).
 */
export async function cacheLoadsOnce(): Promise<number> {
  const app = await NestFactory.createApplicationContext(RedisTestingModule.forRoot({ plugins: [new CachePlugin()] }), { logger: false });

  const cache = app.get<ICacheService>(CACHE_SERVICE);

  let calls = 0;
  const loader = async (): Promise<{ id: number }> => {
    calls += 1;
    return { id: 1 };
  };

  await cache.getOrSet('user:1', loader, { ttl: 60 });
  await cache.getOrSet('user:1', loader, { ttl: 60 });

  await app.close();
  return calls; // 1
}

A typical Vitest spec wrapping that pattern:

typescript
import { describe, it, expect } from 'vitest';
import { Test } from '@nestjs/testing';
import { RedisTestingModule } from '@nestjs-redisx/testing';
import { CachePlugin, CACHE_SERVICE, ICacheService } from '@nestjs-redisx/cache';

describe('cache (in-memory)', () => {
  it('serves from cache on the second call', async () => {
    const app = await Test.createTestingModule({
      imports: [RedisTestingModule.forRoot({ plugins: [new CachePlugin()] })],
    }).compile();
    await app.init();

    const cache = app.get<ICacheService>(CACHE_SERVICE);
    let calls = 0;
    const loader = async () => (calls++, { id: 1 });

    await cache.getOrSet('user:1', loader, { ttl: 60 });
    await cache.getOrSet('user:1', loader, { ttl: 60 });

    expect(calls).toBe(1);
    await app.close();
  });
});

Locks ​

The lock release path uses an owner-checked Lua script, which the interpreter runs faithfully — a second tryAcquire while held returns null:

typescript
const app = await Test.createTestingModule({
  imports: [RedisTestingModule.forRoot({ plugins: [new LocksPlugin()] })],
}).compile();
await app.init();

const locks = app.get<ILockService>(LOCK_SERVICE);
const lock = await locks.acquire('order:1', { ttl: 5000 });
expect(await locks.tryAcquire('order:1', { ttl: 5000 })).toBeNull();
await lock.release();

Rate limit ​

Token-bucket / sliding-window / fixed-window all run their real Lua scripts. With a 2-point bucket, the third request is blocked:

typescript
const app = await Test.createTestingModule({
  imports: [
    RedisTestingModule.forRoot({
      plugins: [new RateLimitPlugin({ defaultAlgorithm: 'token-bucket', defaultPoints: 2, defaultDuration: 60 })],
    }),
  ],
}).compile();
await app.init();

const rl = app.get<IRateLimitService>(RATE_LIMIT_SERVICE);
expect((await rl.check('ip:1')).allowed).toBe(true);
expect((await rl.check('ip:1')).allowed).toBe(true);
expect((await rl.check('ip:1')).allowed).toBe(false);

Circuit breaker ​

The breaker's atomic Lua transitions run on the in-memory interpreter, so the full closed → open → half-open → closed cycle is testable without Redis:

typescript
const app = await Test.createTestingModule({
  imports: [
    RedisTestingModule.forRoot({
      plugins: [new CircuitBreakerPlugin({ failureThreshold: 2, openDurationMs: 1000 })],
    }),
  ],
}).compile();
await app.init();

const cb = app.get<ICircuitBreakerService>(CIRCUIT_BREAKER_SERVICE);
const fail = () => Promise.reject(new Error('down'));
await expect(cb.execute('dep', fail)).rejects.toThrow();
await expect(cb.execute('dep', fail)).rejects.toThrow();
expect((await cb.getState('dep')).state).toBe('open');

Pub/Sub ​

The in-memory driver ships a process-wide Pub/Sub bus, so the real plugin — including its dedicated subscriber client — round-trips messages hermetically:

typescript
const app = await Test.createTestingModule({
  imports: [RedisTestingModule.forRoot({ plugins: [new PubSubPlugin()] })],
}).compile();
await app.init();

const pubsub = app.get<IPubSubService>(PUBSUB_SERVICE);
const got: unknown[] = [];
await pubsub.subscribe('events', (msg) => got.push(msg.data));
await pubsub.publish('events', { n: 1 });
await new Promise((r) => setTimeout(r, 20));
expect(got).toEqual([{ n: 1 }]);

Session ​

The session store's Lua scripts (metadata, per-user indexes, seat limits, the absolute lifetime cap) run on the in-memory interpreter, so login/revocation flows are testable with no Redis:

typescript
const app = await Test.createTestingModule({
  imports: [RedisTestingModule.forRoot({ plugins: [new SessionPlugin({ maxSessionsPerUser: 2 })] })],
}).compile();
await app.init();

const store = app.get<ISessionStore>(SESSION_STORE);
const sessions = app.get<ISessionService>(SESSION_SERVICE);

await store.set('sid-laptop', { cookie: {}, passport: { user: 'user-1' } });
await store.set('sid-phone', { cookie: {}, passport: { user: 'user-1' } });
await sessions.revokeAllExcept('user-1', 'sid-phone');
expect(await store.get('sid-laptop')).toBeNull();

Leader Election ​

Elections run for real on the memory driver — CAS Lua renewal and release included. A single instance leads right after app.init():

typescript
import { LeaderPlugin, LEADER_SERVICE, type ILeaderService } from '@nestjs-redisx/leader';

const app = await Test.createTestingModule({
  imports: [RedisTestingModule.forRoot({ plugins: [new LeaderPlugin({ ttlMs: 500, renewIntervalMs: 100, retryIntervalMs: 100 })] })],
}).compile();
await app.init();

const leader = app.get<ILeaderService>(LEADER_SERVICE);
expect(leader.isLeader()).toBe(true);
await leader.stepDown();
expect(await leader.getLeaderId()).toBeNull();

Simulate a second instance by constructing another LeaderService over the same LEADER_STORE — see the leader testing guide.

Idempotency ​

Register IdempotencyPlugin the same way and inject IDEMPOTENCY_SERVICE; the fingerprint store and TTLs behave exactly as they do against Redis.

Streams ​

The in-memory driver implements stream consumer groups (delivery cursors, PEL, XACK, XCLAIM, XPENDING), so the real producer and the background consumer loop round-trip a message with no Redis:

typescript
import { NestFactory } from '@nestjs/core';
import { StreamsPlugin, STREAM_PRODUCER, STREAM_CONSUMER, type IStreamProducer, type IStreamConsumer } from '@nestjs-redisx/streams';
import { RedisTestingModule } from '@nestjs-redisx/testing';

/**
 * Round-trips a Streams message through the real producer and a consumer group
 * on the in-memory driver — no Redis. The consumer reads via XREADGROUP, the
 * handler runs, and the message is auto-acked. Returns the payloads received.
 */
export async function streamRoundTrip(): Promise<Array<{ n: number }>> {
  const app = await NestFactory.createApplicationContext(RedisTestingModule.forRoot({ plugins: [new StreamsPlugin()] }), { logger: false });

  const producer = app.get<IStreamProducer>(STREAM_PRODUCER);
  const consumer = app.get<IStreamConsumer>(STREAM_CONSUMER);

  const received: Array<{ n: number }> = [];
  const handle = consumer.consume<{ n: number }>('orders', 'workers', 'c1', async (msg) => {
    received.push(msg.data);
  });

  await producer.publish('orders', { n: 1 });
  await new Promise((resolve) => setTimeout(resolve, 50)); // let the consumer poll

  await consumer.stop(handle);
  await app.close();
  return received; // [{ n: 1 }]
}

A Vitest spec driving the producer and consumer group directly:

typescript
import { describe, it, expect } from 'vitest';
import { Test } from '@nestjs/testing';
import { RedisTestingModule } from '@nestjs-redisx/testing';
import { StreamsPlugin, STREAM_PRODUCER, STREAM_CONSUMER, IStreamProducer, IStreamConsumer } from '@nestjs-redisx/streams';

describe('streams (in-memory)', () => {
  it('delivers a published message to the consumer group', async () => {
    const app = await Test.createTestingModule({
      imports: [RedisTestingModule.forRoot({ plugins: [new StreamsPlugin()] })],
    }).compile();
    await app.init();

    const producer = app.get<IStreamProducer>(STREAM_PRODUCER);
    const consumer = app.get<IStreamConsumer>(STREAM_CONSUMER);

    const received: Array<{ n: number }> = [];
    const done = new Promise<void>((resolve) => {
      consumer.consume<{ n: number }>('orders', 'g1', 'c1', async (msg) => {
        received.push(msg.data);
        resolve();
      });
    });

    await producer.publish('orders', { n: 1 });
    await done;

    expect(received).toEqual([{ n: 1 }]);
    await app.close();
  });
});

TIP

The in-memory driver does not truly block: a BLOCK XREADGROUP with no new messages returns promptly, so the consumer poll loop stays responsive in tests instead of waiting the full block timeout.

Seeding and inspecting state ​

For tests that need to pre-populate the keyspace or assert on raw values, cast the injected driver to MemoryRedisAdapter and use getStore():

typescript
import { Injectable, Inject } from '@nestjs/common';
import { REDIS_DRIVER, IRedisDriver } from '@nestjs-redisx/core';
import { MemoryRedisAdapter } from '@nestjs-redisx/testing';

/**
 * In tests you sometimes need to seed Redis state before exercising code, or
 * assert on the raw value it wrote. Cast the injected driver to
 * `MemoryRedisAdapter` and use `getStore()` to reach the in-memory keyspace.
 */
@Injectable()
export class StoreInspector {
  constructor(@Inject(REDIS_DRIVER) private readonly driver: IRedisDriver) {}

  private get store(): ReturnType<MemoryRedisAdapter['getStore']> {
    return (this.driver as MemoryRedisAdapter).getStore();
  }

  /** Seed a string key directly into the keyspace before the code under test runs. */
  seed(key: string, value: string): void {
    this.store.writeString(key, value);
  }

  /** Read back the raw value the code under test wrote. */
  read(key: string): string | undefined {
    return this.store.read(key, 'string');
  }

  /** Reset all keys between test cases. */
  reset(): void {
    this.store.flush();
  }
}

getStore() returns the in-memory MemoryStore, which exposes writeString, read, flush, keys, and TTL helpers — handy for arranging and resetting state between cases.

Released under the MIT License.