Native Test Framework
Terraform 1.6 introduced a first-class testing framework built directly into the CLI, so you no longer need an external language like Go to verify a module behaves correctly. Tests live in .tftest.hcl files written in the same HCL you already know, they run real plan and apply operations against your configuration, and they assert on outputs, resource attributes, and computed values. Because the framework can provision and then automatically destroy ephemeral infrastructure, it gives you confidence that a module actually works — not just that it parses. OpenTofu ships the identical tofu test command and .tftest.hcl format.
How the framework is structured
A test file is a collection of run blocks. Each run executes the configuration under test and then evaluates one or more assert blocks. Runs execute top to bottom within a file, sharing state, so a later run sees the resources created by an earlier one. By default a run performs a full apply, but you can switch it to a plan-only check.
Test files are discovered automatically in the root module directory and in a tests/ subdirectory. Running the suite is a single command:
terraform test
Terraform initializes the configuration, executes every run block, reports pass or fail per assertion, and — for apply runs — tears down everything it created at the end, in reverse order.
Plan tests vs apply tests
The command argument on a run block selects the mode. A plan run is fast and offline-friendly: it evaluates expressions and validation logic without creating anything, which is ideal for checking input validation, computed locals, and conditional logic. An apply run actually provisions resources, which is slower and may incur cost, but lets you assert on attributes that are only known after creation.
| Aspect | command = plan | command = apply |
|---|---|---|
| Creates real resources | No | Yes |
| Speed | Fast | Slower |
| Asserts on computed (known-after-apply) values | No | Yes |
| Cost / credentials | Usually none | Real provider calls |
| Auto-cleanup | Nothing to clean | Destroys at end of run |
Default to
planruns for logic and validation, and reserveapplyruns for the handful of behaviors that genuinely require live resources. A suite that is mostly plan tests stays fast and cheap.
A worked example
Consider a small module that creates an S3 bucket with versioning and exposes a couple of outputs.
# main.tf
variable "bucket_name" {
type = string
validation {
condition = length(var.bucket_name) >= 3
error_message = "bucket_name must be at least 3 characters."
}
}
variable "force_destroy" {
type = bool
default = false
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
force_destroy = var.force_destroy
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
output "versioning_status" {
value = aws_s3_bucket_versioning.this.versioning_configuration[0].status
}
The test file lives in tests/bucket.tftest.hcl. The first run is a plan test that checks input validation logic; the second is an apply test that asserts on a real resource attribute and an output.
# tests/bucket.tftest.hcl
variables {
bucket_name = "devcraftly-test-bucket"
}
run "versioning_is_enabled_in_plan" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Versioning must be configured as Enabled."
}
}
run "bucket_has_correct_name" {
command = apply
assert {
condition = aws_s3_bucket.this.bucket == "devcraftly-test-bucket"
error_message = "Bucket name did not match the requested name."
}
assert {
condition = output.versioning_status == "Enabled"
error_message = "Output versioning_status should report Enabled."
}
}
Running the suite produces per-run, per-assertion results:
terraform test
Output:
tests/bucket.tftest.hcl... in progress
run "versioning_is_enabled_in_plan"... pass
run "bucket_has_correct_name"... pass
tests/bucket.tftest.hcl... teardown
tests/bucket.tftest.hcl... pass
Success! 2 passed, 0 failed.
Per-run variables and validation failures
A top-level variables block sets defaults for every run, and each run may override them to exercise a different scenario. You can also assert that an invalid input is rejected with expect_failures, pointing at the checkable object — here the variable’s validation block.
run "rejects_short_name" {
command = plan
variables {
bucket_name = "ab"
}
expect_failures = [
var.bucket_name,
]
}
Mocking providers
Apply tests need a real provider, which means credentials and cost. To test resource shapes without ever calling a cloud API, Terraform 1.7+ supports mock_provider. Mocked providers return fake-but-schema-valid data for computed attributes, so an apply run completes instantly with no network access. You can also pin specific attributes with override_resource or override_data so assertions remain deterministic.
mock_provider "aws" {}
run "name_is_passed_through_with_mock" {
command = apply
override_resource {
target = aws_s3_bucket.this
values = {
arn = "arn:aws:s3:::devcraftly-test-bucket"
}
}
assert {
condition = output.bucket_arn == "arn:aws:s3:::devcraftly-test-bucket"
error_message = "bucket_arn output should expose the resource ARN."
}
}
Mocking is the bridge between cheap plan tests and expensive live apply tests: you get apply-time semantics and computed values without provisioning anything real.
Best Practices
- Keep tests in a dedicated
tests/directory so they are easy to find and run separately. - Prefer
command = planfor logic, validation, and conditional checks; it is fast and needs no credentials. - Use
mock_providerto exerciseapplybehavior without real cloud resources, reserving live applies for true end-to-end coverage. - Write a clear
error_messageon everyassertso failures explain what was expected. - Use
expect_failuresto lock in your variablevalidationrules and precondition logic. - Run
terraform testin CI on every pull request so module regressions are caught before merge. - Reuse a top-level
variablesblock for shared defaults and override perrunto cover edge cases.