Contract Testing for Modules
When teams share a module, its interface is a contract. The inputs it accepts, the outputs it returns, and the behavior it promises are what every consumer writes their code against — and a change to any of them ripples to every stack that calls the module. Rename an output and a dozen downstream configurations break on their next plan. Contract testing verifies that a module honors its promised interface across versions, so consumers can upgrade with confidence instead of dread.
This is where the module chapter and the testing chapter meet. A shared module is a published API, and the same discipline that keeps a library's API trustworthy applies here: the interface is the thing under test, not the internals. A consumer does not care which resources the module creates; they care that the outputs they depend on still exist, still mean the same thing, and still appear under the same names.
The Module as a Contract
The contract has three parts: the input variables a consumer sets, the outputs they read, and the documented behavior between them. A consumer of a VPC module passes a CIDR block and a list of availability zones, and reads back a VPC ID and a list of subnet IDs. Those names and types are the promise. The module is free to change how it builds the VPC internally, but the moment an output disappears or an input becomes required, the promise is broken and someone's stack stops planning.
Testing the Interface
The native terraform test framework from the previous topic is how you assert the contract. A test sets the inputs a consumer would set and asserts that the promised outputs exist and carry the expected shape and values. The point is not to test that AWS works — it is to test that the module's surface behaves as documented for the input combinations consumers actually use.
run "exposes_vpc_and_subnet_outputs" { command = plan variables { cidr_block = "10.0.0.0/16" availability_zones = ["us-east-1a", "us-east-1b"] } # the contract: these outputs must exist and match the inputs assert { condition = length(output.subnet_ids) == 2 error_message = "module must return one subnet id per availability zone" } }
This run pins the promise: given two availability zones, the module returns two subnet IDs through its subnet_ids output. If a refactor drops that output or changes its cardinality, the test fails before the change is published, and the breakage is caught by the author rather than discovered by a consumer in production.
Versioning and Backward Compatibility
Interface changes are SemVer-significant. Removing an output, renaming one, or making a previously optional input required is a breaking change and demands a major version bump — consumers pinned to ~> 2.0 must not silently receive it. Adding a new optional input or a new output is backward-compatible and belongs in a minor version. Contract tests are what enforce this distinction mechanically: a minor bump that breaks the interface fails its own tests, so the discipline is not left to the author's memory.
Example-Driven Tests
A module's examples/ directory should hold runnable configurations that call the module the way real consumers do — the minimal example, the example with every option set, the common production shape. These examples double as documentation and as integration tests: a test run that plans each example proves the module still works for the usage patterns you ship. The trap is letting examples/ rot until it no longer reflects real usage; a stale example is worse than none, because it documents a contract the module no longer honors.
Catching Breaking Changes
The payoff is a test suite that fails loudly the moment the interface breaks — an output removed or renamed, a required input added, a type changed. Run it in CI on every change before publishing a new version, and a breaking change cannot ship under a non-breaking version number without someone seeing red. That is the whole point of contract testing: move the discovery of a breaking change from the consumer's production plan, weeks later, to the author's pull request, before it is ever tagged.
- Renaming a module output or making a new input required in a minor version, silently breaking every consumer pinned to that minor range on their next plan.
- Shipping a shared module with no interface tests, so breaking changes are discovered by consumers in production instead of by the author in a pull request.
- Testing only the happy-path input combination and missing the ones consumers actually use, so the test passes while real usage breaks.
- Letting the
examples/directory rot until it no longer reflects real usage, documenting a contract the module no longer honors. - Treating a module's internal resources as part of the contract, so consumers reference internals and every refactor becomes a breaking change.
- Test a shared module's interface — its promised outputs and behavior — across the input combinations consumers actually use, not just the happy path.
- Treat any interface change as SemVer-significant and let contract tests catch a breaking change shipped under a non-breaking version number.
- Keep the
examples/directory runnable and use it as both documentation and an integration test on every change. - Run the module's tests in CI on every change before publishing a new version, so a break never reaches the registry.
- Keep the module's public surface minimal — expose only the outputs consumers need, so internals stay free to change without breaking anyone.
Knowledge Check
What does contract testing for a shared module actually verify?
- That the module's promised inputs, outputs, and behavior hold across versions, so consumers upgrade safely
- That the AWS APIs the module calls are themselves up and functioning correctly in every region
- That the module's internal resources are created in the most efficient order for the fastest possible apply run
- That the module's HCL is correctly formatted and passes
terraform validatewith no errors
A maintainer renames an output from vpc_id to id. What version bump does this require?
- A major version — renaming an output is a breaking change to the contract that consumers depend on
- A patch version, because the rename touches no real provisioned infrastructure and only edits an output label
- A minor version, because adding the new
idname is fully backward-compatible - No bump at all, since output names are an internal detail of the module
Why keep a runnable examples/ directory in a shared module?
- It serves as both living documentation and an integration test that proves the module works for real usage
- Terraform requires a runnable examples directory before a module can be published to any public registry at all
- It replaces the need for any input-variable validation blocks inside the module
- It stores the module's state so consumers do not each need a state file of their own
What is the core benefit of running contract tests in CI before publishing?
- A breaking interface change is caught in the author's pull request, not by a consumer in production weeks later
- It guarantees the module will never again need a major version bump for any future interface change of any kind
- It removes the need for consumers to pin the module version in their constraints
- It lets the module skip the plan and apply steps when consumers use it
You got correct