Testing Guide

Strategies and examples for testing rate-limited applications.

Unit Testing the Limiter

import { describe, it, expect, beforeEach } from 'vitest'
import { hitlimit, memoryStore } from '@joint-ops/hitlimit'

describe('Rate Limiter', () => {
  let store: ReturnType<typeof memoryStore>
  let limiter: ReturnType<typeof hitlimit>

  beforeEach(() => {
    store = memoryStore()
    limiter = hitlimit({
      limit: 3,
      window: '1m',
      store
    })
  })

  it('allows requests under limit', async () => {
    const req = { ip: '127.0.0.1' }
    const res = { set: vi.fn() }
    const next = vi.fn()

    await limiter(req, res, next)

    expect(next).toHaveBeenCalled()
  })

  it('blocks requests over limit', async () => {
    const req = { ip: '127.0.0.1' }
    const res = {
      set: vi.fn(),
      status: vi.fn().mockReturnThis(),
      json: vi.fn()
    }
    const next = vi.fn()

    // Exhaust the limit
    for (let i = 0; i < 3; i++) {
      await limiter(req, res, next)
    }

    // This should be blocked
    await limiter(req, res, next)

    expect(res.status).toHaveBeenCalledWith(429)
  })
})

Integration Testing

import { describe, it, expect } from 'vitest'
import supertest from 'supertest'
import { app } from './app'

describe('API Rate Limiting', () => {
  it('returns rate limit headers', async () => {
    const res = await supertest(app)
      .get('/api/data')
      .expect(200)

    expect(res.headers['ratelimit-limit']).toBe('100')
    expect(res.headers['ratelimit-remaining']).toBeDefined()
    expect(res.headers['ratelimit-reset']).toBeDefined()
  })

  it('returns 429 when rate limited', async () => {
    const requests = Array(101).fill(null).map(() =>
      supertest(app).get('/api/data')
    )

    const responses = await Promise.all(requests)
    const blocked = responses.filter(r => r.status === 429)

    expect(blocked.length).toBeGreaterThan(0)
  })
})

Mocking the Store

import { vi } from 'vitest'

const mockStore = {
  get: vi.fn(),
  increment: vi.fn(),
  reset: vi.fn(),
  close: vi.fn()
}

// Simulate rate limited state
mockStore.increment.mockResolvedValue({
  count: 101,
  resetTime: Date.now() + 60000
})

const limiter = hitlimit({
  limit: 100,
  window: '1m',
  store: mockStore
})

Test Helpers

// test/helpers/ratelimit.ts
import { memoryStore } from '@joint-ops/hitlimit'

export function createTestStore() {
  return memoryStore({ cleanupInterval: 0 })
}

export async function exhaustLimit(
  store: Store,
  key: string,
  limit: number,
  windowMs: number
) {
  for (let i = 0; i < limit; i++) {
    await store.increment(key, windowMs)
  }
}

export async function waitForReset(resetTime: number) {
  const waitMs = resetTime - Date.now() + 100
  if (waitMs > 0) {
    await new Promise(r => setTimeout(r, waitMs))
  }
}

Skipping Rate Limits in Tests

// Disable rate limiting in test environment
const limiter = hitlimit({
  limit: 100,
  window: '1m',
  skip: () => process.env.NODE_ENV === 'test'
})

// Or use a very high limit
const testLimit = process.env.NODE_ENV === 'test'
  ? 1000000
  : 100

const limiter = hitlimit({
  limit: testLimit,
  window: '1m'
})

E2E Testing

import { test, expect } from '@playwright/test'

test('shows rate limit error message', async ({ page }) => {
  // Make requests until rate limited
  for (let i = 0; i < 101; i++) {
    await page.goto('/api/test')
  }

  // Verify error message is displayed
  await expect(page.locator('text=Too many requests'))
    .toBeVisible()
})

Testing Best Practices

  • Use fresh store instances for each test
  • Test both success and rate-limited scenarios
  • Verify correct headers are returned
  • Test custom key extraction functions
  • Test skip conditions work as expected
  • Use time mocking for window-based tests