Testing Guide
Injectable clock
Section titled “Injectable clock”All pg-ratelimit SQL uses a parameterized now value instead of Postgres’s now(). This means you can control time in tests without sleeps.
import { Ratelimit } from "pg-ratelimit";
let currentTime = new Date("2025-01-01T00:00:00Z");const clock = () => currentTime;
const ratelimit = new Ratelimit({ pool, limiter: Ratelimit.fixedWindow(5, "1m"), prefix: "test", clock,});Every call to limit(), getRemaining(), etc. uses clock() to get the current time, which flows into all SQL queries as a parameter.
testcontainers setup
Section titled “testcontainers setup”pg-ratelimit tests against real Postgres using testcontainers - a library that spins up Docker containers for testing.
-
Install test dependencies
Terminal window pnpm add -D vitest @testcontainers/postgresql -
Create a test setup file
tests/setup.ts import { PostgreSqlContainer } from "@testcontainers/postgresql";import { Pool } from "pg";let container: any;let pool: Pool;export async function setup() {container = await new PostgreSqlContainer().start();pool = new Pool({ connectionString: container.getConnectionUri() });return pool;}export async function teardown() {await pool.end();await container.stop();}export function getPool() {return pool;} -
Write tests with truncation between cases
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";import { Ratelimit } from "pg-ratelimit";import { setup, teardown, getPool } from "./setup";let pool: Pool;beforeAll(async () => {pool = await setup();});afterEach(async () => {await pool.query("TRUNCATE rate_limit_ephemeral, rate_limit_durable");});afterAll(async () => {await teardown();});it("allows requests within the limit", async () => {const ratelimit = new Ratelimit({pool,limiter: Ratelimit.fixedWindow(5, "1m"),prefix: "test",});for (let i = 0; i < 5; i++) {const result = await ratelimit.limit("user:1");expect(result.success).toBe(true);}});
Time manipulation patterns
Section titled “Time manipulation patterns”Fast-forwarding past a window
Section titled “Fast-forwarding past a window”let currentTime = new Date("2025-01-01T00:00:00Z");const clock = () => currentTime;
const ratelimit = new Ratelimit({ pool, limiter: Ratelimit.fixedWindow(5, "1m"), prefix: "test", clock,});
// Exhaust the limitfor (let i = 0; i < 5; i++) { await ratelimit.limit("user:1");}expect((await ratelimit.limit("user:1")).success).toBe(false);
// Fast forward past the windowcurrentTime = new Date("2025-01-01T00:01:01Z");expect((await ratelimit.limit("user:1")).success).toBe(true);Testing sliding window weights
Section titled “Testing sliding window weights”let currentTime = new Date("2025-01-01T00:00:00Z");const clock = () => currentTime;
const ratelimit = new Ratelimit({ pool, limiter: Ratelimit.slidingWindow(10, "1m"), prefix: "test", clock,});
// Use 8 tokens in the first windowfor (let i = 0; i < 8; i++) { await ratelimit.limit("user:1");}
// Move to 30% into the next windowcurrentTime = new Date("2025-01-01T00:01:18Z");
// Effective count: 8 × 0.7 + 0 = 5.6// So ~4 more requests should be allowedTesting token refill
Section titled “Testing token refill”let currentTime = new Date("2025-01-01T00:00:00Z");const clock = () => currentTime;
const ratelimit = new Ratelimit({ pool, limiter: Ratelimit.tokenBucket(5, "10s", 20), prefix: "test", clock,});
// Use 15 tokensfor (let i = 0; i < 15; i++) { await ratelimit.limit("user:1");}
// Advance 10 seconds - should refill 5 tokenscurrentTime = new Date("2025-01-01T00:00:10Z");const result = await ratelimit.limit("user:1"); // triggers refill calculationexpect(result.remaining).toBe(9); // 5 left + 5 refilled - 1 consumedKey test cases
Section titled “Key test cases”These are the most important scenarios to cover:
- Window enforcement: N requests allowed, N+1 rejected, window resets after duration
- Sliding window boundary: Weighted previous window count produces correct effective count
- Token bucket refill: Tokens refill at the correct rate, capped at max
- Concurrency: Fire 100 simultaneous
limit()calls - verify exactly the right number succeed - Durability: Durable table is logged (
pg_class.relpersistence = 'p'), ephemeral is unlogged ('u') - Negative rate: Tokens refund correctly and can exceed max limit
- Cleanup: Expired rows deleted on
limit()call, only own prefix affected blockUntilReady: Respects timeout, returns early when time untilresetexceeds remaining timeout- Clock injection: Time manipulation works without real sleeps