Skip to content
advanced Phase 14 · Testing Terraform: Native Tests and Terratest

Plan Assertions and Expectations

Asserting resource counts, attribute values, and expected failures in tests.

1h
0 problems
Topic Progress 0%

Asserting the Plan: Proving the Shape of Infrastructure That Does Not Exist Yet

You review a pull request that flips production from t3.micro to whatever the dev default is. A human reviewer might miss it; a plan shows it in passing; an assertion makes it a test failure. Inside a run block, assert is a condition that must hold after the plan (or apply) completes:

# tests/guardrails.tftest.hcl
run "prod_gets_the_prod_instance_type" {
  command = plan

  variables {
    environment   = "prod"
    instance_type = "t3.medium"
  }

  assert {
    condition     = aws_instance.web[0].instance_type == "t3.medium"
    error_message = "prod must not fall back to the dev instance size"
  }
}

command = plan is the default and the right default for most assertions: Terraform builds the full set of planned changes — every resource's after values — and your conditions evaluate against those planned values. Nothing is created, no credentials are needed if the provider is mocked (as in Mocking Providers in Tests), and a failure means the misconfiguration you described was about to be applied. That is the promise: assertions catch misconfiguration before any cloud API call is made.

What each mode can see is the rule worth memorizing:

Command Values the assert reads Needs credentials
plan configuration-derived and mock-pinned values; computed ones are unknown only if the provider is real
apply real post-create attributes and outputs yes

The unknown column is where beginners burn time. Asserting aws_instance.web[0].public_ip == "203.0.113.10" under command = plan fails not because the config is wrong but because the value is (known after apply) — the comparison is unknown, and an unknown condition is a failed condition. Either assert a value that exists at plan time, pin the computed one with an override, or move the assertion into an apply run:

run "bucket_is_readable_after_apply" {
  command = apply

  assert {
    condition     = terraform.workspace != ""
    error_message = "apply run should have completed against a workspace"
  }
}

Assertions reach beyond one resource. Module outputs are fair game — module.network.private_subnets — which is how you test module interfaces: the caller-visible contract, not the internals. Data structures compose too, so a plan-time test can prove a for expression produced the intended map:

assert {
  condition     = length(keys(module.sites)) == 2
  error_message = "exactly two site modules must be planned"
}

That last assertion encodes a count-of-instances invariant — cheap to write, and exactly the thing a reviewer forgets to check when someone edits the sites map. In interviews, expect to be asked to "write a test that ensures X never regresses" where X is a property of the planned resource graph: an encryption flag, a naming prefix, a required tag, an instance count. The run-block assert is the answer, and the discipline is asserting properties of the plan, not values of the cloud — anything the cloud decides belongs one layer down, in the apply-mode and Terratest suites you meet in Terratest Harness in Go.

A run block accepts any number of assert entries, and all of them must pass, so one scenario can encode several invariants at once:

assert {
  condition     = aws_instance.web[0].ebs_optimized
  error_message = "web instances must be ebs_optimized"
}

assert {
  condition     = alltrue([for i in aws_instance.web : contains(i.tags, "ManagedBy")])
  error_message = "every instance must carry a ManagedBy tag"
}

The second assertion is the pattern to remember for collections: iterate the resource list inside the condition rather than writing one assert per instance. for_each resources use the same trick through values(...). And because a failed assert prints its error_message verbatim in the terraform test output, specific messages beat "assertion failed" every time — name the invariant in the message, because the person reading the red CI log at 2 a.m. is you.

Testing the Guards: expect_failures and the Discipline of Narrow Lists

A guardrail no one has watched fire is a guardrail you do not have. If your module declares that prod buckets must have force_destroy = false, the test suite should prove the opposite input is actually rejected — otherwise a typo silently deletes the validation and every green run keeps saying nothing.

Negative testing is what expect_failures does:

run "unknown_environment_is_rejected" {
  command = plan

  variables {
    environment = "qa"
  }

  expect_failures = [
    var.environment,
  ]
}

The run passes only if the plan raises an error on one of the listed objects — here, the variable's validation block rejecting "qa". The failure list is not limited to variables; it takes resources, data sources, outputs, and custom conditions, so the same mechanism tests every kind of guard:

run "prevent_destroy_blocks_the_bucket" {
  command = plan

  expect_failures = [
    aws_s3_bucket.logs,
  ]
}

run "missing_required_tag_fails" {
  command = plan

  variables {
    tags = {} # CostCenter tag intentionally omitted
  }

  expect_failures = [
    terraform_data.cost_center_check,
  ]
}

