TerraformXXI · Testing, Linting, and Static AnalysisProduction Terraform
terraform test in Depth
What you'll learn
- Author a .tftest.hcl file with run blocks, variables, and assertions
- Distinguish `command = plan` from `command = apply` and pick the right one per test
- Write assertions on resource attributes, output values, and the resource graph
- Apply a per-module test discipline: every module ships with tests; tests run on every PR
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Terraform 1.6 introduced a native test framework. The test
files are HCL files with a .tftest.hcl extension. The
engine is terraform test, shipped with the same binary.
There is no separate install, no separate plugin, no
provider version to manage. The output is structured:
pass, fail, with a file and line for each assertion. The
framework is the right tool for unit and integration tests
in 2026. The community alternatives (terratest,
kitchen-terraform) are older and more complex; they solve
problems the native framework has absorbed.
What the framework does
terraform test reads every *.tftest.hcl file in the
working directory and runs each run block in sequence.
Each run block is a self-contained plan or apply with its
own variables, its own provider configuration, and its own
assertions. The framework reports pass or fail per run.
modules/s3_bucket/
main.tf
variables.tf
outputs.tf
tests/
unit.tftest.hcl # run with mock_provider
integration.tftest.hcl # run against a sandbox
# Severity: READ-ONLY - runs in process.
terraform test
Output:
tests/unit.tftest.hcl... in progress
run "creates_bucket"... pass
run "enforces_encryption"... pass
tests/unit.tftest.hcl... pass
tests/integration.tftest.hcl... in progress
run "creates_bucket_in_sandbox"... pass
tests/integration.tftest.hcl... pass
Success! 3 passed, 0 failed.
Exit 0 on a clean run. Exit 1 on any failed assertion.
The file format
A .tftest.hcl file looks like HCL, with a top-level
run block and an optional variables and provider
block. The file is parsed by the same parser that reads
your configuration.
# modules/s3_bucket/tests/unit.tftest.hcl
# Optional: variables that apply to the whole file
variables {
bucket_name = "test-bucket"
environment = "test"
}
# Optional: a mock provider for the whole file
mock_provider "aws" {
alias = "mock"
}
# Each run block is one test
run "creates_bucket" {
command = plan
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket"
error_message = "bucket name did not propagate"
}
assert {
condition = aws_s3_bucket.this.tags["Environment"] == "test"
error_message = "environment tag missing"
}
}
The run block is the unit of testing. The assert blocks
are the assertions. The command field controls whether the
run is a plan or an apply.
The command field
Each run block chooses between plan and apply. The
choice is the most consequential decision in the test.
command = plan - read-only, no state changes, runs in
seconds. The right choice for unit tests
and most integration tests.
command = apply - runs the apply, captures the resulting
state, runs in tens of seconds. The right
choice for integration tests that need to
verify resource attributes after creation.
The rule of thumb:
- Unit tests use
command = plan. The mock provider returns whatever the override ordefaultssay. The plan is the proof. - Integration tests use
command = applywhen the test must verify a resource attribute that is only populated after the cloud accepts the create. Examples: the ARN of a freshly created bucket, the DNS name of a freshly created load balancer, theidof a freshly provisioned EC2 instance.
A command = apply test without a real provider is a
contradiction. The mock provider does not have a real
backend. The apply will succeed only because the mock
cooperates.
Assertions
The assert block is a HCL boolean expression. The
condition is the test. The error message is the failure
diagnosis.
assert {
condition = aws_s3_bucket.this.encryption[0].sse_algorithm == "AES256"
error_message = "SSE algorithm must be AES256"
}
assert {
condition = length(aws_subnet.public) == 3
error_message = "expected three public subnets, got ${length(aws_subnet.public)}"
}
assert {
condition = contains(["standard", "intelligent-tiering"], aws_s3_bucket.this.lifecycle_rule[0].status)
error_message = "lifecycle status must be standard or intelligent-tiering"
}
What assert can check:
- Resource attributes (
aws_s3_bucket.this.bucket) - Output values (
output.arn) - Computed values from the plan
- The resource graph (counts of resources, references between resources)
- Variable values
What assert cannot check:
- That the cloud accepted the configuration (only
terraform applyknows). The assertion is on the plan output, not on the cloud response. - That the configuration will work in three months. The test is point-in-time.
- Performance, security, or cost. Those are separate checks.
The variables block
The variables block at the top of the file provides
defaults for every run block. A run block can override
them with its own variables block.
variables {
bucket_name = "test-bucket"
environment = "test"
}
run "with_override" {
command = plan
variables {
bucket_name = "override-bucket"
}
assert {
condition = aws_s3_bucket.this.bucket == "override-bucket"
error_message = "run-level variables did not override file-level variables"
}
}
The variables block is the most common way to test the
same module with different inputs. The test is the
combination of inputs and assertions.
The provider block
For integration tests, the file can declare a real provider. The block is the same shape as in a normal configuration, but it is scoped to the test.
provider "aws" {
region = "eu-west-1"
access_key = "test"
secret_key = "test"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {
s3 = "http://localhost:4566"
}
}
The access_key and secret_key are placeholders for
LocalStack. For a real sandbox, the values come from the
CI environment.
The per-module discipline
The framework is the gate. The discipline is the rule that makes the gate work.
Every module ships with tests.
- tests/unit.tftest.hcl is required.
- tests/integration.tftest.hcl is required for modules
that hit the cloud.
Tests run on every PR.
- Unit tests run on every PR.
- Integration tests run on merge to main and on
nightly.
A test failure is a pipeline failure.
- The merge is blocked.
- The contributor fixes the configuration and
re-runs.
New module behaviour is a new test.
- A contributor adding a new variable adds a test that
exercises the new variable.
- A contributor adding a new conditional adds a test
that exercises the conditional.
The test is the contract. A module without a test is a module whose contract is “whatever the implementation happens to do.” That is not a contract.
How to run
# Run all tests in the working directory
terraform test
# Run a specific test file
terraform test -test-directory=tests/unit
# Run with verbose output
terraform test -verbose
# Filter by run block name
terraform test -filter=creates_bucket
The -filter flag is the right tool when one test is
flaky and you want to re-run it without the full suite.
Production failure modes
1. Tests are flaky on local but green in CI
Symptom: terraform test exits 1 on the contributor’s
laptop, exits 0 in CI. Cause: the test depends on a
state file or a network resource that exists locally but
not in CI. Fix: the test should not depend on local
state. The test is read-only against the configuration
and the mock provider. If the test relies on a real
provider, it is an integration test and belongs in the
sandbox.
2. command = apply in a unit test
Symptom: the test runs against the mock provider and the
apply step succeeds, but the test takes 30 seconds. Cause:
the apply engine runs against the mock. The mock cooperates
but the engine still runs through the apply graph. Fix:
use command = plan for unit tests. The apply is for
integration tests.
3. Test asserts on a value that is only known after apply
Symptom: condition = aws_instance.this.id fails with
“instance is null.” Cause: id is only populated after a
real apply. The plan does not know the value. Fix: either
use command = apply (integration test) or mock the
value with an override_resource block (unit test).
4. Tests are not in the module
Symptom: a module has no tests/ directory. Cause: the
contributor who wrote the module did not write tests. Fix:
enforce the discipline in CI. A module without a test
file is a module that does not merge.
5. Test file fails to parse
Symptom: terraform test exits 2 with
Error: Unsupported argument. Cause: the test file
uses a syntax that the Terraform version in CI does not
support. The native framework is new and the syntax has
shifted between 1.6 and 1.9. Fix: pin the Terraform
version in CI to match the version the contributor uses
locally.
6. The test passes but the production apply fails
Symptom: the test is green, the apply in staging fails on a region-specific constraint. Cause: the test is a unit test; the mock provider accepted the configuration. Fix: unit tests are not the gate for region-specific behaviour. Add an integration test, or accept the gap and add the finding to the runbook.
7. Test suite is slow across the module
Symptom: terraform test across a 40-module monorepo
takes 5 minutes. Cause: the test framework runs serially
across modules. Fix: split the test runs across CI
matrices. Each module’s tests run on a separate runner.
The suite finishes in the time of the slowest module.
Security and performance implications
The test framework is local-first. Unit tests run in process with no network calls. The CI does not need IAM permissions for the unit test step.
Integration tests run against a real provider. The CI needs short-lived credentials scoped to a sandbox account. The blast radius of a leaked integration test credential is the sandbox account, not the production account.
A unit test suite with 10 runs takes under a second. A
suite with 100 runs takes a few seconds. The cost scales
with the number of runs, not the size of the
configuration. The --filter flag is the right tool when
you want to re-run a single test without the full suite.
Production guidance
- Every module ships with
tests/unit.tftest.hcl. The path is the convention. - Unit tests run on every PR. Integration tests run on merge to main and on nightly.
command = planis the default for unit tests. Usecommand = applyonly for integration tests that need the post-apply state.- Write the assertion first. The test is the contract.
- Treat a failing test as a pipeline failure. The merge is blocked.
- Pin the Terraform version in CI. The test syntax shifts between minor versions.
Verification
# Run the unit tests
terraform test -test-directory=tests/unit
# Run with verbose output
terraform test -verbose
# Show the test file structure
find tests -name '*.tftest.hcl' -print
A clean run exits 0. The verbose output shows each run
block with its pass or fail status.
Knowledge check · 7 questions
Q1. Which Terraform version introduced the native `terraform test` framework?
Q2. The `terraform test` framework requires a separate installation beyond the Terraform binary.
Q3. Which `command` value is the right default for unit tests?
Q4. An assertion fails because `aws_instance.this.id` is null. What is the most likely cause?
Q5. Which of the following are appropriate per-module test disciplines? (Select all that apply.)
Q6. What kind of assertion is the right primary form for a terraform test?
Q7. A team wants to adopt the native test framework in a 30-module monorepo. What is the right starting point?
Passing score: 75%. Answers are checked in this browser.