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:
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:
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:
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:
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:
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:
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:
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:
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():
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.