Terraform 1.6 tests module behavior through ordered runs
Terraform 1.6 makes its redesigned test command generally available. Plan assertions, expected failures, and separate test state give module authors different ways to verify their contracts.
A reusable Terraform module has behavior beyond whether its configuration parses. Inputs can select different resource arguments, outputs can transform values, and custom conditions can reject an unsupported combination. Terraform 1.6, released on October 4, makes the redesigned terraform test command generally available. Test files describe ordered runs against a module and assertions about the resulting plan or state.
The release notes describe a substantial change from the experimental testing approach. The useful unit is now a run block that executes planning and, optionally, apply. This puts the test's execution mode in the same file as its inputs and assertions. A module author can distinguish a naming-rule test that needs only a plan from a test that must observe an actual provisioned object.
I like that distinction because Terraform's reusable language is valuable only when callers can depend on what a module does. Testing the module contract belongs beside the configuration that implements it. The built-in runner gives authors a direct way to express such tests without requiring a separate program to sequence every Terraform command.
A test can stop after planning
The Terraform 1.6 testing documentation identifies .tftest.hcl and .tftest.json as test files. Each contains at least one run. File-level variables supply common inputs, while a run can override them for a particular case. Terraform executes runs in their declared order.
The default command is apply. A run with command = plan instead checks the planned configuration without creating new infrastructure for that run. Assertions must use values available at that stage. An output that depends on a provider-computed value may require apply before its concrete result can be tested.
Consider a small module whose contract is to append a suffix to a caller's prefix. This configuration exports the calculated name and stores it in a built-in resource.
variable "prefix" {
type = string
validation {
condition = length(var.prefix) > 0
error_message = "The prefix must contain at least one character."
}
}
resource "terraform_data" "name" {
input = "${var.prefix}-service"
}
output "service_name" {
value = terraform_data.name.input
}
The output intentionally reads input, whose expression is known during planning. Documentation for terraform_data distinguishes that configured value from the computed output attribute. The example does not need an external provider or remote API to demonstrate the naming contract.
A corresponding test file can inspect that output during a plan. Saving this as tests/name.tftest.hcl makes the test discoverable by the default command.
variables {
prefix = "demo"
}
run "adds_service_suffix" {
command = plan
assert {
condition = output.service_name == "demo-service"
error_message = "The module did not append the service suffix."
}
}
run "rejects_empty_prefix" {
command = plan
variables {
prefix = ""
}
expect_failures = [var.prefix]
}
The two runs verify different promises. One checks the resulting value for an accepted input. The other checks that the declared validation rejects an unsupported input. A module that silently accepts the empty prefix would fail the second run even though Terraform could otherwise plan the configuration.
Expected failure means a particular condition failed
The expect_failures list names checkable objects whose custom conditions should fail. This is not a general instruction to accept any error. Terraform's documentation distinguishes user-defined conditions from errors such as supplying a value of the wrong type. An unrelated type mismatch still fails the test.
That specificity prevents a negative test from passing for the wrong reason. In the example, the intended evidence is failure of the prefix variable's validation. A missing provider or another configuration error would not establish that the module rejects an empty string through its declared contract.
Expected failure also has an execution stage. A run configured to apply expects the relevant failure during apply. If planning fails first, the run cannot perform its requested operation and the test fails. The documentation consequently recommends plan mode for most expected-failure cases, while allowing apply-stage expectations when the condition depends on computed values.
Most custom conditions halt execution when they fail. A test that expects such a failure cannot assume all later values will have been computed. Independent check assertions retain their nonblocking execution behavior, but failure of those assertions counts as a failed test unless expected. The runner's pass-or-fail result therefore adds a testing interpretation to conditions whose ordinary operational consequences differ.
Ordered runs share defined state
The CLI command documentation says each test file starts with empty state maintained in memory. This state is separate from the configuration's existing state. Tests do not simply apply their operations against the state used by an ordinary deployment of the module.
Within a file, runs against the main configuration share its test state. This allows a sequence to create resources and then exercise an update with different variables. Ordered runs are therefore capable of describing a lifecycle sequence, not merely a collection of unrelated evaluations. A later run sees the objects represented by the state left by earlier runs against that configuration.
Test variables can also refer to outputs of earlier runs using the run namespace. A setup step can provide a value that the main module needs. The test format then records the dependency explicitly instead of requiring an external script to extract an output and pass it to the next command.
Separate state does not mean separate provider authority. A test provider still has whatever credentials and service access its configuration supplies. The command documentation notes that tests can create real infrastructure. Choosing plan mode and choosing a testing account address different concerns, since one selects the operation and the other selects where provider requests can act.
Provider configuration can be declared inside the test file, including aliases. A run's providers mapping can select which configurations its module receives. That supports tests whose inputs include a provider region or another provider-specific setting. The testing documentation cautions that changing provider mappings between apply runs can leave later operations unable to use resources created by an earlier mapping. A shared test state records resource identity, but it does not make two provider configurations interchangeable. Tests that switch accounts or regions need to keep that distinction in their lifecycle sequence.
Helper modules have their own state and cleanup order
A run can select an alternate module with a module block. In this release, that block accepts a source and an optional version, with local and registry sources supported. Other run-level settings supply variables and provider mappings. The testing documentation gives setup and verification modules as examples.
Each alternate module has a separate test state, shared by runs that use that module. The main configuration retains its own state. This supports a setup module that creates a dependency, a main module that uses it, and a verification module that reads the resulting infrastructure through data sources.
Cleanup follows a specified order. Terraform first destroys resources in the main configuration's test state. It then destroys resources from alternate modules in reverse run order. That sequence allows a setup module's bucket to survive until objects created by the main configuration have been removed. Helper-module dependencies need to respect the order for destruction to complete.
The documentation specifically permits verification data sources to refer to objects created by the main configuration because data sources need no destruction. Creating managed helper resources that depend on the main configuration is different, since the main resources are cleaned up first. The distinction is part of test design, not merely an implementation detail of the runner.
I prefer a small setup module when a test needs infrastructure the module under test should not own. It makes that ownership explicit and gives cleanup a structure that can be understood from the test. A verification module can then check externally observable results without expanding the production module's outputs solely for testing.
Test results include the work left behind
After a test file completes, Terraform attempts to destroy the infrastructure it created. If destruction fails, the command reports resources it could not remove and diagnostics explaining the failure. A passing assertion and successful cleanup are different outcomes, so the complete command output matters when the run has created remote objects.
The command supports filtering to selected test files and verbose output showing the plan or state for each run. Its JSON mode makes results available to automation. These controls let a module author inspect a particular lifecycle sequence without treating the entire collection as one opaque command.
The new framework's main benefit is that a test states which behavior it expects and which operation should establish it. Plan-only runs can check calculated values and rejected inputs. Apply runs can establish facts about provisioned objects, with test state and cleanup managed by the runner. The execution mode remains explicit in the same run block as the assertion.