Skip to content

Testing Strategy

Comprehensive testing approach for applications using NestJS RedisX.

Testing Pyramid

LevelRedisPurposeSpeed
UnitMockedLogic correctnessFast
IntegrationReal (Docker)Redis interactionMedium
E2ERealFull systemSlow

Unit Testing

Mocking Cache Service

typescript
import { describe, it, expect, beforeEach, afterEach, vi, type MockedObject } from 'vitest';

// __mocks__/cache.service.ts
export const mockCacheService = {
  get: vi.fn(),
  set: vi.fn(),
  delete: vi.fn(),
  invalidateTags: vi.fn(),
};

// user.service.spec.ts
describe('UserService', () => {
  let service: UserService;
  let cacheService: MockedObject<ICacheService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: CACHE_SERVICE,
          useValue: mockCacheService,
        },
      ],
    }).compile();

    service = module.get(UserService);
    cacheService = module.get(CACHE_SERVICE);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  describe('getUser', () => {
    it('should return cached user', async () => {
      const cachedUser = { id: '123', name: 'John' };
      cacheService.get.mockResolvedValue(cachedUser);

      const result = await service.getUser('123');

      expect(result).toEqual(cachedUser);
      expect(cacheService.get).toHaveBeenCalledWith('user:123');
    });

    it('should fetch and cache on miss', async () => {
      cacheService.get.mockResolvedValue(null);
      const dbUser = { id: '123', name: 'John' };
      userRepository.findById.mockResolvedValue(dbUser);

      const result = await service.getUser('123');

      expect(result).toEqual(dbUser);
      expect(cacheService.set).toHaveBeenCalledWith(
        'user:123',
        dbUser,
        expect.any(Object),
      );
    });
  });
});

Mocking Lock Service

typescript
import { describe, it, expect, beforeEach, vi, type MockedObject } from 'vitest';

// __mocks__/lock.service.ts
export const mockLockService = {
  acquire: vi.fn(),
  release: vi.fn(),
  withLock: vi.fn((key, fn) => fn()), // Execute function directly
};

// payment.service.spec.ts
describe('PaymentService', () => {
  let service: PaymentService;
  let lockService: MockedObject<ILockService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        PaymentService,
        {
          provide: LOCK_SERVICE,
          useValue: mockLockService,
        },
      ],
    }).compile();

    service = module.get(PaymentService);
    lockService = module.get(LOCK_SERVICE);
  });

  it('should process payment with lock', async () => {
    const mockLock = { release: vi.fn() };
    lockService.acquire.mockResolvedValue(mockLock);

    await service.processPayment('order-123');

    expect(lockService.acquire).toHaveBeenCalledWith(
      'payment:order-123',
      expect.any(Object),
    );
  });

  it('should throw when lock not acquired', async () => {
    lockService.acquire.mockResolvedValue(null);

    await expect(service.processPayment('order-123')).rejects.toThrow();
  });
});

Mocking Rate Limit Service

typescript
import { describe, it, expect, beforeEach, vi, type MockedObject } from 'vitest';

// rate-limit.service.spec.ts
describe('RateLimitGuard', () => {
  let guard: RateLimitGuard;
  let rateLimitService: MockedObject<IRateLimitService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        RateLimitGuard,
        {
          provide: RATE_LIMIT_SERVICE,
          useValue: {
            check: vi.fn(),
          },
        },
      ],
    }).compile();

    guard = module.get(RateLimitGuard);
    rateLimitService = module.get(RATE_LIMIT_SERVICE);
  });

  it('should allow when under limit', async () => {
    rateLimitService.check.mockResolvedValue({
      allowed: true,
      remaining: 99,
      resetIn: 60,
    });

    const result = await guard.canActivate(mockExecutionContext);

    expect(result).toBe(true);
  });

  it('should block when over limit', async () => {
    rateLimitService.check.mockResolvedValue({
      allowed: false,
      remaining: 0,
      resetIn: 30,
    });

    await expect(guard.canActivate(mockExecutionContext)).rejects.toThrow(
      TooManyRequestsException,
    );
  });
});

Integration Testing

Setup with Docker Redis

typescript
// test/setup.ts
import { GenericContainer, StartedTestContainer } from 'testcontainers';

let redisContainer: StartedTestContainer;

beforeAll(async () => {
  redisContainer = await new GenericContainer('redis:7-alpine')
    .withExposedPorts(6379)
    .start();

  process.env.REDIS_HOST = redisContainer.getHost();
  process.env.REDIS_PORT = redisContainer.getMappedPort(6379).toString();
}, 60000);

afterAll(async () => {
  await redisContainer.stop();
});

Integration Test Example

