Disposable databases with Testcontainers

If your tests hit the same Postgres your dev server uses, they create fake data, delete your seed books, and collide with anything you are working on. Two developers running tests at the same time make it worse. You want a completely fresh database every time the suite runs.

Testcontainers is a library that starts a real Postgres container from your test code, waits for it to be ready, and gives you the connection string. When the suite ends, the container is destroyed and the data is gone. Zero contamination, perfect isolation, no lingering state.

tests/api.test.ts
typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';

let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  // 1. Start the container
  container = await new PostgreSqlContainer('postgres:15-alpine')
    .withDatabase('bookstore_test')
    .withUsername('testuser')
    .withPassword('testpass')
    .start();

  // 2. Override env BEFORE the app loads
  const uri = container.getConnectionUri();
  process.env.DATABASE_URL = uri;
  process.env.JWT_SECRET = 'test_secret_key';

  // 3. Push the Drizzle schema into the fresh database
  execSync('npx drizzle-kit push', {
    env: { ...process.env, DATABASE_URL: uri },
    stdio: 'ignore',
  });

  // 4. Dynamically import the app so it reads the new env
  const mod = await import('../after/index.js');
  app = mod.app;
}, 60000); // allow 60s the first time, in case Docker pulls the image

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

Start a fresh Postgres container, point DATABASE_URL at it, push the schema, then dynamically import the app.

Because your app reads DATABASE_URL the moment it loads. If you use a normal import at the top, that happens before beforeAll runs, so the app connects to the wrong database. Dynamic import runs at the point you call it, after the env is already configured. Set the env, then import. That order is the entire trick.

One operational detail: Testcontainers needs Docker Desktop running on your machine. The first time a test suite runs, it pulls the postgres image, which can take a minute. After that, startup is seconds. Plan your CI timeouts accordingly.

Quiz: Quiz

Loading practice…