Chapter 12: Testing, Policy & Validation
Topic 79

Contract Testing for Modules

ModulesTesting

A reusable module is a contract. Its input variables and output values are an interface other teams depend on, and a change that breaks that interface breaks every consumer — exactly like changing a function signature in a shared library. Contract testing pins that interface down so the break is caught in your CI, not in a downstream team's failed apply.

Three tools cover it: examples/ directories that double as runnable tests, terraform test for the input/output contract, and Terratest in Go for the heavier integration nobody else can reach. And the single most important contract to test is not an output at all — it is that the module's defaults are safe, because an insecure default spreads exposure to everyone who adopts the module.

The Module Interface as a Contract

The variables — their defaults and validations — and the outputs form a promise to consumers. Renaming an output, removing one, or flipping the behavior of a default is a breaking change exactly like altering a function signature. A team that references module.ingest.topic_id sees its plan break the moment you rename that output to pubsub_topic_id, and they find out at their next apply, not at yours.

That is the case for catching it with a test instead. A contract test asserts the output exists and the validation holds, so the break shows up in the module's own CI when you make it — where you can fix it — rather than as a surprise in a consumer's pipeline a sprint later.

examples/ That Double as Tests

An examples/minimal and an examples/complete directory serve two jobs at once: they document how to call the module, and they are fixtures that terraform test and Terratest run against. If the examples do not plan, the module is broken — so wiring them into the test suite means the documentation cannot quietly rot away from the real module.

The trap they avoid is the copy-paste of a broken snippet. A new consumer reaches for examples/minimal, copies it, and expects it to work. If that example is run under terraform test on every change, a broken example fails CI before it can mislead anyone — the docs are proven, not asserted.

terraform test for the Contract

Plan-mode run blocks assert the module's outputs and computed wiring. The Hatch ingest module exposes the bucket name and the Pub/Sub topic id; a test asserts those outputs exist and that the service account is bound to the right role. Because these are plan-mode and mocked, they run offline and free on every PR — the contract is checked without provisioning anything.

Variable validation rules are themselves contract checks, and the test confirms they fire. A test that passes an out-of-range value and expects the run to fail proves the validation guards the input as documented. The example asserts both an output and a binding the module promises.

A contract test for the ingest module
# tests/contract.tftest.hcl
mock_provider "google" {}

run "exposes_stable_outputs" {
  command = plan

  assert {
    condition     = output.topic_id != ""
    error_message = "topic_id output is part of the contract — do not rename or remove"
  }

  assert {
    condition     = google_pubsub_topic_iam_member.publisher.role == "roles/pubsub.publisher"
    error_message = "ingest SA must keep publisher on the topic"
  }
}

Each assertion names the contract it guards. If a refactor renames topic_id or drops the binding, this run fails in the module's CI with a message that says what broke, long before a consumer feels it.

Terratest for Heavier Integration

Terratest is Go-based: it does a real apply, makes live assertions against the deployed resources, then destroys. You can call the deployed Cloud Run URL and check the response, read the bucket's actual IAM policy, or confirm the BigQuery dataset exists — checks that need a real resource standing in real GCP. Native terraform test cannot reach those, because it asserts on plan or state, not on live behavior.

The cost is a Go toolchain, real provisioned resources, and the time to apply and destroy. So Terratest earns its place only for behavior that genuinely needs a deployed resource. Reaching for it to check an output that a mocked plan run already covers is paying apply time and resource cost for an assertion that never needed GCP.

Testing That Defaults Are Safe

The highest-value contract test asserts the no-input case. A consumer who sets nothing should get a private bucket, CMEK on, public-access-prevention enforced, and no 0.0.0.0/0 ingress. That is one mocked plan run with empty variables and a handful of assertions on the resulting posture — cheap to write, and it guards the property that matters most.

