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 }]);

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.