Terraform
Tests
Note: This testing framework is available in Terraform v1.6.0 and later.
Terraform tests let authors validate that module configuration updates do not introduce breaking changes. Tests run against test-specific, short-lived resources, preventing any risk to your existing infrastructure or state.
Integration or Unit testing
By default, tests within Terraform create real infrastructure and can run assertions and validations against that infrastructure. This is analogous to integration testing because you are testing Terraform's core functionality by executing operations and validating the infrastructure Terraform creates.
You can override the normal testing behavior by updating the command
attribute within a run
block (examples below). By default, each run
block executes with command = apply
instructing Terraform to execute a complete apply
operation against your configuration. Replacing the command
value with command = plan
instructs Terraform to not create new infrastructure for this run
block. This allows test authors to validate logical operations and custom conditions within their infrastructure in a process analogous to unit testing.
Terraform v1.7.0 introduced the ability to mock data returned by the providers during a terraform test
execution. This can be used to write more detailed and complete unit tests.
Syntax
Each Terraform test lives in a test file. Terraform discovers test files are based on their file extension: .tftest.hcl
or .tftest.json
.
Each test file contains the following root level attributes and blocks:
Terraform executes run
blocks in order, simulating a series of Terraform commands executing directly within the configuration directory. The order of the variables
and provider
blocks doesn't matter, Terraform processes all the values within these blocks at the beginning of the test operation. We recommend defining your variables
and provider
blocks first, at the beginning of the test file.
Example
The following example demonstrates a simple Terraform configuration that creates an AWS S3 bucket, using an input variable to modify the name. We will create an example test file (below) that validates the buckets name is created as expected.
# main.tf
provider "aws" {
region = "eu-central-1"
}
variable "bucket_prefix" {
type = string
}
resource "aws_s3_bucket" "bucket" {
bucket = "${var.bucket_prefix}-bucket"
}
output "bucket_name" {
value = aws_s3_bucket.bucket.bucket
}
The following test file runs a single Terraform plan
command which creates the S3 bucket, and then validates the logic for calculating the name is correct by checking the actual name matches the expected name.
# valid_string_concat.tftest.hcl
variables {
bucket_prefix = "test"
}
run "valid_string_concat" {
command = plan
assert {
condition = aws_s3_bucket.bucket.bucket == "test-bucket"
error_message = "S3 bucket name did not match expected"
}
}
Run blocks
Each run
block has the following fields and blocks:
Field or Block Name | Description | Default Value |
---|---|---|
command | An optional attribute, which is either apply or plan . | apply |
plan_options.mode | An optional attribute, which is either normal or refresh-only . | normal |
plan_options.refresh | An optional boolean attribute. | true |
plan_options.replace | An optional attribute containing a list of resource addresses referencing resources within the configuration under test. | |
plan_options.target | An optional attribute containing a list of resource addresses referencing resources within the configuration under test. | |
variables | An optional variables block. | |
module | An optional module block. | |
providers | An optional providers attribute. | |
assert | Optional assert blocks. | |
expect_failures | An optional attribute. |
The command
attribute and plan_options
block tell Terraform which command and options to execute for each run block. The default operation, if you do not specify a command
attribute or the plan_options
block, is a normal Terraform apply operation.
The command
attribute states whether the operation should be a plan
or an apply
operation.
The plan_options
block allows test authors to customize the planning mode and options they would typically need to edit via command-line flags and options. We cover the -var
and -var-file
options in the Variables section.
Assertions
Terraform run block assertions are Custom Conditions, consisting of a condition and an error message.
At the conclusion of a Terraform test command execution, Terraform presents any failed assertions as part of a tests passed or failed status.
Assertion References
Assertions within tests can reference any existing named values that are available to other custom conditions within the main Terraform configuration.
Additionally, test assertions can directly reference outputs from current and previous run
blocks. Pulling from the previous example, this is a valid condition: condition = output.bucket_name == "test_bucket"
.
Variables
You can provide values for Input Variables within your configuration directly from your test files.
The test file syntax supports variables
blocks at both the root level and within run
blocks. Terraform passes all variable values from the test file into all run
blocks within the file. You can override variable values for a particular run
block with values provided directly within that run
block.
Adding to the test file from the example above:
# variable_precedence.tftest.hcl
variables {
bucket_prefix = "test"
}
run "uses_root_level_value" {
command = plan
assert {
condition = aws_s3_bucket.bucket.bucket == "test-bucket"
error_message = "S3 bucket name did not match expected"
}
}
run "overrides_root_level_value" {
command = plan
variables {
bucket_prefix = "other"
}
assert {
condition = aws_s3_bucket.bucket.bucket == "other-bucket"
error_message = "S3 bucket name did not match expected"
}
}
We've added a second run
block that specifies the bucket_prefix
variable value as other
, overriding the value test
that is provided by the test file and used during the first run
block.
Specify variables with the Command Line or definition files
In addition to specifying variable values via test files, the Terraform test
command also supports the other typical mechanisms for specifying variable values.
You can specify values for variables across all tests with the Command Line and with Variable Definition Files.
As with the main configuration direction, Terraform will automatically load any variables defined in the automatic variable files within a test directory. The automatic variable files are terraform.tfvars
, terraform.tfvars.json
, and any files that end with .auto.tfvars
or .auto.tfvars.json
.
Note: Variable values loaded from the automatic variable files within a test directory will only apply to tests also defined within the same test directory. Variables defined in all other ways will apply to all tests in a given test run.
This is particularly useful for using sensitive variables values and for configuring providers. Otherwise, testing files could directly expose those sensitive values.
Variable definition precedence
Variable Definition Precedence remains the same within tests, except for variable values that test files provide. The variables defined in test files take the highest precedence, overriding environment variables, variables files, or command-line input.
For tests defined in a test directory, any variable values defined in automatic variable files from the test directory will override values defined in automatic variable files from the main configuration directory.
Variable References
Variables you define within run
blocks can refer to outputs from modules executed in earlier run
blocks and variables defined at higher precedence levels.
For example, the following code block shows how a variable can refer to higher precedence variables and previous run blocks:
variables {
global_value = "some value"
}
run "run_block_one" {
variables {
local_value = var.global_value
}
# ...
# Some test assertions should go here.
# ...
}
run "run_block_two" {
variables {
local_value = run.run_block_one.output_one
}
# ...
# Some test assertions should go here.
# ...
}
Above, the local_value
in run_block_one
gets its value from the global_value
variable. This pattern is useful if you want to assign multiple variables the same value. You can specify a variable value once at the file level and then share it with different variables.
In comparison, local_value
in run_block_two
takes its value from the output value of output_one
from run_block_one
. This pattern is useful for passing values between run
blocks, particularly if run
blocks are executing different modules as detailed in the Modules section.
Providers
You can set or override the required providers within the main configuration from your testing files by using provider
and providers
blocks and attributes.
At the root level of a Terraform testing file, you can define provider
blocks as if Terraform were creating them within the main configuration. Terraform will then pass these provider blocks into its configuration as each run
block executes.
By default, each provider you specify is directly available within each run
block. You can customize the availability of providers within a given run
block by using a providers
attribute. The behavior and syntax for this block match the behavior of providers meta-argument.
If you do not provide provider configuration within a testing file, Terraform attempts to initialize any providers within its configuration using the provider's default settings. For example, any environment variables aimed at configuring providers are still available, and Terraform can use them to create default providers.
Below, we expand on our previous example to allow tests, instead of the configuration, to specify the region. In this example, we are going to test the following configuration file:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
variable "bucket_prefix" {
type = string
}
resource "aws_s3_bucket" "bucket" {
bucket = "${var.bucket_prefix}-bucket"
}
output "bucket_name" {
value = aws_s3_bucket.bucket.bucket
}
We can now define our provider
blocks within the following test file:
# customised_provider.tftest.hcl
provider "aws" {
region = "eu-central-1"
}
variables {
bucket_prefix = "test"
}
run "valid_string_concat" {
command = plan
assert {
condition = aws_s3_bucket.bucket.bucket == "test-bucket"
error_message = "S3 bucket name did not match expected"
}
}
We can also create a more complex example configuration, that makes use of multiple providers and aliases:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.secondary]
}
}
}
variable "bucket_prefix" {
default = "test"
type = string
}
resource "aws_s3_bucket" "primary_bucket" {
bucket = "${var.bucket_prefix}-primary"
}
resource "aws_s3_bucket" "secondary_bucket" {
provider = aws.secondary
bucket = "${var.bucket_prefix}-secondary"
}
Within our test file we can specify multiple providers:
# customised_providers.tftest.hcl
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "secondary"
region = "eu-central-1"
}
run "providers" {
command = plan
assert {
condition = aws_s3_bucket.primary_bucket.bucket == "test-primary"
error_message = "invalid value for primary S3 bucket"
}
assert {
condition = aws_s3_bucket.secondary_bucket.bucket == "test-secondary"
error_message = "invalid value for secondary S3 bucket"
}
}
It is also possible to define specific providers you want to use in specific run
blocks:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.secondary]
}
}
}
data "aws_region" "primary" {}
data "aws_region" "secondary" {
provider = aws.secondary
}
variable "bucket_prefix" {
default = "test"
type = string
}
resource "aws_s3_bucket" "primary_bucket" {
bucket = "${var.bucket_prefix}-${data.aws_region.primary.name}-primary"
}
resource "aws_s3_bucket" "secondary_bucket" {
provider = aws.secondary
bucket = "${var.bucket_prefix}-${data.aws_region.secondary.name}-secondary"
}
Our test file can pass in specific providers for each different run
block:
# customised_providers.tftest.hcl
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "secondary"
region = "eu-central-1"
}
provider "aws" {
alias = "tertiary"
region = "eu-west-2"
}
run "default_providers" {
command = plan
assert {
condition = aws_s3_bucket.primary_bucket.bucket == "test-us-east-1-primary"
error_message = "invalid value for primary S3 bucket"
}
assert {
condition = aws_s3_bucket.secondary_bucket.bucket == "test-eu-central-1-secondary"
error_message = "invalid value for secondary S3 bucket"
}
}
run "customised_providers" {
command = plan
providers = {
aws = aws
aws.secondary = aws.tertiary
}
assert {
condition = aws_s3_bucket.primary_bucket.bucket == "test-us-east-1-primary"
error_message = "invalid value for primary S3 bucket"
}
assert {
condition = aws_s3_bucket.secondary_bucket.bucket == "test-eu-west-2-secondary"
error_message = "invalid value for secondary S3 bucket"
}
}
Note: When running tests with
command = apply
, switching providers betweenrun
blocks can result in failed operations and tests because resources created by one provider definition will be unusable when modified by a second.
From Terraform v1.7.0, provider
blocks can also reference test file variables and run block outputs. This means the testing framework can retrieve credentials and other setup information from one provider and use this when initializing a second.
In the following example, the vault
provider is initialized first, and then used within a setup module to extract credentials for the aws
provider. For more information on setup modules, see Modules.
provider "vault" {
# ... vault configuration ...
}
provider "aws" {
region = "us-east-1"
# The `aws` provider can reference the outputs of the "vault_setup" run block.
access_key = run.vault_setup.aws_access_key
secret_key = run.vault_setup.aws_secret_key
}
run "vault_setup" {
module {
# This module should only include reference to the Vault provider. Terraform
# will automatically work out which providers to supply based on the module
# configuration. The tests will error if a run block requires access to a
# provider that references outputs from a run block that has not executed.
source = "./testing/vault-setup"
}
}
run "use_aws_provider" {
# This run block can then use both the `aws` and `vault` providers, as the
# previous run block provided all the data required for the `aws` provider.
}
Modules
You can modify the module that a given run
block executes.
By default, Terraform executes the given command against the configuration being tested for each run
block. Terraform tests the configuration within the directory you execute the terraform test
command from (or the directory you point to with the -chdir
argument). Each run
block also allows the user to change the targeted configuration using the module
block.
Unlike the traditional module
block, the module
block within test files only supports the source
attribute and the version
attribute. The remaining attributes that are typically supplied via the traditional module
block should be supplied by the alternate attributes and blocks within the run
block.
Note: Terraform test files only support local and registry modules within the
source
attribute.
All other blocks and attributes within the run
block are supported when executing an alternate module, with assert
blocks executing against values from the alternate module. This is discussed more in Modules State.
Two example use cases for the modules
block within a testing file are:
- A setup module that creates the infrastructure the main configuration requires for testing.
- A loading module to load and validate secondary infrastructure (such as data sources) that are not created directly by the main configuration being tested.
The following examples demonstrate both of these use cases.
First, we have a module that will create and load several files into an already created S3 bucket. This is the configuration we want to test.
# main.tf
variable "bucket" {
type = string
}
variable "files" {
type = map(string)
}
data "aws_s3_bucket" "bucket" {
bucket = var.bucket
}
resource "aws_s3_object" "object" {
for_each = var.files
bucket = data.aws_s3_bucket.bucket.id
key = each.key
source = each.value
etag = filemd5(each.value)
}
Second, we have a setup module that will create the S3 bucket, so it is available to the configuration under test.
# testing/setup/main.tf
variable "bucket" {
type = string
}
resource "aws_s3_bucket" "bucket" {
bucket = var.bucket
}
Third, we have a loading module, that will load the files in the s3 bucket. This is a fairly contrived example, as it is definitely possible just to validate the files directly when they are created in the module under test. It is, however, good for demonstrating the use case.
# testing/loader/main.tf
variable "bucket" {
type = string
}
data "aws_s3_objects" "objects" {
bucket = var.bucket
}
Finally, we have the test file itself which configures everything and calls out to the various helper modules we have created.
# file_count.tftest.hcl
variables {
bucket = "my_test_bucket"
files = {
"file-one.txt": "data/files/file_one.txt"
"file-two.txt": "data/files/file_two.txt"
}
}
provider "aws" {
region = "us-east-1"
}
run "setup" {
# Create the S3 bucket we will use later.
module {
source = "./testing/setup"
}
}
run "execute" {
# This is empty, we just run the configuration under test using all the default settings.
}
run "verify" {
# Load and count the objects created in the "execute" run block.
module {
source = "./testing/loader"
}
assert {
condition = length(data.aws_s3_objects.objects.keys) == 2
error_message = "created the wrong number of s3 objects"
}
}
Modules state
While Terraform executes a terraform test
command, Terraform maintains at least one, but possibly many, state files within memory for each test file.
There is always at least one state file that maintains the state of the main configuration under test. This state file is shared by all run
blocks that do not have a module
block specifying an alternate module to load.
Additionally, there is one state file per alternate module that Terraform loads. An alternate module state file is shared by all run
blocks that execute the given module.
The Terraform team is interested in any use cases requiring manual state management or the ability to execute different configurations against the same state within the test
command. If you have a use case, please file an issue and share it with us.
The following example uses comments to explain where the state files for each run
block originate. In the below example Terraform creates and manages a total of three state files. The first state file is for the main configuration under test, the second for the setup module, and the third for the loader module.
run "setup" {
# This run block references an alternate module and is the first run block
# to reference this particular alternate module. Therefore, Terraform creates
# and populates a new empty state file for this run block.
module {
source = "./testing/setup"
}
}
run "init" {
# This run block does not reference an alternate module, so it uses the main
# state file for the configuration under test. As this is the first run block
# to reference the main configuration, the previously empty state file now
# contains the resources created by this run block.
assert {
# In practice we'd do some interesting checks and tests here but the
# assertions aren't important for this example.
}
# ... more assertions ...
}
run "update_setup" {
# We've now re-referenced the setup module, so the state file that was created
# for the first "setup" run block will be reused. It will contain any
# resources that were created as part of the other run block before this run
# block executes and will be updated with any changes made by this run block
# after.
module {
source = "./testing/setup"
}
variables {
# In practice, we'd likely make some changes to the module compared to the
# first run block here. Otherwise, there would be no point recalling the
# module.
}
}
run "update" {
# As with the "init" run block, we are executing against the main configuration
# again. This means we'd load the main state file that was initially populated
# by the "init" run block, and any changes made by this "run" block will be
# carried forward to any future run blocks that execute against the main
# configuration.
# ... updated variables ...
# ... assertions ...
}
run "loader" {
# This run block is now referencing our second alternate module so will create
# our third and final state file. The other two state files are managing
# resources from the main configuration and resources from the setup module.
# We are getting a new state file for this run block as the loader module has
# not previously been referenced by any run blocks.
module {
source = "./testing/loader"
}
}
Modules Cleanup
At the conclusion of a test file, Terraform attempts to destroy every resource it created during the execution of that test file. When Terraform loads alternate modules, the order in which Terraform destroys those objects in is important. For example, in the first Modules example, Terraform could not destroy the resources created in the "setup" run
block before the objects created in the "execute" run
block, because the S3 bucket we created in the "setup" step can not be destroyed while it contains objects.
Terraform destroys resources in reverse run
block order. In the most recent example, there are three state files. One for the main state, one for the ./testing/loader
module, and one for the ./testing/setup
module. The ./testing/loader
state file would be destroyed first as it was referenced most recently by the last run block. The main state file would be destroyed second as it was referenced by the "update" run
block. The ./testing/setup
state file would then be destroyed last.
Note, that the first two run
blocks "setup" and "init", do nothing during the destroy operations as their state files are used by later run blocks and have already been destroyed.
If you use a single setup module as an alternate module, and it executes first, or you use no alternate modules, then the order of destruction does not affect you. Anything more complex may require careful consideration to make sure the destruction of resources can complete automatically.
Expecting failures
By default, if any Custom Conditions, including check
block assertions, fail during the execution of a Terraform test file then the overall command reports the test as a failure.
However, it is a common testing paradigm to want to test failure cases. Terraform supports the expect_failures
attribute for this use case.
In each run
block the expect_failures
attribute can provide a list of checkable objects (resources, data sources, check blocks, input variables, and outputs) that should fail their custom conditions. The test passes if the checkable objects you specify report an issue, and the test fails overall if they do not.
You can still write assertions alongside an expect_failures
block, but you should be mindful that all custom conditions, except check block assertions, halt the execution of Terraform. This still applies during test execution, so your assertions should only consider values that you are sure will be computed before the checkable object is due to fail. You can manage this using references, or the depends_on
meta-argument within your main configuration.
This also means that, with the exception of check
blocks, you can only reliably include a single checkable object. We support a list of checkable objects within the expect_failures
attribute purely for check
blocks.
A quick example below demonstrates testing the validation
block on an input variable. The configuration file accepts a single input variable that must be even number.
# main.tf
variable "input" {
type = number
validation {
condition = var.input % 2 == 0
error_message = "must be even number"
}
}
The test file contains two run blocks. One that validates that our custom condition passes on an even number and one that validates our custom condition fails on an odd number.
# input_validation.tftest.hcl
variables {
input = 0
}
run "zero" {
# The variable defined above is even, so we expect the validation to pass.
command = plan
}
run "one" {
# This time we set the variable is odd, so we expect the validation to fail.
command = plan
variables {
input = 1
}
expect_failures = [
var.input,
]
}
Note: Terraform only expects failures in the operation specified by the
command
attribute of therun
block.
Be careful when using expect_failures
in run
blocks with command = apply
. A run
block with command = apply
that expects a custom condition failure will fail overall if that custom condition fails during the plan.
This is logically consistent, as the run
block is expecting to be able to run an apply operation but can not because the plan failed. It is also potentially confusing, as you will see the failure in the diagnostics as the reason the test failed, even though that failure was marked as being expected.
There are instances when Terraform does not execute a custom condition during the planning stage, because that condition is relying on computed attributes that are only available after Terraform creates the referenced resource. In these cases, you could use an expect_failures
block alongside a command = apply
attribute and value. However, in most cases we recommend only using expect_failures
alongside command = plan
operations.
Note: Expected failures only apply to user-defined custom conditions.
Other kinds of failure besides the specified expected failures in the checkable object still result in the overall test failing. For example, a variable that expects a boolean value as input fails the surrounding test if Terraform provides the wrong kind of value, even if that variable is included in an expect_failures
attribute.
The expect_failures
attribute is included to allow authors to test their configuration and any logic defined within. A type mismatch, as in the previous example, is not something Terraform authors should have to worry about testing as Terraform itself will handle enforce type constraints. As such, you can only expect_failures
in custom conditions.