typescript
// user.integration.spec.ts
describe('UserService (Integration)', () => {
  let app: INestApplication;
  let service: UserService;
  let redis: Redis;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        RedisModule.forRoot({
          clients: {
            host: process.env.REDIS_HOST,
            port: parseInt(process.env.REDIS_PORT),
          },
          plugins: [new CachePlugin()],
        }),
        UserModule,
      ],
    }).compile();

    app = module.createNestApplication();
    await app.init();

    service = module.get(UserService);
    redis = module.get(getRedisToken());
  });

  beforeEach(async () => {
    // Clear Redis between tests
    await redis.flushall();
  });

  afterAll(async () => {
    await app.close();
  });

  it('should cache user in Redis', async () => {
    const user = await service.getUser('123');

    // Verify it's in Redis
    const cached = await redis.get('cache:user:123');
    expect(JSON.parse(cached)).toEqual(user);
  });

  it('should invalidate cache on update', async () => {
    // Populate cache
    await service.getUser('123');
    expect(await redis.exists('cache:user:123')).toBe(1);

    // Update user
    await service.updateUser('123', { name: 'New Name' });

    // Cache should be invalidated
    expect(await redis.exists('cache:user:123')).toBe(0);
  });
});

Testing Locks

typescript
// lock.integration.spec.ts
describe('Lock Integration', () => {
  it('should prevent concurrent execution', async () => {
    const results: string[] = [];

    // Start two concurrent operations
    const op1 = service.processWithLock('resource-1', async () => {
      results.push('op1-start');
      await sleep(100);
      results.push('op1-end');
    });

    const op2 = service.processWithLock('resource-1', async () => {
      results.push('op2-start');
      await sleep(100);
      results.push('op2-end');
    });

    await Promise.all([op1, op2]);

    // Operations should be serialized
    expect(results).toEqual([
      'op1-start',
      'op1-end',
      'op2-start',
      'op2-end',
    ]);
  });
});

Testing Rate Limits

typescript
// rate-limit.integration.spec.ts
describe('Rate Limit Integration', () => {
  it('should enforce rate limit', async () => {
    const limit = 5;
    const results: boolean[] = [];

    for (let i = 0; i < limit + 3; i++) {
      const result = await rateLimitService.check({
        key: 'test:rate',
        limit,
        window: 60,
      });
      results.push(result.allowed);
    }

    // First 5 allowed, next 3 blocked
    expect(results).toEqual([
      true, true, true, true, true,
      false, false, false,
    ]);
  });
});

Simulating Redis Failures

Test Redis Down Scenario

typescript
describe('Redis Failure Handling', () => {
  it('should handle Redis disconnect gracefully', async () => {
    // Stop Redis
    await redisContainer.stop();

    // Service should handle gracefully
    const result = await service.getUserWithFallback('123');

    expect(result).toBeDefined(); // Falls back to DB
  });

  it('should recover when Redis comes back', async () => {
    // Restart Redis
    redisContainer = await new GenericContainer('redis:7-alpine')
      .withExposedPorts(6379)
      .start();

    // Wait for reconnection
    await sleep(1000);

    const result = await service.getUser('123');
    expect(result).toBeDefined();
  });
});

E2E Testing

typescript
// app.e2e-spec.ts
describe('API (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/users/:id (GET) - should cache response', async () => {
    // First request
    const res1 = await request(app.getHttpServer())
      .get('/users/123')
      .expect(200);

    // Second request (should be cached)
    const res2 = await request(app.getHttpServer())
      .get('/users/123')
      .expect(200);

    expect(res1.body).toEqual(res2.body);
    expect(res2.headers['x-cache']).toBe('HIT');
  });

  it('/payments (POST) - should be idempotent', async () => {
    const idempotencyKey = 'test-key-123';

    // First request
    const res1 = await request(app.getHttpServer())
      .post('/payments')
      .set('Idempotency-Key', idempotencyKey)
      .send({ amount: 100 })
      .expect(201);

    // Retry with same key
    const res2 = await request(app.getHttpServer())
      .post('/payments')
      .set('Idempotency-Key', idempotencyKey)
      .send({ amount: 100 })
      .expect(201);

    expect(res1.body.id).toBe(res2.body.id);
  });
});

Test Utilities

Redis Test Helper

typescript
// test/redis-helper.ts
export class RedisTestHelper {
  constructor(private redis: Redis) {}

  async clearAll(): Promise<void> {
    await this.redis.flushall();
  }

  async clearPattern(pattern: string): Promise<void> {
    const keys = await this.redis.keys(pattern);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }

  async getJson<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async setJson<T>(key: string, value: T, ttl?: number): Promise<void> {
    if (ttl) {
      await this.redis.setex(key, ttl, JSON.stringify(value));
    } else {
      await this.redis.set(key, JSON.stringify(value));
    }
  }
}

Summary

Test TypeWhat to TestRedis
UnitBusiness logicMocked
IntegrationRedis interactionsDocker
E2EFull flowDocker

Next Steps

Released under the MIT License.