Guides/TerraformTerraform/Validating and Testing Terraform Code

Validating and Testing Terraform Code

Build confidence in Terraform the cheap-first way: fmt, validate, plan review, variable validation, linting, security scanning, policy as code, native tests, Terratest, and drift detection.


Testing infrastructure code is different from testing an application. You cannot just "run it" locally and poke at the result, because running it means creating real cloud resources that cost money and take minutes to converge. So the winning strategy is not one big test suite - it is a ladder of checks ordered from cheapest and fastest to most expensive and slowest, where each rung catches a different class of mistake before it reaches the rung above. Formatting catches nothing but noise; a plan review catches most real disasters; a full integration test catches the rest but costs real infrastructure. This guide walks that ladder from the bottom up, so you spend the cheap checks liberally and the expensive ones deliberately.

The layers of confidence, cheapest first

Think of it as a funnel. Every commit should pass the cheap checks; only some changes need the expensive ones. A useful ordering, from milliseconds to minutes:

  1. terraform fmt - is the code formatted consistently? (free, instant)
  2. terraform validate - is the configuration syntactically valid and internally consistent? (free, no network)
  3. Linting (tflint) - does it follow provider-specific best practices and catch dead code or invalid values? (fast, no apply)
  4. Security scanning (tfsec / checkov / trivy) - does it configure resources insecurely? (fast, static)
  5. Policy as code (OPA / Sentinel) - does the plan comply with organizational rules? (needs a plan)
  6. Plan review - does the intended change match what a human expects? (needs credentials, no apply)
  7. Native terraform test - does the module behave correctly, with plan-only or real applies? (fast to slow)
  8. Integration tests (Terratest) - does the real deployed infrastructure actually work? (slow, real resources)
  9. Drift detection - has reality diverged from the code since the last apply? (scheduled, ongoing)

The point of the ordering is economy. A malformed variable should be caught by validate in a second, not by a failed apply twenty minutes into a pipeline. Push failures as far down (cheap) as they will go.

terraform fmt and terraform validate

These are the two checks you run constantly, because they cost nothing and never touch a cloud provider.

terraform fmt rewrites your .tf files to the canonical style - consistent indentation, aligned = signs, tidy spacing. It is not testing correctness; it is removing formatting from code review so humans argue about substance, not whitespace.

terraform fmt              # rewrite files in place
terraform fmt -recursive   # include subdirectories
terraform fmt -check       # exit non-zero if anything is unformatted (for CI)
terraform fmt -diff        # show what would change

In CI you run terraform fmt -check -recursive. It makes no changes; it just fails the build if someone committed unformatted code.

terraform validate checks that the configuration is syntactically valid and internally consistent - references resolve, argument types match, required arguments are present, no duplicate resource names. It runs against the code alone: no credentials, no state, no network calls. Because it needs the provider schemas, run terraform init -backend=false first so init does not try to reach your real backend.

terraform init -backend=false   # install providers, skip the backend
terraform validate              # check syntax and internal consistency
terraform validate -json        # machine-readable output for tooling

What validate does NOT do is check whether the plan is a good idea, whether the resources will actually create, or whether values are sensible. It catches "you referenced var.regoin which does not exist," not "you are about to delete the production database." That is the plan's job.

The plan is the primary safety gate

If you take one thing from this guide: reviewing the plan is the single most valuable check you have. terraform plan computes the exact diff between your desired state (the code) and the real world (the state plus a refresh of live resources), and shows you every create, update, and destroy before anything happens. Most infrastructure disasters - a dropped database, a recreated load balancer, a security group opened to the world - are visible right there in the plan output. The discipline is simple: read the plan, every time, and never approve an apply you have not understood.

terraform plan

Pay special attention to the summary line and to two signals in particular:

  • destroy - a resource is being deleted. Sometimes intended, often a disaster. Know why.
  • # forces replacement - an in-place change is impossible, so Terraform will destroy and recreate the resource. For a compute instance that is a reboot; for a database that is data loss. This annotation is the most important thing to scan for.

Save the plan, then apply exactly that

The gap between "the plan I reviewed" and "the changes that applied" is where surprises live. Close it by saving the plan to a file and applying that exact file, so no drift can sneak in between review and apply.

terraform plan -out=tfplan          # compute and save the plan to a file
terraform show tfplan               # human-readable view of the saved plan
terraform show -json tfplan > plan.json   # machine-readable, for policy checks
terraform apply tfplan              # apply EXACTLY that plan, no re-planning, no prompt

This is the correct CI pattern: one job runs plan -out=tfplan and stores the artifact, a human (or a policy engine) reviews it, and the apply job consumes the saved file. Because apply tfplan does not recompute, what you approved is precisely what runs. The terraform show -json output is also the input every policy and security tool wants - a stable, structured description of the intended change.

Two more useful flags:

