Integration Testing#
edg is uniquely suited to work as a self-contained integration testing tool for databases. A single config expresses a full test, all the way from schema creation, data population, query execution, assertions, and tear down. The edg all command runs this entire lifecycle and fails if any query errors or expectation is not met, making it a drop-in CI gate.
How it works#
The all command runs five phases in order:
up -> seed -> run -> deseed -> down- up - creates tables and indexes
- seed - populates them with realistic data
- run - executes your query workload with concurrent workers, collecting latency and error metrics
- deseed - truncates tables
- down - drops tables
After run, any expectations are evaluated against the collected metrics. If an expectation fails, edg still runs teardown (deseed and down) before exiting with a failure code.
Writing an integration test#
A complete integration test in a single config:
let customers = 10000
let initial_balance = 10000
let batch_size = 5000
up {
create_customer `CREATE TABLE IF NOT EXISTS customer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email STRING NOT NULL
)`
create_account `CREATE TABLE IF NOT EXISTS account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
balance FLOAT NOT NULL,
customer_id UUID NOT NULL REFERENCES customer(id)
)`
}
seed {
populate_customer(count: customers, size: batch_size)
`INSERT INTO customer (email) __values__` (gen('email'))
populate_account `INSERT INTO account (balance, customer_id)
SELECT $2::FLOAT, c.id
FROM customer c
ORDER BY c.id
OFFSET $1::INT * $3::INT
LIMIT $3::INT` (
batch(customers / batch_size),
initial_balance,
batch_size
)
}
weights {
check_balance = 50
credit_account = 50
}
init {
fetch_accounts `SELECT id FROM account ORDER BY random()`
}
run {
check_balance `SELECT balance FROM account WHERE id = $1::UUID` (
ref('fetch_accounts').id
)
credit_account `UPDATE account SET balance = balance + $2::FLOAT
WHERE id = $1::UUID` (
ref('fetch_accounts').id,
gen('number:1,1000')
)
}
deseed {
truncate_account `TRUNCATE TABLE account CASCADE`
truncate_customer `TRUNCATE TABLE customer CASCADE`
}
down {
drop_account `DROP TABLE IF EXISTS account`
drop_customer `DROP TABLE IF EXISTS customer`
}
expect {
error_rate < 1
check_balance.p99 < 50
credit_account.p99 < 50
tpm > 1000
}Run it:
edg all \
--driver pgx \
--config integration-test.edg \
--url ${DATABASE_URL} \
-w 10 \
-d 30sedg creates the schema, seeds 10,000 customers with accounts, runs balance checks and credits with 10 workers for 30 seconds, then asserts that:
- The overall error rate stays below 1%
- Both queries stay under 50ms at p99
- Throughput exceeds 1,000 transactions per minute
If any assertion fails, the test is deemed to have failed. The database is cleaned up either way.
What to test#
The expectations section supports global and per-query metrics:
Correctness - queries should not error:
expect {
error_rate == 0
make_transfer.error_count == 0
}Latency - queries should respond within bounds:
expect {
get_user.p99 < 25
checkout.p99 < 200
}Throughput - the system should sustain a minimum transaction rate:
expect {
tpm > 5000
}Combined - multiple conditions in a single expression:
expect {
error_rate < 0.5 && tpm > 10000
}Referencing globals - use variables from the globals section to avoid hardcoding values:
let accounts = 10000
let max_error_pct = 5
expect {
error_rate < max_error_pct
query `SELECT COUNT(*) AS cnt FROM account` cnt == accounts
}Multiple test scenarios#
Use separate config files for different test scenarios, or use !includes to share schema definitions while varying seed data and workloads:
test-fixtures/
shared/
schema.edg # up + down (shared across scenarios)
teardown.edg # deseed (shared across scenarios)
happy-path.edg # seed for standard flow
edge-cases.edg # seed for boundary conditions
empty-state.edg # no seed, just schema# happy-path.edg
import "shared/schema.edg" # up + down
import "shared/teardown.edg" # deseed
let users = 500
let batch_size = 100
seed {
populate_users(count: users, size: batch_size)
`INSERT INTO users (email) __values__` (gen('email'))
}Run the scenario you need:
edg all \
--driver pgx \
--config test-fixtures/happy-path.edg \
--url ${DATABASE_URL} \
-w 5 \
-d 30sDeterministic seeding#
The --rng-seed flag makes expression output deterministic. Two runs with the same seed produce identical generated values:
edg all \
--driver pgx \
--config integration-test.edg \
--url ${DATABASE_URL} \
--rng-seed 42 \
-w 10 \
-d 30sFunctions like gen(), uniform.int(), set(), and other random expressions return the same sequence each time. This makes test runs reproducible and debugging easier because the data is predictable across runs.
CI pipeline example#
GitHub Actions#
name: Integration Tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U test"
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: "postgres://test:test@localhost:5432/testdb?sslmode=disable"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: Install edg
run: go install github.com/codingconcepts/edg@latest
- name: Run integration tests
run: |
edg all \
--driver pgx \
--config integration-test.edg \
--url ${DATABASE_URL} \
--rng-seed 42 \
-w 10 \
-d 30sBecause edg all handles setup, execution, assertions, and teardown in a single command, the pipeline step is just one invocation. A non-zero exit fails the build.
Docker Compose#
For local development, pair edg with a docker-compose.yaml that spins up the database:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "test"]
interval: 2s
timeout: 5s
retries: 5Then run tests as follows:
docker compose up -d --wait
edg all \
--driver pgx \
--config integration-test.edg \
--url ${DATABASE_URL} \
--rng-seed 42 \
-w 10 \
-d 30s
docker compose downRunning phases separately#
For debugging or when you want to inspect the database between phases, run each step individually:
edg up --driver pgx --config integration-test.edg --url ${DATABASE_URL}
edg seed --driver pgx --config integration-test.edg --url ${DATABASE_URL}
# Inspect the database, run ad-hoc queries, etc.
edg run --driver pgx --config integration-test.edg --url ${DATABASE_URL} -w 10 -d 30s
edg deseed --driver pgx --config integration-test.edg --url ${DATABASE_URL}
edg down --driver pgx --config integration-test.edg --url ${DATABASE_URL}Driver-specific considerations#
Spanner emulator setup#
The Spanner emulator requires an instance and database to be created before edg can connect. After starting the container, use the REST admin API on port 9020:
docker run \
-d \
--name spanner \
-p 9010:9010 \
-p 9020:9020 \
gcr.io/cloud-spanner-emulator/emulator
# Create instance.
curl -s "http://localhost:9020/v1/projects/test-project/instances" \
--json '{
"instanceId": "test-instance",
"instance": {
"config": "emulator-config",
"displayName": "Test",
"nodeCount": 1
}
}'
# Create database.
curl -s "http://localhost:9020/v1/projects/test-project/instances/test-instance/databases" \
--json '{
"createStatement": "CREATE DATABASE testdb"
}'
# Run edg
SPANNER_EMULATOR_HOST=localhost:9010 \
edg all \
--driver spanner \
--config workload.edg \
--url "projects/test-project/instances/test-instance/databases/testdb" \
--rng-seed 42 \
-w 10 \
-d 30s
docker rm -f spannerThe
SPANNER_EMULATOR_HOSTenvironment variable tells the Spanner client library to connect to the local emulator instead of Google Cloud.
Spanner PostgreSQL dialect setup#
Spanner databases created with the PostgreSQL dialect are reached through PGAdapter, which speaks the PostgreSQL wire protocol. There’s no dedicated driver for this: use --driver pgx and point --url at PGAdapter.
docker run \
-d \
--name pgadapter-emulator \
-p 5432:5432 \
gcr.io/cloud-spanner-pg-adapter/pgadapter-emulator
# Run edg. PGAdapter creates the database on first connection.
edg all \
--driver pgx \
--config workload.edg \
--url "postgres://localhost:5432/testdb?sslmode=disable" \
--rng-seed 42 \
-w 10 \
-d 30s
docker rm -f pgadapter-emulatorThe dialect is PostgreSQL, but the engine is still Spanner, so its restrictions apply:
| Concern | pgx (PostgreSQL) | pgx (CockroachDB) | pgx (Spanner PostgreSQL) |
|---|---|---|---|
Primary key delcared in CREATE | optional | optional | required |
| Row generation | generate_series(1, $1) | generate_series(1, $1) | unsupported - seed with type: exec_batch |
| Cleanup | TRUNCATE TABLE t | TRUNCATE TABLE t | DELETE FROM t WHERE TRUE |
SQL syntax differences#
The examples above use PostgreSQL/CockroachDB (pgx) syntax. When targeting other drivers, adjust the SQL accordingly. Key differences for Spanner (GoogleSQL):
| Concern | pgx | Spanner |
|---|---|---|
| UUID column | UUID DEFAULT gen_random_uuid() | STRING(36) DEFAULT (GENERATE_UUID()) |
| Primary key | inline PRIMARY KEY | PRIMARY KEY (col) at table level |
| Batch expansion | __values__ (works across all drivers) | __values__ (works across all drivers) |
| Type cast | $1::UUID, $2::FLOAT | CAST($1 AS STRING), CAST($2 AS FLOAT64) |
| Random ordering | ORDER BY random() | TABLESAMPLE RESERVOIR (N ROWS) |
| Cleanup | TRUNCATE TABLE t CASCADE | DELETE FROM t WHERE TRUE |
| Bind params | $1, $2 | @p1, @p2 |
For complete Spanner examples, see the built-in workloads which include Spanner variants for every benchmark.
Tips#
- Use
--rng-seedin CI. Deterministic data eliminates an entire class of flaky tests caused by random values. - Keep seed data small. Integration tests should be fast. Thousands of rows are usually enough – save millions for load testing.
- Validate configs in CI. Add
edg validate --config integration-test.edgas an earlier pipeline step to catch config errors before they hit the database. - Use
stagesfor ramp-up tests. The stages section lets you vary worker counts and durations across phases, useful for testing how your database handles increasing load.