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
  1. up - creates tables and indexes
  2. seed - populates them with realistic data
  3. run - executes your query workload with concurrent workers, collecting latency and error metrics
  4. deseed - truncates tables
  5. 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 30s

edg 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 30s

Deterministic 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 30s

Functions 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 30s

Because 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: 5

Then 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 down

Running 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 spanner

The SPANNER_EMULATOR_HOST environment 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-emulator

The dialect is PostgreSQL, but the engine is still Spanner, so its restrictions apply:

Concernpgx (PostgreSQL)pgx (CockroachDB)pgx (Spanner PostgreSQL)
Primary key delcared in CREATEoptionaloptionalrequired
Row generationgenerate_series(1, $1)generate_series(1, $1)unsupported - seed with type: exec_batch
CleanupTRUNCATE TABLE tTRUNCATE TABLE tDELETE 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):

ConcernpgxSpanner
UUID columnUUID DEFAULT gen_random_uuid()STRING(36) DEFAULT (GENERATE_UUID())
Primary keyinline PRIMARY KEYPRIMARY KEY (col) at table level
Batch expansion__values__ (works across all drivers)__values__ (works across all drivers)
Type cast$1::UUID, $2::FLOATCAST($1 AS STRING), CAST($2 AS FLOAT64)
Random orderingORDER BY random()TABLESAMPLE RESERVOIR (N ROWS)
CleanupTRUNCATE TABLE t CASCADEDELETE 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-seed in 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.edg as an earlier pipeline step to catch config errors before they hit the database.
  • Use stages for ramp-up tests. The stages section lets you vary worker counts and durations across phases, useful for testing how your database handles increasing load.