terraform plan -detailed-exitcode   # exit 0 = no changes, 2 = changes, 1 = error
terraform plan -refresh-only        # show only how real state drifted from state file

The -detailed-exitcode flag is how automation asks "is there a diff?" - it is the backbone of drift detection, covered below.

Variable validation, preconditions, and postconditions

The checks above are external. Terraform also lets you bake correctness rules into the configuration itself, so bad inputs fail immediately with a clear message instead of producing a broken plan or a runtime error.

A validation block inside a variable constrains what values are allowed:

variable "environment" {
  type = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

variable "instance_count" {
  type = number

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "instance_count must be between 1 and 10."
  }
}

Now terraform plan rejects environment = "production" up front with your message, instead of quietly building the wrong thing.

Preconditions and postconditions (check blocks inside a resource or output) assert things Terraform should guarantee around a resource - a precondition checks an assumption before the resource is handled, a postcondition checks a promise after.

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

  lifecycle {
    precondition {
      condition     = data.aws_ami.selected.architecture == "x86_64"
      error_message = "The selected AMI must be x86_64."
    }
    postcondition {
      condition     = self.public_dns != ""
      error_message = "The instance must receive a public DNS name."
    }
  }
}

These turn silent assumptions into enforced, self-documenting contracts. They cost nothing at runtime and catch a whole category of "it planned fine but the result was wrong" bugs.

Linting with tflint

terraform validate knows the language but not the provider. tflint fills that gap: it catches provider-specific mistakes Terraform will happily plan and only fail on at apply - an invalid EC2 instance type, a deprecated argument, an unenforced naming convention, unused declarations, missing required tags.

tflint --init     # install the configured plugins (e.g. the AWS ruleset)
tflint            # lint the current directory
tflint --recursive

You configure it in .tflint.hcl, enabling provider rulesets and any custom rules:

