Chapter 11: Testing and Validation
Topic 65

The terraform test Framework

TestingModules

Terraform 1.6 shipped a built-in test framework, and it changed what testing infrastructure code looks like. You write tests in HCL in .tftest.hcl files, each containing run blocks that execute a plan or an apply against your configuration and assert blocks that check the results. No external language, no Go, no separate harness — the tests live next to the module and run with terraform test.

The question this answers is the one every module author has: does this module actually create what it claims, do its variable validations fire when they should, does it behave correctly across the input combinations people will throw at it. Before 1.6 you reached for Terratest in Go or hoped a careful plan review would catch regressions. Now the assertions are first-class, version-controlled HCL that runs in CI on every change to the module.

Two kinds of run block
command = plan
Plan-based tests — fast and free, no real resources. Assert on planned values and variable validations. Nothing to clean up.
command = apply
Apply-based tests — create real infrastructure to assert on actual behavior. Slow, costs money, and Terraform must destroy it afterward.

The Framework

A test file is a sequence of run blocks. Each run sets variables, runs either a plan or an apply, and asserts on outputs and resource attributes. Runs in one file execute in order and share state, so a later run can build on what an earlier one created. The assertion is a condition that must be true and an error_message printed when it is not — the same shape as a variable validation.

tests/defaults.tftest.hcl — a plan-based test
run "sets_bucket_name_from_prefix" {
  command = plan

  variables {
    name_prefix = "acme-prod"
  }

  assert {
    condition     = aws_s3_bucket.this.bucket == "acme-prod-logs"
    error_message = "bucket name must be the prefix plus -logs"
  }
}

command = plan makes this run compute a plan and assert against the planned values without creating anything. The test passes if the module would name the bucket acme-prod-logs given that prefix, and fails with a clear message if the naming logic regresses.

Plan vs Apply Tests

Every run chooses command = plan or command = apply, and the difference is the whole cost model. A plan test computes the diff and asserts on planned values — it is fast, free, and creates no real resources, so it verifies the config's intended shape and logic. An apply test actually creates the infrastructure, asserts that it works end to end, then destroys it — which costs time, money, and demands reliable cleanup. Plan tests are the broad base of the pyramid; apply tests are the narrow, expensive top reserved for critical paths.

Mocking Providers

Plan tests still need a provider to compute the plan, and that is where mock providers come in. A mock_provider block stands in for the real AWS provider, returning synthetic values for computed attributes so the plan resolves without real credentials or API calls. This lets you test a module's logic — its conditionals, its for_each shaping, its name construction — entirely offline, with no AWS account and no cost, which is exactly what you want running on every pull request.

a mock provider lets plan tests run with no real AWS
mock_provider "aws" {}

run "creates_one_subnet_per_az" {
  command = plan

  variables {
    availability_zones = ["us-east-1a", "us-east-1b"]
  }

  assert {
    condition     = length(aws_subnet.this) == 2
    error_message = "one subnet must be created per availability zone"
  }
}

With the mock provider in place, this run asserts that the module creates exactly one subnet per AZ for any list you pass — pure logic, verified in milliseconds, no infrastructure created. That is the sweet spot for the framework: testing the parts of a module that have logic worth getting wrong.

In CI

Run terraform test on every change to a module, before it is published or merged. Lean on plan-based tests with mock providers for the bulk of coverage, since they are fast enough to run on every commit and cost nothing. Reserve apply-based tests for a handful of critical end-to-end paths where "it plans correctly" is not enough assurance — and make sure those tests clean up reliably even when an assertion fails partway through, or you will leak resources and pay for them.

Plan-based vs apply-based tests

Plan-based tests (command = plan) — compute the diff and assert on planned values without creating real resources. Fast, free, and safe to run on every commit; with a mock provider they need no AWS account. Use them broadly to verify the config's intended shape, conditionals, and validation logic.

Apply-based tests (command = apply) — create real infrastructure, assert it works end to end, then destroy it. Slow, costing real money, and dependent on reliable cleanup. Reserve them for a few critical paths where only a real apply proves the thing works.

Common Mistakes
  • Writing only apply-based tests that are slow and cost real money, when most of the assertions could run as fast, free plan tests against a mock provider.
  • Running apply tests without reliable cleanup, so a failure mid-run leaks real resources and you discover them on the next bill.
  • Testing trivial pass-through behavior — that a variable reaches an output unchanged — instead of the conditional logic and validation that actually breaks.
  • Assuming the native framework covers everything and skipping contract or integration testing where it genuinely fits better.
  • Forgetting that runs in one file share state and execute in order, then being confused when a later run sees resources an earlier run created.
Best Practices
  • Favor plan-based tests for speed and cost, reserving apply-based tests for the few critical end-to-end paths that justify creating real resources.
  • Use mock_provider blocks to test module logic with no AWS account, so the suite runs on every commit for free.
  • Test the parts that have logic — conditionals, for_each shaping, variable validation, computed outputs — not trivial pass-through values.
  • Ensure every apply-based test cleans up reliably, even when an assertion fails partway through the run.
  • Run terraform test in CI on every module change, before publishing a new version.
Comparable tools Terratest Go-based apply tests that predate the native framework kitchen-terraform an older Ruby-based integration harness Pulumi tests written in the host language's test framework

Knowledge Check

What is the trade-off of a command = apply test versus a command = plan test?

  • The apply test creates real infrastructure and verifies it end to end, at the cost of time and cleanup; the plan test is fast and free but only checks planned values
  • The apply test runs entirely offline with a mock provider and costs nothing, while the plan test needs real live AWS credentials and provisions resources to check them
  • The apply test can only assert on declared outputs while the plan test can assert on any resource attribute
  • The plan test creates the real resources while the apply test only previews them as a diff

What does a mock_provider block let you do?

  • Run plan-based tests that exercise the module's logic without real credentials, API calls, or created resources
  • Apply a module to a real isolated sandbox AWS account that is then automatically created and torn down for you each run
  • Replace the test framework's hand-written assert blocks with provider-generated checks
  • Skip the plan phase entirely and assert directly against the saved state file

In a module, which behavior is most worth covering with a test?

  • The conditional and for_each logic — for example, that one subnet is created per availability zone passed in
  • That a plain string input variable arrives at a named output unchanged, with no transformation applied to it
  • That the AWS provider itself returns the correct ID for a freshly created resource
  • That a run of terraform fmt leaves the module's source files unchanged

Which Terraform version introduced the native .tftest.hcl framework?

  • 1.6 — before it, teams reached for external tools like Terratest
  • 0.12, alongside the original introduction of the new HCL2 expression syntax
  • 1.1, shipped in the very same release as the new moved blocks
  • It has always existed as a built-in part of the core CLI

You got correct