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.

Four assertions that need a running database Each of these four checks is invisible to static analysis and to plan assertions, so each justifies the cost of provisioning a real cluster. The extension check confirms PostGIS was actually created at the pinned version, which a plan cannot show when creation happens through a provisioner or a migration. The index-usage check confirms the query planner chooses the spatial index on realistic data, which depends on statistics and cost settings rather than on configuration. The memory check confirms the configured values resolved to the sizes intended, catching the eight-kilobyte-block unit error in one line. The replica check confirms changes actually reach the read endpoint and that the endpoint is not silently pointing at the primary. Extension present, pinned a cluster without PostGIS applies cleanly and fails at the first spatial query Planner uses the index an index never chosen is functionally absent needs a fixture big enough to be realistic Memory in the right units SHOW returns 64MB where you meant 8GB caught in one line, and nowhere else Replica actually replicates a reader endpoint pointing at the primary looks identical until write load arrives

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Assert behaviour, not configuration. Read the module’s outputs and assert relationships against them rather than restating literals — a test asserting replica_count == 2 while 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
        })
    })
}
Fixture size decides whether the index assertion is meaningful Against a table of ten rows the planner chooses a sequential scan, and it is right to — reading ten rows directly is cheaper than consulting an index. An index assertion against that fixture fails for a reason that has nothing to do with the module under test, and the usual response is to weaken the assertion, at which point it tests nothing. Against a few thousand features with a realistic spatial distribution the planner chooses the index scan, and the assertion then genuinely tests that the index exists, that statistics were gathered and that the cost settings are sane. The fixture must also be analysed before the assertion runs, because a table with no statistics is treated as small regardless of its actual size. Ten rows planner chooses a sequential scan and it is right to — ten rows is cheaper direct the assertion fails for the wrong reason so it gets weakened, and then tests nothing A few thousand features realistic spatial distribution ANALYZE run before the assertion planner chooses the index scan the assertion tests what it claims to A table with no statistics is treated as small whatever its row count — so ANALYZE is part of the fixture, not an extra.

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.

Three deliberate breakages, three specific failures A suite that has never failed is a suite nobody has verified, so break the module on purpose and confirm each assertion catches its own defect. Removing the extension creation must fail the version assertion. Expressing shared buffers as a byte count rather than in eight-kilobyte blocks must fail the memory assertion, which is the check that exists precisely for that mistake. Dropping the spatial index must fail the planner assertion. Each failure message should name the problem clearly enough that the next person reads it rather than debugging it. Break this This assertion must fail remove the extension creation extension version set shared_buffers in bytes memory size — its whole purpose drop the spatial index planner chooses an index scan each message should be read, not debugged

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.