Terraform Cheatsheet

Testing and Validation

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The Validation Ladder

Terraform gives you five layers, cheapest first. Use them in this order, because each one catches a class of bug the next one cannot.

LayerRuns atFails whatCost
terraform fmt -checkany time, no initformattingfree
terraform validateafter init, no credentialssyntax, types, missing args, backend block (1.15+)free
variable validationplanbad inputsfree
precondition / postconditionplan and applybroken assumptionsfree
check block assertafter applylive health, as a warningfree
terraform teston demandmodule behaviourplan free, apply costs money

Variable Validation

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "cidr_block" {
  type = string
  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be valid CIDR notation."
  }
  validation {
    condition     = tonumber(split("/", var.cidr_block)[1]) <= 20
    error_message = "cidr_block must be /20 or larger."
  }
}

Multiple validation blocks are allowed and all of them are evaluated, so you get every failure in one plan rather than one at a time.

Cross-variable validation (1.9+). Conditions used to see only their own variable. Since 1.9 they are ordinary expressions and may reference other variables, locals and data sources.

variable "min_size" { type = number }
variable "max_size" {
  type = number
  validation {
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be greater than or equal to min_size."
  }
}

error_message must be a single-sentence string. Terraform prints it verbatim, so write it for the person who set the variable wrong.

Preconditions, Postconditions and check Blocks

precondition and postcondition live inside lifecycle (or directly in an output) and fail the operation. check blocks (1.5+) are standalone and only warn.

resource "aws_instance" "web" {
  ami = var.ami_id

  lifecycle {
    precondition {
      condition     = data.aws_ami.selected.architecture == "arm64"
      error_message = "This instance type requires an arm64 AMI."
    }
    postcondition {
      condition     = self.private_ip != ""
      error_message = "Instance came up without a private IP."
    }
  }
}

check "api_reachable" {
  data "http" "healthz" {
    url = "https://${aws_lb.api.dns_name}/healthz"
  }

  assert {
    condition     = data.http.healthz.status_code == 200
    error_message = "API health check did not return 200."
  }
}
preconditionpostconditioncheck
Wherelifecycle or outputlifecycle or outputtop level
Whenbefore create or updateafter the object existsend of plan and apply
On failureoperation errorsoperation errorswarning, exit code stays 0
self availablenoyesno
Own scoped data blocknonoyes, exactly one

Use check for continuous verification of things outside Terraform's control, such as an endpoint responding. Use conditions when a failure genuinely should stop the apply.

terraform test and .tftest.hcl (1.6+)

Test files end in .tftest.hcl (or .tftest.json) and live in tests/ by convention. Terraform runs them from the root configuration directory, not from tests/.

A file holds at most one test block, zero or more provider blocks, an optional file-level variables block, and one or more run blocks executed in order.

# tests/naming.tftest.hcl

variables {
  bucket_prefix = "acme-test"
  environment   = "dev"
}

provider "aws" {
  region = "us-east-1"
}

# Unit test: no infrastructure created
run "name_is_prefixed" {
  command = plan

  assert {
    condition     = aws_s3_bucket.app.bucket == "acme-test-app"
    error_message = "bucket name was ${aws_s3_bucket.app.bucket}"
  }
}

# Reject a bad input without creating anything
run "rejects_bad_environment" {
  command = plan

  variables {
    environment = "banana"      # overrides the file-level value
  }

  expect_failures = [
    var.environment,
  ]
}

# Integration test: creates and then destroys real resources
run "bucket_really_exists" {
  command = apply

  assert {
    condition     = output.bucket_arn != ""
    error_message = "module produced no bucket ARN"
  }
}

run block reference

FieldValuesDefault
commandplan or applyapply
plan_options.modenormal or refresh-onlynormal
plan_options.refreshbooltrue
plan_options.replace / .targetlist of addresses—
variablesblock, overrides file-level values—
moduleblock, source and version onlyconfig under test
providersmap, remaps provider configsinherited
assertzero or more blocks—
expect_failureslist of addresses expected to fail—
state_keywhich internal state file to useper module
parallelbool, run alongside other parallel runsfalse

Assertions can reference anything the configuration can, plus output.<name> from the current run and from earlier runs, which is how you chain a setup run into a verification run.

