Terratest: asserting check-block failures — InitAndPlanE and plan tests
A precondition that never fails in CI is decoration. Make the failure a test the pipeline must pass.
<!-- hal:authoritative:yaml -->
A precondition that never fails in CI is decoration. Make the failure a test the pipeline must pass.
§I — Frame
07-31 built terraform.Options, deferred destroy, retries, and stages. 08-12 watched behavior during apply with HTTP polling and asserted an empty plan after converge. 08-21 compared Azure SDK properties to terraform.Output. 08-30 copied folders so parallel tests stopped fighting over .terraform.
Those instruments stay on the shelf. Do not reopen them as first teaching.
Ops today places a lifecycle precondition on aws_instance.app: prod must not use t3.micro. Lab 07 already encodes the same rule in terraform test with expect_failures = [terraform_data.deployment]. The Go job is to prove the same failure from terratest, then prove a clean plan under safe variables.
Brikman Chapter 9 Integration Tests gives the skeleton: options, init, apply, assert, destroy. Plan testing names the cheaper cousin: run plan, inspect the result, skip the cloud when the graph itself is wrong. Today's failure case never needs apply. The success case can stop at plan when you only assert "no precondition error."
§II — Language idiom: InitAndPlan and a required error
Terratest wraps the Terraform CLI. terraform.InitAndPlan(t, options) returns plan stdout as a string and fails the test if Terraform exits non-zero. For precondition tests you want the opposite of the usual helper: Terraform must exit non-zero, and the error text must name your rule.
package test
import (
"strings"
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/require"
)
func TestProdMicroPreconditionFailsPlan(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../fixtures/check-blocks-aws",
Vars: map[string]interface{}{
"environment": "prod",
"instance_type": "t3.micro",
"name_prefix": "tfpro",
"vpc_id": "vpc-test",
"subnet_id": "subnet-test",
"ingress_cidrs": []string{"10.0.0.0/16"},
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
},
}
_, err := terraform.InitAndPlanE(t, opts)
require.Error(t, err)
require.True(t,
strings.Contains(err.Error(), "prod must not use t3.micro") ||
strings.Contains(err.Error(), "precondition"),
"expected precondition failure, got: %v", err,
)
}
Use the E-suffixed helper when you need the error object. The non-E helpers call t.Fatal on any non-zero exit. That is correct for happy paths and wrong for intentional failures.
§III — Happy-path plan under safe vars
func TestDevMicroPlanSucceeds(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../fixtures/check-blocks-aws",
Vars: map[string]interface{}{
"environment": "dev",
"instance_type": "t3.micro",
"name_prefix": "tfpro",
"vpc_id": "vpc-test",
"subnet_id": "subnet-test",
"ingress_cidrs": []string{"10.0.0.0/16"},
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
},
}
plan := terraform.InitAndPlan(t, opts)
require.NotEmpty(t, plan)
}
This test proves the module still plans when the forbidden combination is absent. It does not prove AWS will accept the AMI filter in every account. Keep cloud-live apply tests behind a build tag or a stage flag if credentials are optional on the PR runner.
§IV — Mapping Lab 07 expect_failures to Go
Lab 07's basic.tftest.hcl run prod_requires_safer_instance_type sets prod + t3.micro and lists expect_failures = [terraform_data.deployment]. Native terraform test understands which address should fail. Terratest sees a process exit and a stderr string.
Translate deliberately:
- Same variable matrix as the HCL test.
- Assert on error text that matches
error_messagefrom Ops. - Add a second test for the passing matrix.
- Keep fixture directory copied or isolated if you later add parallel applies (08-30). For plan-only tests, a shared fixture is usually enough.
Do not re-teach CopyTerraformFolderToTemp here. Call it only if a later apply stage appears.
§V — Check warnings versus plan failure
Advisory check blocks may print warnings while exiting zero. InitAndPlanE then returns err == nil. If you need to assert a check warning from Go, parse plan JSON (today's Go companion) or run terraform console style inspections. Terratest's default plan helpers do not treat check warnings as failures.
That split matches Ops doctrine: preconditions fail the run; checks warn. Write separate tests for each layer. Do not force a check to become a precondition just to make require.Error turn green.
§V.B — Capturing plan JSON from terratest for deeper asserts
Sometimes you need structure, not a substring. Terratest can run plan with an out file and show JSON:
func TestPlanJSONHasNoReplaceWhenDev(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: "../fixtures/check-blocks-aws",
Vars: map[string]interface{}{
"environment": "dev",
"instance_type": "t3.micro",
"name_prefix": "tfprodev",
"vpc_id": "vpc-test",
"subnet_id": "subnet-test",
"ingress_cidrs": []string{"10.0.0.0/16"},
},
}
planFile := "tfplan.out"
terraform.Init(t, opts)
terraform.RunTerraformCommand(t, opts, "plan", "-out="+planFile)
// Prefer Show with -json in a small helper, or shell out once:
jsonPlan := terraform.RunTerraformCommand(t, opts, "show", "-json", planFile)
require.Contains(t, jsonPlan, `"format_version"`)
}
Hand the JSON string to today's Go companion helpers (hashicorp/terraform-json) when you need typed ResourceChanges or check results. Keep terratest responsible for process exit and fixture lifecycle. Keep terraform-json responsible for schema. Crossing those wires produces brittle tests that parse stderr with regex forever.
§V.C — Parallelism and AWS describe calls during plan
t.Parallel() is fine for plan-only tests that hit distinct variable matrices. AMI and subnet data sources still call AWS. Rate limits appear when twenty packages plan at once against the same account.
Mitigations that stay small:
- Stub data sources behind
var.use_stubsfor PR jobs. - Cap parallelism with a package-level semaphore.
- Move live-describe plans to a nightly workflow.
08-30 taught folder copy for parallel applies. Plan-only tests usually skip the copy. If you add InitAndApply later for a postcondition that needs a real instance, copy first, destroy always, and tag resources with the test name.
§V.D — Retry policy for intentional failures
Brikman and terratest both discuss retries for eventual consistency on apply. Do not wrap InitAndPlanE precondition failures in retry helpers. A precondition is deterministic for a given variable set. Retrying a deliberate error hides flakes and wastes minutes.
Use retries only around describe-after-apply assertions on the happy path. Keep failure tests strict and fast.
§VI.B — CI sketch
# fragment — terratest plan gates
- name: terratest precondition gates
run: |
cd Polyglot-Dev/Go/check-blocks
go test ./test -count=1 -timeout 20m -run 'TestProdMicro|TestDevMicro'
- name: terraform native tests
run: |
cd fixtures/check-blocks-aws
terraform init
terraform test
Native terraform test and terratest should agree on the prod-micro matrix. When they disagree, believe Terraform CLI output first, then fix the Go assertion or the fixture drift.
§VI — Minimal fixture layout
fixtures/check-blocks-aws/
main.tf # Ops module under test
variables.tf
versions.tf # AWS provider + terraform version
test/
precondition_plan_test.go
Point TerraformDir at the fixture. Prefer relative paths from test/. Pin provider versions so CI does not float into a breaking AWS provider release mid-week.
For AMI data sources during plan, Terraform still talks to AWS. Use a runner role that can ec2:DescribeImages and ec2:DescribeSubnets, or inject a test double module that replaces data sources with variables when var.use_stubs = true. Stubs keep PR checks offline. Live describes keep nightly honesty.
§VII — What not to reopen
| Prior day | Spent instrument | Today |
|---|---|---|
| 07-31 | Options, retries, stages, destroy | reuse Options quietly |
| 08-12 | HTTP poll, empty-plan idempotence | plan failure, not HTTP |
| 08-21 | Azure GetProperties vs Output | no cloud SDK assert |
| 08-30 | CopyTerraformFolderToTemp | plan-only shared fixture |
The new claim is narrow: assert the precondition failure string from InitAndPlanE. Everything else is glue.
§VIII — Closing
Commit two tests. One must fail Terraform and pass Go. One must pass both. Wire them into CI next to terraform test for Lab 07 so HCL and Go agree on the same matrix.
When Ops changes the error_message, update the substring assertion the same day. Drift between HCL text and Go text is a silent false green.
§VIII.B — Assertion library choices
require from testify fails fast. assert continues. For precondition tests, prefer require.Error and require.True so a missing error does not keep executing plan cleanup logic that assumes failure.
Avoid giant regexes over entire stderr. Prefer strings.Contains on the stable error_message Ops owns. If Terraform wraps the message, contain on a unique fragment (t3.micro plus prod) rather than the full sentence.
When multiple preconditions can fire, write one test per failing matrix. Combined failures make stderr order nondeterministic across Terraform versions.
§VIII.C — Provider auth in plan-only CI
Plan with AWS data sources needs credentials even when no resource is created. Options:
- OIDC to a read-only role with
ec2:Describe*. - Stub module inputs that skip data sources.
- Recorded HTTP is out of scope for terratest; do not invent a VCR layer in this lesson.
Document the choice in the fixture README. PR templates should say whether the job is stub or live.
§VIII.D — Relating to the Go companion
The Go companion lesson builds a small library over hashicorp/terraform-json that prints check results and resource change counts. Terratest can call that library from a test once plan JSON exists. Keep the import direction clean: tests depend on the library; the library must not import terratest.
That split lets a non-test binary (PR bot) reuse the same JSON logic without pulling testing modules into production containers.
Related
- Paired Ops: Archmagus-Stack/01-Earth-DevOps/Synthesis-Lessons/2026-09-08-terraform-check-blocks-preconditions-postconditions-on-aws-resources/lesson.md
- Paired Go (JSON): Archmagus-Stack/Polyglot-Dev/Go/2026-09-08-go-terraform-json-parsing-plan-and-check-results/lesson.md
- Prior terratest (do not rehash): Archmagus-Stack/Polyglot-Dev/Go/2026-08-30-terratest-copy-folder-gcp-and-the-access-that-is-not-a-public-ip/lesson.md
- Lab 07 tests: Archmagus-Stack/Sovereign-Bootcamp/tfpro-labs/labs/07-validation-checks-tests-broken/tests/basic.tftest.hcl
- Syllabus ledger: Archmagus-Stack/Polyglot-Dev/_syllabus-ledger.md