Seed a disposable Postgres for integration tests
Starting a Postgres container in CI takes seconds. Filling it with data that is referentially intact and identical on every run is the part that decides whether your integration suite proves anything.
The container comes up in four seconds. That is the easy part, and it is where most integration-test guides stop: a docker compose up, a health check, a DATABASE_URL, and a green pipeline.
Then the suite runs against a database with no rows in it. Or against the three fixture rows someone committed in 2024. Or against a fresh random dataset every time, so a test that failed on Tuesday cannot be made to fail again on Wednesday. The tests pass, and the thing they were supposed to verify, which is how your application behaves against data, is never exercised at all.
A disposable database has two requirements that pull in different directions. It has to be cheap enough to destroy on every run, and its contents have to be reproducible enough that a failure means something. Getting both is the whole job.
Starting Postgres is the easy half
One container is enough, and it does not need a source database sitting next to it.
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: weavori
POSTGRES_PASSWORD: weavori
POSTGRES_DB: app
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weavori -d app"]
interval: 2s
timeout: 3s
retries: 20Two details matter more than they look. The health check is not decoration: depends_on waits for the container to start, not for Postgres to accept connections, so a readiness loop or a healthcheck is the difference between a reliable suite and a race. And the image tag should be pinned. postgres:18-alpine behaves differently from postgres:latest on the day a new major ships, and an integration test that changes its database because someone else released software is not a test.
Fill it from the schema, not from a dump
The instinct is to load a pg_dump of staging. That works until the dump is three weeks stale, or it contains customer rows, or it is 40 GB and the pipeline spends longer restoring it than running the tests.
The schema is the only input a generator actually needs. Weavori reads the same CREATE TABLE statements a migration would apply and writes rows into the container in foreign-key order, parents before children:
There is no source database in that command, which means no production credentials in CI and no network path to a real cluster. The container is empty, the schema file is in the repository, and the target tables are created as part of the run (--create-tables is on by default). If you would rather have your real migrations build the schema, run them first and pass --create-tables=false, so the test exercises the same DDL path production does.
This is also the honest reason to generate instead of dump: the data has no PII in it by construction, and it is never stale relative to the schema in the same commit. When a test genuinely needs production's exact rows, a dump or a masked slice of production is the right tool, and neither belongs in a job that any pull request can trigger.
Reproducibility is what makes the container disposable
Here is the failure that costs a day. A test fails once in twenty runs. It passes locally. It passes when you re-run the job. Nobody can reproduce it, so it gets marked flaky and skipped, and whatever it was catching stays in the code.
The cause is usually the database, not the test. If each run draws fresh random values, the one run that produced a specific collision or a specific ordering is gone forever. A container you can throw away is not the same thing as a database whose contents you can reproduce, and a test suite needs the second property.
--seed 42 makes the run byte-identical: the same seed against the same schema produces the same rows, in the same order, with the same derived values. A failure becomes an input you can replay instead of a story about Tuesday. And the seed does not have to be memorized, because every run prints it, including the runs where you let it pick randomly. The line looks like this:
Seed: 4217933 (random, replay with --seed 4217933)That line is the difference between "it failed once" and a command you can paste into a terminal. In CI, log it. The seed in the job output is the reproduction step.
The CI mechanics that actually bite
The generation itself is one command. What breaks a pipeline is the environment around it.
- Authentication has no keyring. A CI runner cannot open a browser or unlock an OS keychain, so use
WEAVORI_API_KEY(or--api-key). An absent credential exits2rather than falling back to an interactive prompt that hangs the job. - Machine-readable output.
--output plaingives RFC 3339 timestamps and no terminal styling;--output jsonemits JSONL. Both are safe to pipe into logs and log aggregators, whereprettyproduces noise. - Branch on exit codes, not on message text. Auth is
2, quota is4, and a general failure is1, so a retry policy for a quota error does not accidentally retry an auth error forever. The full contract is in the generate reference. - Licenses validate offline. A runner on a restricted network does not need an outbound call to generate. That matters in air-gapped or egress-filtered environments, where a tool that phones home fails for reasons unrelated to your code.
- Cache, and know how to bypass it. The schema cache skips re-introspection between runs. When you are debugging a schema change and the output looks stale,
--no-cacheforces a fresh read.
A GitHub Actions step ends up small, because the container service does the heavy lifting:
- name: Generate synthetic data
env:
WEAVORI_API_KEY: ${{ secrets.WEAVORI_API_KEY }}
run: |
weavori generate --input schema.sql --input-format ddl \
--target "postgres://weavori:weavori@localhost:5432/app" \
--rows 1000 --seed 42 --yes --output plainCompose or Testcontainers
They solve different lifecycle problems, and the deciding question is who owns the container.
| Docker Compose | Testcontainers | |
|---|---|---|
| Who owns the lifecycle | You, or the CI runner | The test process |
| Isolation | Shared across the job | Per suite or per test class |
| Startup cost | Once per job | Once per suite, torn down after |
| Best for | A fixed local loop, a simple CI job | Suites that need their own database and clean up after themselves |
| Cost of getting it wrong | A stale container between runs | Slower suites if you start one per test |
Neither changes the data problem. With Testcontainers, the test process starts Postgres and owns its lifetime, and you run the same generation command against the mapped DSN before the suite begins. With Compose, the pipeline starts the database once and every step shares it. Pick on lifecycle, not on which one has a nicer README, and make the seed deterministic in both.
Assert on the data, not only on the application
An integration suite that only asserts HTTP status codes leaves the database unverified. The cheap addition is a set of assertions about the data itself, run with ON_ERROR_STOP so a failure fails the job:
DO $$
DECLARE orphans integer; bad_emails integer;
BEGIN
SELECT count(*) INTO orphans
FROM projects p LEFT JOIN users u ON u.id = p.owner_id
WHERE u.id IS NULL;
SELECT count(*) INTO bad_emails
FROM users WHERE email !~ '@example\.com$' OR email <> lower(email);
IF orphans > 0 THEN
RAISE EXCEPTION '% project(s) reference missing owners', orphans;
END IF;
IF bad_emails > 0 THEN
RAISE EXCEPTION '% email(s) not derived from the formula', bad_emails;
END IF;
END $$;Two things are being tested here that application code cannot test. The first is that generation produced a referentially closed dataset, so the suite is not passing because the tables happened to be empty. The second is that a formula still derives what it is supposed to derive, which catches the day someone changes the derivation and every downstream assumption silently shifts.
What this does not do
Honest boundaries
A generated container validates schema, constraints, and query shape. It is not a performance benchmark: a thousand rows will not reproduce the plan a million rows produce, so size the dataset for the question you are asking. It also does not contain the specific production row that breaks tonight's migration, because that row exists in your real tables and has to be found there, which is the case for a masked slice of production. And a seed reproduces rows for a given schema and version, so changing the schema changes the output even when the seed is unchanged. The guarantee is repeatability, not immutability.
Summary
- The container is the easy half. Readiness and a pinned image tag are the two details that keep it reliable.
- Generate from the schema file. No source database, no production credentials, no stale dump, and no PII by construction.
- A disposable database still has to be reproducible.
--seedmakes a failed run replayable, and the seed is printed on every run so you never have to guess. - The environment is where pipelines break. API key,
plain/jsonoutput, exit codes, offline license validation, and--no-cachewhen the cache lies to you. - Assert on the data. Row counts, orphan checks, and derived-value checks catch the failures application tests structurally cannot see.
The full runnable version, including the compose file and the GitHub Actions workflow, is cookbook recipe 09. For the data itself, Realistic synthetic data is more than valid foreign keys covers the distribution and coherence controls that decide whether the rows behave like a population instead of a random draw.