plugin "aws" {
  enabled = true
  version = "0.30.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

rule "terraform_naming_convention" {
  enabled = true
}

The value of tflint is catching things that are valid HCL but wrong in practice - instance_type = "t2.mega" validates fine because Terraform does not know AWS instance types, but tflint knows there is no such type and fails in a second instead of thirty minutes into an apply.

Security scanning with tfsec, checkov, and trivy

Linting asks "is this correct?" Security scanning asks "is this safe?" These tools statically analyze your Terraform against a large catalog of misconfiguration rules - a public S3 bucket, an unencrypted volume, a security group open to 0.0.0.0/0, missing logging - and flag them before they ship.

Trivy now absorbs the old tfsec engine and is the actively developed successor, so prefer it for new setups (tfsec still works and shares the rule heritage):

trivy config .                 # scan the current directory for misconfigurations
trivy config --severity HIGH,CRITICAL .
tfsec .                        # the older standalone scanner, same lineage

Checkov (from Bridgecrew) is the other common choice, with a very large rule set spanning Terraform, CloudFormation, Kubernetes, and more, and easy custom policies:

checkov -d .                            # scan a directory
checkov -f plan.json                    # scan a plan, not just source
checkov -d . --skip-check CKV_AWS_20    # suppress a specific rule

Two practical notes. First, scanning the plan JSON (checkov -f plan.json) is stronger than scanning source, because it sees resolved values after variables and modules expand, not just the raw HCL. Second, every scanner produces false positives for your context, so use inline suppressions with a reason (# tfsec:ignore:aws-s3-enable-bucket-logging this bucket is public by design) rather than disabling rules globally - a documented exception is auditable, a silenced ruleset is a blind spot.

Policy as code: OPA and Sentinel

Linters and scanners enforce general best practices. Policy as code enforces YOUR organization's rules - "no resource without a cost-center tag," "only these regions," "no instance larger than xlarge outside prod," "S3 buckets must have versioning." These are decisions, not universal truths, so you write them yourself and gate the pipeline on them.

The engine evaluates the plan JSON (terraform show -json tfplan), which is why saving the plan matters - the policy check runs against the exact change you are about to apply.

  • OPA / Conftest - open source, policies written in Rego, works with any pipeline. conftest test plan.json runs your .rego policies against the plan.
  • Sentinel - HashiCorp's own policy language, integrated into Terraform Cloud/Enterprise, with soft-mandatory and hard-mandatory enforcement levels.

A tiny Rego example, conceptually: "fail if any resource is missing an environment tag." The plan JSON lists every planned resource and its attributes; the policy walks that list and returns violations. You do not need to master Rego to use this - you need to know that policy-as-code is the layer where "we require X across all infrastructure" becomes an automated gate that blocks non-compliant plans, rather than a wiki page nobody reads.

The native terraform test framework

Since Terraform 1.6, there is a built-in test framework using .tftest.hcl files, run with terraform test. It is the right tool for module testing: assert that given some inputs, the module plans (or applies) the resources you expect with the values you expect.

A test file lives alongside your module (commonly in a tests/ directory) and is made of run blocks. Each run executes a plan or apply and asserts against the result:

# tests/defaults.tftest.hcl

run "sets_expected_instance_type" {
  command = plan          # plan-only: fast, creates nothing

  variables {
    environment = "dev"
  }

  assert {
    condition     = aws_instance.web.instance_type == "t3.small"
    error_message = "dev environment should use t3.small"
  }
}

run "rejects_bad_environment" {
  command = plan

  variables {
    environment = "banana"
  }

  expect_failures = [
    var.environment,      # the variable validation should reject this
  ]
}
terraform test                     # run every *.tftest.hcl
terraform test -filter=tests/defaults.tftest.hcl

The key lever is command. command = plan tests are fast and free - they check that your logic produces the right plan without creating anything, ideal for verifying conditionals, defaults, and variable validation. command = apply tests actually create real resources, then assert on their real attributes, then automatically destroy everything at the end of the run. Use plan-only for most tests and apply for the few that must confirm real-world behavior. Because it is built in, there is no extra language or framework to adopt - it is the default answer for testing a reusable module.

Integration testing with Terratest

terraform test proves the module plans and applies correctly. It does not prove the deployed thing actually WORKS - that the web server serves traffic, the load balancer routes, the database accepts connections. That is integration testing, and the standard tool is Terratest, a Go library from Gruntwork.

The pattern: in Go, apply the module, then make real assertions against the live infrastructure (HTTP requests, SSH, cloud SDK calls), then tear it down with a deferred destroy.

func TestWebServer(t *testing.T) {
    opts := &terraform.Options{TerraformDir: "../examples/web"}

    defer terraform.Destroy(t, opts)   // always clean up, even on failure
    terraform.InitAndApply(t, opts)

    url := terraform.Output(t, opts, "url")
    http_helper.HttpGetWithRetry(t, url, nil, 200, "OK", 30, 5*time.Second)
}
go test -v -timeout 30m ./test/

Terratest is powerful but expensive: it spins up real infrastructure, takes minutes to tens of minutes, costs money, and needs cloud credentials. So it sits at the top of the ladder - you run it on the handful of modules where "does the real thing actually work end to end" is worth the cost, typically on a schedule or before a release, not on every commit. Note the long timeout and the deferred destroy: a Terratest run that fails to clean up leaks real, billable resources.

Drift detection with a scheduled plan

Everything so far checks code before it applies. Drift is the opposite problem: after you apply, someone changes a resource by hand in the console, or another tool touches it, and now reality no longer matches your code. Nothing in your pipeline notices, because nobody ran a plan.

The fix is to run a plan on a schedule and alert when it is not empty. terraform plan -detailed-exitcode is built for exactly this - it exits 0 for no changes, 2 for changes present, 1 for an error:

terraform init -input=false
terraform plan -detailed-exitcode -refresh-only
# exit 0 -> in sync; exit 2 -> drift detected; exit 1 -> error

A nightly CI job that runs this and fails (or opens a ticket / pings a channel) on exit code 2 gives you continuous assurance that the live world still matches git. The -refresh-only variant focuses on external drift specifically - resources that changed outside Terraform - versus pending changes from code you have not applied yet. Either way, the sooner you learn a security group was hand-edited at 2am, the cheaper it is to reconcile.

Putting it in a pipeline

A sane CI ordering follows the ladder - fail fast on the cheap checks, gate the expensive ones behind a manual approval:

# --- fast checks, every commit, fail fast ---
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
tflint --recursive
trivy config .                         # or: checkov -d .

# --- needs credentials, still no apply ---
terraform init -input=false
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json                # policy as code against the plan
checkov -f plan.json                   # scan resolved plan values

# --- module tests ---
terraform test                         # plan-based tests are cheap; run them here

# --- gate, then apply EXACTLY the reviewed plan ---
terraform apply tfplan                 # after human/policy approval

# --- expensive, on a schedule or pre-release, not every commit ---
go test -v -timeout 30m ./test/        # Terratest integration tests

And separately, on a nightly schedule:

terraform plan -detailed-exitcode -refresh-only   # exit 2 -> alert on drift

The shape of it

Confidence in Terraform is a ladder, not a single test. fmt and validate cost nothing and run on every save. The plan review - saved to a file and applied verbatim - is your primary safety gate and catches most real disasters, especially destroy and forces replacement. Variable validation, preconditions, and postconditions bake correctness into the config so bad inputs fail early. tflint catches provider-specific mistakes, trivy / checkov / tfsec catch insecure configurations, and OPA / Sentinel enforce your own organizational rules against the plan JSON. The native terraform test framework handles module tests cheaply, plan-first, and Terratest proves the real deployed thing works when that is worth the cost. Finally, a scheduled plan -detailed-exitcode catches drift after the fact. Spend the cheap checks freely and the expensive ones deliberately, and most infrastructure mistakes never reach production.