Why a built-in test runner
Before 1.6, "Terraform tests" meant one of two things: terratest in Go (spin up, verify, tear down) or nothing at all. The first option is expensive in time; the second was common practice. HashiCorp acknowledged the gap and built a runner directly into the CLI.
The idea is straightforward. You place a *.tftest.hcl file next to your
module. In it you describe scenarios: which variables to pass in and what
the output should look like. terraform test runs each scenario and reports
results.
Tests are most valuable for modules. They work at the root level too, but the root configuration is effectively tested by production.
A minimal test
Module layout:
modules/s3-bucket/
├── main.tf
├── variables.tf
└── outputs.tf
tests/
└── basic.tftest.hcl
tests/basic.tftest.hcl:
variables {name = "test-bucket"
}
run "naming" {command = plan
assert {condition = aws_s3_bucket.this.bucket == "test-bucket"
error_message = "bucket name not propagated from var.name"
}
}
run "tags" {command = plan
variables { tags = { Owner = "ci" }}
assert {condition = aws_s3_bucket.this.tags["Owner"] == "ci"
error_message = "Owner tag missing"
}
}
Running it:
terraform test
# tests/basic.tftest.hcl... in progress
# run "naming"... pass
# run "tags"... pass
# Success! 2 passed, 0 failed.
No cloud account needed. command = plan stops at the plan stage.
File structure
| Block | What it does |
|---|---|
variables { ... } (file-level) | Default variables for all run blocks in the file. |
run "<name>" { ... } | One scenario. Has its own variables, command, and assert blocks. |
assert { condition error_message } | A check. condition is a boolean expression. error_message is shown on failure. |
provider "X" { ... } | Overrides the provider for this file only. |
module { source = "..." } (inside run) | Uses a different module instead of the one in the current directory. Useful when testing a wrapper. |
command = plan vs command = apply
| Mode | What it does | When to use |
|---|---|---|
command = plan | Plan only, no resources created. Attributes that Terraform can resolve at plan stage are available. | Unit tests for modules, which covers around 90% of cases. |
command = apply | Full apply followed by destroy. Computed attributes (ARN, ID) are available. | Integration: verify that the module actually works on LocalStack or AWS. |
Apply-mode tests are expensive. Each run block triggers an apply and then a
destroy. A suite of 10 such tests takes minutes on LocalStack and tens of
minutes on AWS. Use them sparingly.
Accessing attributes
Inside assert you can reference:
# a resource attribute
aws_s3_bucket.this.bucket
# a module output
output.bucket_arn
# a variable
var.name
# computed expressions
length(aws_s3_bucket.this.tags) >= 1
contains(keys(aws_s3_bucket.this.tags), "Owner")
Attributes that are only known after apply (such as arn and id on certain
resources) show up as (known after apply) with command = plan. If an
assert depends on them, you need command = apply.
Expected failure: testing validation
Sometimes you need to confirm that a plan fails on bad input. Use
expect_failures for that:
run "rejects_empty_name" {command = plan
variables {name = ""
}
expect_failures = [
var.name, # the variable whose validation block must fire
]
}
The test passes only if the referenced validation { condition = ... } blocks
or preconditions/postconditions actually fail. This is an inverted test.
Testing multiple scenarios for one module
A typical layout:
tests/
├── defaults.tftest.hcl # default variables, basic happy path
├── tags.tftest.hcl # tag variations
├── versioning.tftest.hcl # versioning_enabled / disabled
└── invalid.tftest.hcl # expect_failures
Each file is an independent scenario. Within a file, run blocks execute
sequentially, and each block sees the state left by the previous one when you
use command = apply. This is useful for chains: create, verify, update,
verify again.
Running a single file
terraform test -filter=tests/tags.tftest.hcl
Or several at once:
terraform test -filter=tests/tags.tftest.hcl -filter=tests/versioning.tftest.hcl
Pitfalls
-
The test framework requires
terraform initin the module directory. If you skip it, the runner will printModule not installed. In CI, always runterraform init -backend=falsebeforeterraform test. -
command = plandoes not resolve everything. ARNs, IDs, and other computed attributes come back as unknown. If an assert readsaws_iam_role.this.arn, the test will fail with "cannot proceed: value is not known". Either switch tocommand = applyor check a different attribute. -
A
*.tftest.hclfile is not parsed like a regular.tffile. It is a separate HCL subset with a different set of blocks. You cannot declareresourceordatablocks in a test file; those must live in the ordinary.tffiles that the test invokes. -
Provider configuration in test files does not carry over automatically. If your module's
provider "aws"block points to a LocalStack endpoint, repeat that block in the*.tftest.hclfile. Otherwise the runner picks up the default provider (without the endpoint) and calls the real AWS. See tf-test-mocks for mock_provider, which solves this class of problems more cleanly. -
Tests run in your working directory. Not in a separate workspace and not against a separate state. If you use
command = applyand the test fails between apply and destroy, those resources remain. Run tests in a clean directory or against LocalStack. -
The test runner is separate from unit tests in Pulumi or CDKTF. If you have moved to CDKTF,
.tftest.hclfiles are of limited use. Write your tests in Jest or pytest, and runterraform testonly if you want to validate the generated HCL. See tf-cdktf.