Terratest Integration Tests for PostGIS Modules
Everything about a PostGIS module that can be checked cheaply — version pins, encryption, subnet placement, parameter presence — is already checked by static analysis and plan assertions. What remains is the class of defect that only exists once the database is running: the extension that did not install, the index the planner refuses to use, the parameter that applied in the wrong unit, the replica that lags. Those need a real cluster, and Terratest is the standard way to get one, exercise it, and destroy it. This guide extends Testing and Validation for Spatial IaC within CI/CD Automation and Governance.
What is worth testing at this layer
The integration layer is slow and expensive, so it should test only what cannot be tested faster. For a PostGIS module, four things qualify.
The extension exists at the pinned version. A managed PostgreSQL instance without PostGIS applies cleanly and fails at the first spatial query. Nothing in the plan proves the extension was created — a CREATE EXTENSION executed by a provisioner or a migration is invisible until it runs.
The planner uses the spatial index. This is the assertion that justifies the whole layer. An index that exists but is never chosen is functionally absent, and whether it is chosen depends on statistics, on random_page_cost and on the data distribution — none of which are visible in a plan. It also requires a fixture large enough for the planner to behave realistically, because on a table of ten rows a sequential scan is genuinely correct.
Memory parameters resolve to the intended sizes. SHOW shared_buffers returning 64MB when you configured what you thought was 8 GB is the unit error caught in one line, and it is caught nowhere else.
Replica topology behaves. A read replica that exists but is not receiving changes, or whose endpoint points at the primary, is a common misconfiguration that a plan cannot detect.
Prerequisites and environment assumptions
Go 1.22 or later with Terratest. Terraform 1.6 or later. A dedicated test account with its own state backend, because a suite that can reach production state can damage it and the strongest guarantee is that it cannot authenticate there at all. A seeded fixture published as a versioned artifact — a few thousand features with a realistic spatial distribution, not ten rows in a square — and pinned by version in the suite so a fixture regeneration cannot masquerade as a regression.
Budget the wall-clock time honestly. Provisioning a managed database is measured in minutes, not seconds, so this suite belongs on merge to the default branch and on a schedule rather than on every push. A forty-minute stage attached to every pull request will be marked optional within a month, at which point it protects nothing.
Step-by-step implementation
-
Give every run a unique prefix and its own state key. Concurrent runs then cannot collide, and orphaned resources are identifiable by name — which is what makes an automated sweep possible without maintaining an inventory.
-
Defer the destroy before the apply. Registering teardown first means it runs even when an assertion panics. This one ordering detail is the difference between a failed test and a failed test plus a running database cluster.
-
Wait for readiness with a bounded retry, not a sleep. A managed cluster reports available before it accepts connections reliably, and a fixed sleep is either too short or wasteful. Retry the actual condition — a successful connection — with a deadline.
-
Seed the fixture, then
ANALYZE. Without statistics the planner will choose a sequential scan regardless of what indexes exist, and the index assertion will fail for a reason that has nothing to do with the module. -
Assert behaviour, not configuration. Read the module’s outputs and assert relationships against them rather than restating literals — a test asserting
replica_count == 2while the module takes a variable will pass forever and mean nothing once production moves to three.
package test
import (
"database/sql"
"fmt"
"strings"
"testing"
"time"
_ "github.com/lib/pq"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/retry"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPostGISModule(t *testing.T) {
t.Parallel()
prefix := fmt.Sprintf("tftest-%s", random.UniqueId())
opts := &terraform.Options{
TerraformDir: "../modules/postgis-cluster",
Vars: map[string]interface{}{
"name_prefix": prefix,
"postgis_version": "3.4",
"instance_memory_gb": 8,
"max_connections": 100,
"replica_count": 1,
// Pinned fixture: an unversioned one turns a regeneration into a
// failed build that looks like a regression.
"fixture_uri": "s3://gis-test-fixtures/parcels/v7/parcels.sql.gz",
},
BackendConfig: map[string]interface{}{
"bucket": "gis-terraform-test-state",
"key": fmt.Sprintf("integration/%s.tfstate", prefix),
},
}
// Deferred FIRST: this runs even if an assertion below panics, which is the
// difference between a failed test and a failed test plus a live cluster.
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
dsn := terraform.Output(t, opts, "connection_string")
var db *sql.DB
// A managed cluster reports available before it reliably accepts
// connections. Retry the real condition rather than sleeping a guess.
retry.DoWithRetry(t, "connect", 30, 10*time.Second, func() (string, error) {
var err error
db, err = sql.Open("postgres", dsn+"?sslmode=verify-full")
if err != nil {
return "", err
}
return "", db.Ping()
})
defer db.Close()
t.Run("extension is present at the pinned version", func(t *testing.T) {
var version string
require.NoError(t, db.QueryRow(
"SELECT extversion FROM pg_extension WHERE extname = 'postgis'").Scan(&version))
assert.True(t, strings.HasPrefix(version, "3.4"),
"expected PostGIS 3.4.x, module reported %s", version)
})
t.Run("memory parameters resolved to the intended size", func(t *testing.T) {
var sharedBuffers string
require.NoError(t, db.QueryRow("SHOW shared_buffers").Scan(&sharedBuffers))
// The 8 kB block unit error shows up here as a value wrong by orders of
// magnitude, and it shows up nowhere else.
assert.Equal(t, "2GB", sharedBuffers,
"shared_buffers wrong — check the 8 kB block arithmetic")
})
t.Run("planner chooses the spatial index", func(t *testing.T) {
// Without statistics the planner sequentially scans regardless of what
// indexes exist, and the assertion would fail for the wrong reason.
_, err := db.Exec("ANALYZE parcels")
require.NoError(t, err)
var plan string
require.NoError(t, db.QueryRow(`
EXPLAIN (FORMAT TEXT)
SELECT count(*) FROM parcels
WHERE geom && ST_MakeEnvelope(-122.5, 37.7, -122.3, 37.9, 4326)`).Scan(&plan))
assert.Contains(t, plan, "Index Scan",
"planner chose a sequential scan — index, statistics, or fixture size")
})
t.Run("replica receives changes", func(t *testing.T) {
readDSN := terraform.Output(t, opts, "reader_connection_string")
replica, err := sql.Open("postgres", readDSN+"?sslmode=verify-full")
require.NoError(t, err)
defer replica.Close()
marker := random.UniqueId()
_, err = db.Exec("INSERT INTO replica_probe (marker) VALUES ($1)", marker)
require.NoError(t, err)
// Replication is asynchronous: retry to a deadline rather than
// asserting immediately and calling the result flaky.
retry.DoWithRetry(t, "replica catches up", 20, 3*time.Second, func() (string, error) {
var found int
if err := replica.QueryRow(
"SELECT count(*) FROM replica_probe WHERE marker = $1", marker).Scan(&found); err != nil {
return "", err
}
if found != 1 {
return "", fmt.Errorf("marker not yet replicated")
}
return "", nil
})
})
}
Verification
Verify the suite catches real defects by breaking the module deliberately. Remove the extension creation and confirm the first assertion fails with a message that names the problem. Set shared_buffers in bytes instead of blocks and confirm the memory assertion catches it. Drop the spatial index and confirm the planner assertion fails. A suite that has never been shown to fail is a suite nobody has verified.
Then verify the hygiene properties. Interrupt a run between apply and destroy and confirm the resources it left carry the run prefix, so the sweep can find them. Check the test account for accumulated resources after a week of runs; anything older than a day with a test prefix means teardown is not reliably reached and the sweep is doing necessary work.
Preventing recurrence
- Run a scheduled sweep on the prefix. Deferred teardown covers panics but not a cancelled job or a dead runner, so a sweep destroying anything with the test prefix beyond a maximum age is a required companion.
- Keep the suite off the per-pull-request path. Static analysis and plan assertions run on every request; this runs on merge and on a schedule, so it stays enabled.
- Version the fixture and pin it. Assertions encode expectations about the data, and an unversioned fixture makes every regeneration look like a regression.
- Name an owner for the suite. A red build nobody owns is resolved by whoever is most inconvenienced, and the cheapest resolution is always to skip the test.
Frequently Asked Questions
Why not test against a local PostGIS container instead?
Use both, for different things. A container is excellent for testing SQL, extensions and index behaviour quickly. It cannot test the managed service’s parameter groups, replica topology, encryption or networking, which is exactly the part of a module that a plan also cannot test — so the container replaces some assertions and none of the reason for this layer.
The index assertion is flaky. What is wrong?
Usually the fixture, and occasionally a missing ANALYZE. A planner near the cost boundary between scan types will flip on small changes, so the fixture needs to be comfortably past that boundary rather than at it. Increase the row count and confirm the plan is stable across several runs before trusting it.
How do I keep the suite from taking an hour?
Run test cases in parallel with distinct prefixes, and keep the module under test small. If one module takes fifteen minutes to provision, that is a signal about the module’s blast radius as much as about the test, and splitting it is likely to help both.
Should the suite have credentials to production?
No. The strongest guarantee that a test cannot damage production is that it cannot authenticate to it. Give the runner an identity in the test account only, and if a shared resource genuinely must be reachable, grant a narrowly scoped read rather than widening the trust policy.
Related
- Testing and Validation for Spatial IaC — the parent topic defining the four validation layers
- Static Analysis with Checkov and tfsec for Geospatial Stacks — the fast layer that runs long before this one
- Tuning RDS Parameter Groups for PostGIS Workloads — the arithmetic the memory assertion protects
- PostGIS Cluster Provisioning — the module under test