All modules in this track (11)
In this module, you'll learn to:
- Run the standard change workflow, and explain why shared environments only ever apply a saved plan
- Order a change across base, composition, and environment code so nothing is wired in before it's ready
- Write a module test with
terraform test, and know when a real-infrastructure test with Terratest is worth its cost
Before you start: read Base, Composition & Environment for the layers this workflow moves through.
What you reviewed is what gets applied
The most important habit in this module is small: in any shared environment, terraform apply only ever runs against a saved plan file. A plain terraform apply makes a new plan at that moment, which may not be the plan anyone reviewed. A saved plan is fixed. If the state has changed since it was made, Terraform refuses to apply it and tells you the plan is stale, so a reviewed change can never quietly turn into a different one.
The seven steps
Run from terraform/environments/<env>/:
terraform init
terraform fmt -recursive
terraform validate
terraform plan -var-file="terraform.tfvars" -out=tfplan
terraform show tfplan # read it, again, before applying
terraform apply tfplanIn CI, the same steps run in a pipeline: plan on the pull request, a person approves, then apply uses the plan artifact from that run (Module 06).
Changing infrastructure from the bottom up
When a change needs new code at more than one layer, work up the call tree:
- Base module. Does this need a new reusable resource type, or a change to an existing one? Build or change it first, alone.
- The gate. The new or changed base module passes the promotion gate (Module 11) before anything references it.
- Composition. Add the
moduleblock, updateiam.tf,networking.tf, andlocals.tf, and expose any new outputs. - Environment. Pass the new inputs through
variables.tf,terraform.tfvars, andmain.tf. - Records. Write or update the ADR if this is a decision worth recording, and add a CHANGELOG entry under
[Unreleased](Module 10). - Workflow. Run the seven steps above.
- Docs. Update the module
README.mdfiles whose inputs or outputs changed.
Working bottom-up means every layer you touch is calling something already reviewed. Working top-down, starting from the environment, means wiring in code that doesn't exist yet and reviewing the whole stack at once.
validate is not a test
terraform validate checks syntax and types. It can't tell you that the bucket is encrypted, or that a bad input is rejected. Every base module should have tests that check its behaviour, in a tests/ folder beside it:
modules/base/s3/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
└── tests/
└── s3_basic.tftest.hcl
Native terraform test (Terraform 1.6 and later) runs .tftest.hcl files. Each run block plans or applies the module with the given variables, then checks assert conditions:
# modules/base/s3/tests/s3_basic.tftest.hcl
mock_provider "aws" {} # Terraform 1.7+: no AWS credentials needed
variables {
bucket_name = "test-bucket"
}
run "blocks_public_access" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_acls
error_message = "The bucket must block public ACLs"
}
assert {
condition = aws_s3_bucket_public_access_block.this.restrict_public_buckets
error_message = "The bucket must restrict public bucket policies"
}
}
run "rejects_invalid_bucket_name" {
command = plan
variables {
bucket_name = "Not_A_Valid_Name"
}
expect_failures = [var.bucket_name]
}Run it from the module folder:
terraform test
The second run is the one people forget. It proves the module's validation block (Module 03) actually rejects a bad name. A test suite that only checks the happy path never catches a validation rule that was deleted.
mock_provider returns fake values instead of calling AWS, so tests run in seconds on any CI runner. The trade-off: a mock can't tell you AWS would reject the request. For that, you need real infrastructure.
When to test against real AWS
Some behaviour only exists once a resource is real: does the Glue crawler actually find a table? For those modules, an integration test with Terratest runs terraform apply against a disposable sandbox account, checks the result with the AWS SDK, and always runs terraform destroy in a deferred cleanup step, even when the test fails.
These tests cost money and take minutes, so they don't run on every commit:
| Test | Runs on | Speed | Catches |
|---|---|---|---|
terraform test with mocks | every pull request touching modules/base/** or modules/composition/**, before the security scan | seconds | wrong defaults, missing settings, broken validation |
terraform test with command = apply | a sandbox account, on merge or nightly | minutes | settings AWS rejects |
| Terratest | a separate scheduled job, for heavier modules | minutes to an hour | behaviour that only real resources show |
Before a pull request merges
terraform fmt -recursivewas run, with no remaining diff.terraform validateandterraform testpass.- The plan is reviewed and attached to, or linked from, the pull request.
docs/CHANGELOG.mdis updated.- A new ADR is added if an architectural decision was made.
- Module
README.mdfiles match any changed inputs or outputs. - No secrets or account IDs are hardcoded in
.tffiles; they come from variables or data sources. - The promotion gate is satisfied (Module 11).
Implementation notes
- Keep the plan file out of Git and out of logs. A plan file can contain sensitive values in plain text, the same way state can (Module 04). Store it as a short-lived, access-controlled CI artifact.
- Test what the module promises, not what Terraform does. Asserting that
bucketequalsvar.bucket_nametests Terraform. Asserting that public access is blocked tests your module. - Use
terraform plan -detailed-exitcodein scripts. It returns 2 when there are changes, which lets a pipeline tell "no changes" from "changes to review".
Key terms
- Saved plan: a plan written with
-out;applyon it does exactly what it shows, or refuses if the state has moved on. - Bottom-up change order: base module, gate, composition, environment, records, workflow, docs.
terraform test: Terraform's built-in test runner for.tftest.hclfiles.mock_provider: a stand-in provider that returns fake values, so tests need no cloud credentials.expect_failures: marks a run that should fail, such as a validation rule rejecting bad input.- Terratest: a Go library for testing real infrastructure: apply, check, then always destroy.
Pop quiz
Five questions: three on the ideas in this module, and two scenarios where you apply them. The order changes every time you take it, and 4 out of 5 passes.
5 questions. Decide on your answer to each, then check it.
-
Concept
Why does a shared environment apply only a saved plan file?
Show answer
What gets applied is exactly what was reviewed, and Terraform refuses if the state has changed since
A fresh apply builds a new plan that nobody has seen. A saved plan is fixed, and goes stale rather than changing.
-
Concept
What does
terraform validatecheck?Show answer
Syntax, references, and types in the configuration
It's a static check of the code. Behaviour needs tests, and security needs a scanner.
-
Concept
What does a
runblock withexpect_failures = [var.bucket_name]prove?Show answer
The variable's validation rule rejects the bad input it's given
It passes only if that validation fails. It's how you test the guard rails, not just the happy path.
-
Scenario
You need a new base module for an SQS queue and want the Glue job to send to it. Someone starts by adding the queue resource to
environments/dev/main.tf, planning to tidy it into modules later. What's the right order?Show answer
Build the base module, pass the gate, wire it into the composition with IAM, then add the environment inputs
Bottom-up, so each layer only calls code that's already reviewed and scanned, and the environment stays the same shape as the others.
-
Scenario
Your module tests use
mock_providerand all pass, but the first real apply fails because AWS rejects a setting combination. What does that tell you?Show answer
Mocks can't see AWS's own rules; this module needs an apply-mode test or Terratest in a sandbox account
Mocks are fast because they don't call AWS, and so they can't catch what only AWS checks. Add a slower real-infrastructure test for modules where that matters.