The reason it matters most is blast radius. A module whose defaults are insecure spreads that exposure to every team that adopts it with defaults — which is most of them. An output rename breaks one consumer loudly at their next plan; an unsafe default leaks quietly across everyone, with no error at all. Test the zero-input instantiation and assert it is private, encrypted, and closed.

terraform test vs Terratest

terraform test — HCL, mocked plan-mode runs that assert outputs, wiring, and validations offline and free. Covers the interface contract and safe-defaults check. Choose it for the bulk of contract tests; it runs on every PR.

Terratest — Go, real apply then live assertions then destroy. Reaches behavior native test cannot — a live Cloud Run response, actual bucket IAM. Choose it only when a check genuinely needs a deployed resource, accepting the Go toolchain and real cost.

Common Mistakes
  • Shipping a module whose default leaves the bucket public or CMEK off, so every team that adopts it with defaults inherits the exposure — test the no-input case and assert the safe posture.
  • Renaming or removing an output as "cleanup" without a contract test, silently breaking every consumer that references module.ingest.topic_id on their next plan.
  • Keeping examples/ that no longer plan, so the documentation lies and a new consumer copies a broken snippet — wire the examples into terraform test so a broken example fails CI.
  • Reaching for Terratest and a Go toolchain for a contract that a mocked terraform test plan run already covers — you pay real apply time and resource cost for an assertion that did not need GCP.
  • Treating a variable validation as documentation rather than testing it — without a test that passes a bad value and expects failure, you never confirm the guard actually fires.
  • Versioning the module loosely so a breaking output rename ships as a patch, leaving consumers with no signal that the contract changed under them.
Best Practices
  • Treat inputs and outputs as a versioned contract: add a terraform test assertion for every output and every variable validation consumers rely on.
  • Keep examples/minimal and examples/complete and run them under terraform test so the docs are proven to plan on every change.
  • Write an explicit "safe defaults" test that asserts a zero-input instantiation is private, encrypted, and closed.
  • Use mocked terraform test for the interface contract and reserve Terratest for the few behaviors that genuinely need a real deployed resource.
  • Version the module with semantic versioning so a breaking interface change ships as a major bump and consumers get a clear signal to migrate.
Comparable tools terraform test · Terratest the native and Go contract harnesses terraform-compliance · conftest assert policy on the plan Pulumi property and integration testing in its own ecosystem Config Connector modules tested via the Kubernetes test harness

Knowledge Check

Why are a module's inputs and outputs treated as a contract?

  • Consumers depend on them as an interface, so renaming an output is a breaking change like altering a function signature
  • The registry signs and encrypts each declared input and output, so renaming one invalidates the module's published content checksum
  • Terraform refuses to run apply on any module whose declared outputs have changed since the last release
  • Inputs and outputs become permanently immutable the moment the module is published to a registry

How do examples/ directories double as tests?

  • They are fixtures terraform test runs against, so a broken example fails CI and the docs cannot rot
  • They are statically scanned by tfsec for security issues during CI instead of ever being planned or applied
  • They stand in for and replace the module's own resource blocks while a test run is executing
  • They are purely human-readable documentation and play no role at all in automated testing

When is Terratest worth its cost over native terraform test?

  • When a check needs a real deployed resource — a live Cloud Run response or actual bucket IAM — that plan and state cannot reveal
  • For every single contract test you write, since native terraform test can never assert on a module's declared outputs or resource attributes
  • Whenever the module declares more than three input variables in its variables block
  • Only when you lack GCP credentials, since Terratest is the path that runs fully offline

Why is testing safe defaults the highest-value contract test?

  • An insecure default spreads exposure silently to every team that adopts the module with defaults, unlike a rename that breaks one consumer loudly
  • Variable defaults are the only part of a module that Terraform automatically validates for both correct type and secure configuration before any plan runs
  • A safe-defaults test is the only contract test that can run without any cloud credentials at all
  • Variable defaults silently shift on every provider upgrade, so they demand constant retesting

You got correct