Setup and verification modules

The module block inside a run points that run at a different configuration. Only source and version are allowed, and only local and registry sources work.

run "setup" {
  module {
    source = "./testing/setup"    # create prerequisites
  }
}

run "main" {
  # empty: exercises the configuration under test
}

run "verify" {
  module {
    source = "./testing/loader"   # data sources that read it back
  }
  assert {
    condition     = length(data.aws_s3_objects.objects.keys) == 2
    error_message = "wrong number of objects created"
  }
}

Provider mocking (1.7+) lets command = plan runs work against fake provider data, which is how you unit-test a module with no credentials at all.

Running tests

terraform test                                  # every *.tftest.hcl
terraform test -filter=tests/naming.tftest.hcl  # one file
terraform test -verbose                         # print each plan or state
terraform test -json                            # machine-readable
terraform test -junit-xml=report.xml            # CI test report (GA in 1.11)
terraform test -test-directory=integration      # default is tests
terraform test -parallelism=4                   # default 10

command = apply runs create real, billable infrastructure. Terraform destroys it afterwards and reports anything it could not clean up. Point tests at a disposable account you can safely purge, and prefer command = plan for logic.

plan -detailed-exitcode, the CI Primitive

Plain terraform plan exits 0 whether or not there are changes, which is useless for gating. -detailed-exitcode fixes that.

Exit codeMeaning
0succeeded, empty diff, nothing to do
1error
2succeeded, non-empty diff, changes are pending
set +e
terraform plan -input=false -detailed-exitcode -out=plan.tfplan
code=$?
set -e

case $code in
  0) echo "no changes, skipping apply" ;;
  2) echo "changes pending, awaiting approval"
     terraform show -no-color plan.tfplan > plan.txt ;;
  *) echo "plan failed"; exit 1 ;;
esac

Two habits that go with it: always -out the plan and apply that file so the applied change is the reviewed change, and read the plan with terraform show -json plan.tfplan when a policy tool needs to inspect it.

Linters, Policy and Scanners

ToolWhat it checks
tflintprovider-aware lint: invalid instance types, deprecated syntax, naming rules
checkovsecurity and compliance misconfiguration across many IaC formats
trivy configsuccessor to tfsec, scans HCL for insecure settings
terraform-complianceBDD-style ("given/when/then") policy over a plan file
OPA with conftestRego policy over terraform show -json output
SentinelHashiCorp's policy-as-code, enforced by HCP Terraform
infracostcost delta of a plan, catches an accidental expensive instance
tflint --recursive
checkov -d . --framework terraform
trivy config .
terraform show -json plan.tfplan > plan.json && conftest test plan.json

terratest, the Heavy Option

Go-based integration testing. Reach for it only when you need real HTTP calls, retries, SSH or Kubernetes clients that .tftest.hcl cannot express.

func TestS3Module(t *testing.T) {
    opts := &terraform.Options{
        TerraformDir: "../examples/basic",
        Vars: map[string]interface{}{"bucket_prefix": "tt-acme"},
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    arn := terraform.Output(t, opts, "bucket_arn")
    assert.Contains(t, arn, "arn:aws:s3:::")
}

It is slower, needs a Go toolchain, and creates real resources. Start with terraform test.

A CI Pipeline That Holds

#!/usr/bin/env bash
set -euo pipefail

terraform fmt -check -recursive            # 1. no credentials needed
terraform init -input=false -backend=false
terraform validate                         # 2. still no credentials
tflint --recursive                         # 3. lint
terraform test                             # 4. plan-only tests

terraform init -input=false                # 5. real backend
set +e
terraform plan -input=false -detailed-exitcode -out=plan.tfplan
code=$?
set -e
[ "$code" -eq 1 ] && exit 1

terraform show -json plan.tfplan > plan.json
conftest test plan.json                    # 6. policy gate

if [ "$code" -eq 2 ] && [ "${APPROVED:-}" = "true" ]; then
  terraform apply -input=false plan.tfplan # 7. the reviewed plan only
fi

Steps 1 to 4 need no cloud credentials, so they run on every pull request. Steps 5 and 6 need read access. Only step 7 needs write access, so scope the CI role accordingly.