
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.
@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:
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:
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():
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:
In tests, we intercept that import and replace it with our PGlite-based 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:
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:
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():
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:
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 |
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 |
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.drizzle-kit generate to produce SQL migrations, then apply them in the test setup. We're working on this.