Now the discipline that separates a careful engineer from someone cargo-culting the syntax: the list must be narrow. expect_failures does not swallow all errors for the run — an error on any object outside the list still fails the test. That is the property to exploit. Declare exactly the object you expect to reject, and the run doubles as proof that nothing else broke while you were feeding it bad input. Widen the list to every resource "just in case" and you have built a test that passes when the module fails for the wrong reason entirely — the most dangerous test shape there is.

A few semantics worth knowing before the follow-up questions start:

  • The listed object must actually raise during that run. If the validation never fires, the run fails with "expected failure, but no error was reported" — your test of the guard now proves the guard exists.
  • One expect_failures entry suffices even when the guard would fire multiple times; the check is about which object errored, not how often.
  • Guard tests still use command = plan by default. Rejections you can trigger during planning — validation blocks, precondition blocks on resources and data sources, prevent_destroy — are the cheap ones; anything that only fails at create time belongs to the apply layer.

A complete guardrail file pairs both directions, which is how a reviewer reads intent straight off the test names:

run "valid_environment_plans_cleanly" {
  command   = plan
  variables = { environment = "prod" }
}

run "unknown_environment_is_rejected" {
  command        = plan
  variables      = { environment = "qa" }
  expect_failures = [var.environment]
}

The first run proves the happy path still plans; the second proves the guard still fires. Delete either and the suite lies in one direction. Interviewers who know Terraform test well often ask exactly this: "how do you test that a validation works?" — answer with expect_failures = [var.name], then immediately add the narrow-list rationale.

expect_failures extends to Terraform's other guard mechanisms without changing shape. Resource precondition blocks — checked during plan — are listed by resource address; data source precondition failures, by data source address; and custom check blocks are the one exception, since checks never fail a plan by themselves and therefore cannot be expected. The uniformity is the point: whatever guard you write, its negative test is the same five lines — bad input, plan, narrow list — and a guard without those five lines is untested code wearing a security costume.

Whole-Repo Plan Checks: terraform show -json with jq and Conftest

Run-block asserts see one test's scenario. Some properties belong to the entire plan as CI will run it: no destroys in this workspace, no security group opens 0.0.0.0/0, resource count within budget. For those, turn the plan into data and interrogate it.

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json

The -out flag saves the exact plan; terraform show -json renders it as a document whose resource_changes array lists every address, its type, and its change.actions. Now jq becomes a policy language:

# fail if the plan deletes anything
terraform show -json tfplan.binary \
  | jq -e '[.resource_changes[]
            | select(.change.actions | index("delete"))] | length == 0'

# fail if any security group ingress is open to the world
terraform show -json tfplan.binary \
  | jq -e '[.resource_changes[]
            | select(.type == "aws_security_group")
            | .change.after.ingress[]?
            | select(.cidr_blocks[]? == "0.0.0.0/0")] | length == 0'

# summarize the blast radius for the PR comment
terraform show -json tfplan.binary \
  | jq '[.resource_changes[] | .change.actions[0]] | group_by(.)
         | map({(.[0]): length}) | add'

-e is what turns a query into a gate: jq exits non-zero when the output is false or null, so the pipeline step fails and the merge is blocked. No cloud credentials in this stage beyond whatever plan already needed, and the plan JSON artifact doubles as the review artifact posted to the pull request.

When the rule set grows past a handful of jq one-liners, Conftest wraps the same document in OPA's Rego:

package terraform.security

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_security_group"
  ingress := rc.change.after.ingress[_]
  ingress.cidr_blocks[_] == "0.0.0.0/0"
  msg := sprintf("%s opens ingress to 0.0.0.0/0", [rc.address])
}
conftest test plan.json -p policy/

Why bother when assert blocks exist? Three reasons that show up in real pipelines. Scope: the plan JSON is the whole configuration as CI sees it, including every module call, while a run block tests one scenario you wrote. Portability: policies run against any tool that emits plan JSON — Terraform, OpenTofu, Atlantis, a comment bot — with no .tftest.hcl machinery. Separation: platform teams own the Rego directory while product teams own tests, so security rules get enforced centrally without editing every module's test file. If your roadmap has you at OPA and Conftest Policy Gates, the two topics are the same mechanism pointed at the same artifact; here the emphasis is the testing workflow — plan saved, JSON rendered, gate executed, artifact retained.

One operational caveat: plan JSON only describes what the plan contains. Resources read outside Terraform's graph — data sources fetched during plan, provider-level changes — appear as reads, not diffs, so the gate sees the proposed changes and nothing more. Combine the layers and nothing important is left to reviewer attention alone: mocked plan tests for wiring, expect_failures for guards, jq and Rego for whole-plan policy — and the real cloud waiting below in the apply suites.

