
We test our entire tRPC backend 18 tables, foreign keys, JSONB columns, array types without Docker, without a real PostgreSQL server, and without any external infrastructure.
The database runs inside the test process. It's PostgreSQL compiled to WebAssembly. And it works.
Repo: https://github.com/rogasper/labas-bahasa
@electric-sql/pglite) runs PostgreSQL in-memory via WebAssembly no Docker, no Testcointainers, no external databases needed. Combined with Bun's mock.module for dependency injection, it enables full integration tests that run in under 3 seconds.@labas/db to return the PGlite-based Drizzle instance, then use tRPC's createCaller to test routers end-to-end with real SQL queries.Bun.sleep(4500) to satisfy the constraint, proving the test runner can handle async timing-sensitive code.Unit tests are easy. Mock the database, test the logic, done. But integration test the ones that verify your tRPC routers actually execute correct SQL against real tables need a real database.
The traditional approaches:
gen_random_uuid() so your tests don't match production.We chose a fifth option: PGlite PostgreSQL compiled to WebAssembly, running in-process.
PGlite is an open-source project by Electric SQL that compiles PostgreSQL to WebAssembly. It runs entirely in memory no network, no external process, no Docker. You import it like any npm package:
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
const pg = new PGlite();
const db = drizzle(pg, { schema });That's it. You now have a PostgreSQL database running inside your Javascript process. It supports JSONB, array types, foreign keys, gen_random_uuid(), and most PostgreSQL features. It just doesn't persist to disk which is perfect for tests.
Our test setup file (packages/api/src/__tests__/test-setup.ts) creates all 14 application tables via raw SQL:
async function initSchema(pg: PGlite) {
const tables = [
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, ...)`,
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, ...)`,
`CREATE TABLE IF NOT EXISTS "question" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, ...)`,
`CREATE TABLE IF NOT EXISTS "test_package" (id uuid DEFAULT gen_random_uuid() PRIMARY KEY, ...)`,
`CREATE TABLE IF NOT EXISTS "package_section" (...)`,
`CREATE TABLE IF NOT EXISTS "section_question" (...)`,
`CREATE TABLE IF NOT EXISTS "test_attempt" (...)`,
`CREATE TABLE IF NOT EXISTS "section_result" (...)`,
`CREATE TABLE IF NOT EXISTS "answer" (...)`,
`CREATE TABLE IF NOT EXISTS "generation_job" (...)`,
// ... 18 tables total
];
for (const sql of tables) {
await pg.exec(sql);
}
}Why raw SQL instead of Drizzle's migration system? Because drizzle-kit push requires a separate CLI process. In tests, we want everything in-process. Raw SQL is verbose but fast and the schema is stable enough that we don't change it often.
We also have a second setup file (test-db.ts) that uses a slightly different approach enabling the pgcrypto extension explicity for gen_random_uuid():
await _client.exec("CREATE EXTENSION IF NOT EXISTS pgcrypto");This inconsistency shows the project evolving its test strategy. The first setup file works because PGlite includes pgcrypto by default. The second is more explicit. Both work.
Here's where it gets clever. Our application code imports the database from @labas/db:
import { db } from "@labas/db";In tests, we intercept that import and replace it with our PGlite-based instance:
import { mock } from "bun:test";
mock.module("@labas/db", () => ({
__esModule: true,
...schema,
db: testDb, // PGlite-based Drizzle instance
}));When the tRPC router imports @labas/db, it gets our test database instead of the real one. No code changes needed in the router. No dependency injection framework. Just module mocking.
We also mock environment variables:
mock.module("@labas/env/server", () => ({
env: {
DATABASE_URL: "postgres://localhost:5432/test",
BETTER_AUTH_SECRET: "a".repeat(32),
BETTER_AUTH_URL: "http://localhost:3000",
API_KEY_ENCRYPTION_KEY: "z".repeat(32),
// ... all required env vars
},
}));This prevents the env validation from failing during tests it expects real values, but we're running in-memory.
With the database and end mocked, we can test tRPC routers end-to-end:
describe("attempt router", () => {
function makeCaller(userId: string) {
return attemptRouter.createCaller({
session: { user: { id: userId }, expiresAt: new Date() },
auth: null,
});
}
it("start creates a new attempt", async () => {
const { caller } = await createUserAndCaller("start");
const result = await caller.start({ packageId });
expect(result).toHaveProperty("attemptId");
});
it("getById strips correctAnswer during in_progress", async () => {
const { caller } = await createUserAndCaller("strip");
const { attemptId } = await caller.start({ packageId });
const result = await caller.getById({ id: attemptId });
for (const s of result.sections) {
for (const q of s.questions) {
expect(q.correctAnswer).toBeUndefined();
expect(q.explanation).toBeUndefined();
}
}
});
});The createCaller pattern is tRPC's built-in test utility. It creates a caller object that invokes router procedures directly no HTTP layer, no network. Combined with PGlite, the entire test runs in-process.
One of our business rules: you can't finish an exam in under 5 seconds. The test verifies this with Bun.sleep():
it("finish rejects attempts under 5 seconds", async () => {
const { caller } = await createUserAndCaller("fastfinish");
const { attemptId } = await caller.start({ packageId });
expect(caller.finish({ attemptId })).rejects.toThrow("terlalu cepat");
});
it("finish scores correct answers", { timeout: 30000 }, async () => {
const { caller } = await createUserAndCaller("score");
const { attemptId } = await caller.start({ packageId });
await Bun.sleep(4500); // Wait past the 5-second minimum
// ... submit answers, finish, verify scores
expect(result.totalScore).toBe(1);
expect(result.maxScore).toBe(3);
expect(result.percentage).toBe(33);
});The test sleeps for 4.5 seconds to satisfy the 5-second minimum. The { timeout: 3000 } option gives Bun 30 seconds for the entire test case enough time for the sleep plus the database operations.
This is a genuine integration test. It verifies that the router's timing logic works correctly with real timestamps, real database inserts, and real score computation.
Another test verifies rate limiting you can't start two exam attempts within 3 seconds:
it("start rejects duplicate in-progress attempt", { timeout: 15000 }, async () => {
const { caller } = await createUserAndCaller("dup");
await caller.start({ packageId });
await Bun.sleep(3100); // wait for rate limit window (3000ms)
expect(caller.start({ packageId })).rejects.toThrow("sedang berjalan");
});The rate limiter uses an in-memory Map (not Redis, not a database table). The test works because the Map persist across test cases within the same process which is why each test uses a unique user label ("dup", "start", "strip") to avoid cross-test interference.
| Approach | Setup time | Docker needed | JSONB support | Array types | gen_random_uuid() |
|---|---|---|---|---|---|
| PGlite | ~200ms | No | Yes | Yes | Yes |
PGlite wins on setup speed and zero infrastructure. The tradeoff: it's not a real PostgreSQL server, so edge cases (complex queries, performance characteristics) might differ. But for integration tests that verify business logic, it's more than sufficient.
PGlite isn't perfect. Here's what we've encountered:
test-setup.ts and test-db.ts a sign of evovling test strategy. They should eventually be consolidated.CREATE TABLE statements manually instead of using Drizzle's migration system. When the schema changes, we update both Drizzle schema and the test SQL a potential drift point.| Docker Compose | ~5-10s | Yes | Yes | Yes | Yes |
| Testcontainers | ~10-15s | Yes | Yes | Yes | Yes |
| SQLite | ~50ms | No | No | No | No |
| Shared PG | ~0ms | Yes | Yes | Yes | Yes |
drizzle-kit generate