Two jq facts prevent most first-hour confusion. First, use .change.after for attributes as they will be after apply, and .change.before for the current state — a policy checking an encryption flag almost always wants after. Second, planned-but-unknown values live in a separate .change.after_unknown map with true markers, so an attribute that will exist but whose value is unknowable at plan appears absent from after rather than null. A policy testing after.acl == null therefore fires on a resource whose ACL was never set and on one whose ACL is merely unknown — check after_unknown before concluding anything. Finally, terraform plan -detailed-exitcode on the plan step itself returns 0 for no changes, 2 for pending changes, and 1 for error, letting the pipeline skip the JSON gate entirely on empty plans.

Quiz

1. A run block uses `command = plan` and asserts on `aws_instance.web[0].public_ip`. The assertion fails with the value shown as unknown. What is the correct fix?

Question 1 options

2. What does a run block with `expect_failures = [var.environment]` do when the plan raises no error at all?

Question 2 options

3. A test lists every resource in `expect_failures` 'to be safe'. What is the specific danger?

Question 3 options

4. In CI you need to fail the build when a saved plan contains any security group ingress from 0.0.0.0/0. What is the correct pipeline step?

Question 4 options

Flashcards

Question

What values does an assert block read under command = plan versus command = apply?

Answer

Under plan, assertions evaluate against planned values — configuration-derived and mock-pinned values are available; cloud-computed attributes are (known after apply) and any comparison on them fails as unknown. Under apply, assertions read real post-apply attributes and outputs, at the cost of credentials and created infrastructure.

Question

How does expect_failures change a run's outcome, and what happens when the expected error never occurs?

Answer

The run passes only if Terraform reports an error on one of the listed objects; errors on unlisted objects still fail the run. If no error is reported at all, the run fails with a message that the expected failure did not happen — the test proves the guard still fires.

Question

Why should expect_failures lists stay narrow?

Answer

Because only listed objects' errors are tolerated. A broad list silently accepts unrelated failures — a broken reference or a second broken validation — so the suite passes while the module is broken. List exactly the object whose rejection the test is proving.

Question

How do you turn a Terraform plan into machine-checkable data in CI?

Answer

terraform plan -out=tfplan.binary saves the plan, then terraform show -json tfplan.binary renders it as JSON with a resource_changes array. jq -e filters turn queries into pass/fail gates, and Conftest runs Rego policies against the same document.

Question

When are whole-plan JSON checks better than run-block asserts?

Answer

When the property spans the entire plan as CI runs it — no destroys anywhere, no 0.0.0.0/0 ingress, cost or count budgets — or when policies must be owned centrally and run by any tool that emits plan JSON. Run-block asserts test one scenario; plan JSON gates the change set itself.

Revision Notes

Key Takeaways

  • 1. assert blocks evaluate conditions against planned values, so plan-mode tests catch misconfiguration before any cloud API call
  • 2. Computed attributes are (known after apply) at plan time — assert config-derived values, pin with overrides, or move the assertion to an apply run
  • 3. expect_failures is two-sided: an error on a listed object passes the run, no error fails it, and unlisted errors always fail
  • 4. Narrow expect_failures lists keep the test honest — broad lists let the suite pass for the wrong reason
  • 5. terraform show -json turns a saved plan into CI-gateable data: jq -e for one-line checks, Conftest/Rego for a shared policy directory

Interview Tips

  • • "Write a test that proves prod never uses t3.micro" — a run block with command = plan and an assert on the planned instance_type; then say why plan mode is enough (the value is known at plan)
  • • "How do you test that a validation rule works?" — expect_failures = [var.name] on the rejecting input; add the narrow-list rationale to show you have seen the failure mode of the broad one
  • • "Plan or apply for assertions?" — plan by default: free, mocked, pre-flight; apply only when the property literally exists after create (ARNs, IPs, DNS)
  • • "How do you gate a pipeline on the whole plan?" — save with -out, render with show -json, filter with jq -e or Rego via Conftest; mention the artifact doubles as the PR review comment

Cheat Sheet

Plan Assertions and Expectations

run "prod_instance_type" {
  command   = plan
  variables = { environment = "prod", instance_type = "t3.medium" }
  assert {
    condition     = aws_instance.web[0].instance_type == "t3.medium"
    error_message = "prod must not use the dev instance size"
  }
}

run "guard_fires" {
  command         = plan
  variables       = { environment = "qa" }
  expect_failures = [var.environment] # narrow: only listed errors tolerated
}

Mode table:

Command Assert sees Cost
plan (default) planned values; computed = unknown free with mocks
apply real post-create attributes + outputs credentials + infra

Plan JSON gate:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json
jq -e '[.resource_changes[] | select(.change.actions | index("delete"))] | length == 0' plan.json
conftest test plan.json -p policy/   # Rego over the same document

expect_failures contract: error on a listed object → run passes; error elsewhere → run fails; no error → run fails (guard regressed).

Continue Learning