Introduction
Recently, I had the opportunity to use Terraform Test in my work, and I’d like to introduce it here.
Target Audience and Estimated Time
This blog is aimed at developers and DevOps engineers who are already familiar with Terraform and are looking for ways to improve their workflow with Terraform testing. It assumes a basic understanding of Terraform concepts and practices.
Estimated Reading Time: 10-15 minutes
What is Terraform Test?
Terraform Test is a feature introduced in Terraform v1.6.0.
It allows you to prepare test code and perform plan and apply operations at the module level.
You can also use assert statements to verify the values of the created resources.
By using this feature, you can properly test your modules and achieve the following:
- Prevent issues where plan succeeds but apply fails.
- Verify that conditional branching using dynamic or count behaves as expected.
Directory Structure
Let's assume you have the following Terraform repository structure:
.
├── README.md
├── env
│ ├── dev
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── variables.tf
│ ├── prod
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── variables.tf
│ └── stg
│ ├── backend.tf
│ ├── main.tf
│ └── variables.tf
│
└── modules
└── module
├── outputs.tf
├── s3.tf
├── tests
├── variables.tf
└── versions.tf
Terraform test code is created inside a dedicated tests directory within each module.
Inside this directory, you create .tftest.hcl
or .tftest.json
files — these are your test files.
In the next section, I’ll explain how to write and run Terraform tests using this structure, focusing mainly on .tftest.hcl
, since that's what I usually use.
Example Test Code
Suppose you have the following code in your s3.tf
and variables.tf
files:
// Create S3 bucket
resource "aws_s3_bucket" "storage" {
bucket = "${var.environment}-storage"
force_destroy = var.force_destroy_s3_bucket
}
// Set bucket policy to deny access
resource "aws_s3_bucket_policy" "deny_access_from_role" {
count = var.access_restriction_storage_bucket_config.enable_access_restriction ? 1 : 0
bucket = aws_s3_bucket.storage.id
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Deny",
Principal = "*",
Action = "s3:*",
Resource = [
aws_s3_bucket.storage.arn,
"${aws_s3_bucket.storage.arn}/*"
],
Condition = {
StringNotLike = {
"aws:userId" = concat([
for unique_id in var.access_restriction_storage_bucket_config.storage_access_role_unique_ids : "${unique_id}:*"
],
[
for user_id in var.access_restriction_storage_bucket_config.storage_access_user_unique_ids : user_id
]
)
}
}
}
]
})
}
variable "environment" {
type = string
}
variable "created_by" {
type = string
}
variable "force_destroy_s3_bucket" {
type = string
}
variable "access_restriction_storage_bucket_config" {
type = object({
enable_access_restriction = bool
storage_access_role_unique_ids = optional(list(string))
storage_access_user_unique_ids = optional(list(string))
})
validation {
condition = (
var.access_restriction_storage_bucket_config.enable_access_restriction == false ||
(
length(coalesce(var.access_restriction_storage_bucket_config.storage_access_role_unique_ids, [])) > 0 ||
length(coalesce(var.access_restriction_storage_bucket_config.storage_access_user_unique_ids, [])) > 0
)
)
error_message = "When enable_access_restriction is true, at least one of storage_access_role_unique_ids or storage_access_user_unique_ids must have one or more elements."
}
}
In this case, you would create a test like the following in tftest.hcl:
run "test_dryrun" {
command = plan
variables {
environment = "tftest"
force_destroy_s3_bucket = true
access_restriction_storage_bucket_config = {
enable_access_restriction = true
storage_access_role_unique_ids = [
"iam_role_id1",
"iam_role_id2",
"iam_role_id3"
]
storage_access_user_unique_ids = [
"iam_user_id1",
"iam_user_id2",
]
}
}
assert {
condition = length(aws_s3_bucket_policy.deny_access_from_role) == 1
error_message = "Bucket policy is not attached"
}
}
In Terraform Test, you specify the module variables within the run block and execute plan or apply.
The main things you set are:
- command: Specifies the operation to execute (plan or apply).
- variables: Variables defined in the module.
- assert: The conditions you want to verify.
In this case, we are using an assert to ensure that when enable_access_restriction is set to true, the S3 bucket policy is properly created.
One important note: values that are "known after apply" cannot be checked with assert after plan. In such cases, you need to execute apply.
Personally, I prefer to first plan, and then run apply for verification.
run "test" {
command = apply
variables {
environment = "tftest"
force_destroy_s3_bucket = true
access_restriction_storage_bucket_config = {
enable_access_restriction = true
storage_access_role_unique_ids = [
"iam_role_id1",
"iam_role_id2",
"iam_role_id3"
]
storage_access_user_unique_ids = [
"iam_user_id1",
"iam_user_id2",
]
}
}
assert {
condition = alltrue([
alltrue([
for id in var.access_restriction_storage_bucket_config.storage_access_role_unique_ids :
contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
"${id}:*"
)
]),
alltrue([
for id in var.access_restriction_storage_bucket_config.storage_access_user_unique_ids :
contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
id
) &&
!contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
"${id}:*"
)
])
])
error_message = "IAM Role IDs must end with ':*' and IAM User IDs must not."
}
}
You can also define global variables for the entire test using a variables block, and create temporary dependent resources inside the run block if needed.
For example, for IAM roles and users referenced in the bucket policy, you may want to create them temporarily during tests.
Here's how you can do it:
- Create the following files under
./tests/setup
(you can name setup as you like):
resource "aws_iam_role" "test_role1" {
name = "test_role1"
}
resource "aws_iam_role" "test_role2" {
name = "test_role2"
}
resource "aws_iam_user" "test_user1" {
name = "test_user1"
path = "/system/"
}
resource "aws_iam_user" "test_user2" {
name = "test_user2"
path = "/system/"
}
output "iam_role_ids" {
value = [
aws_iam_role.test_role1.unique_id,
aws_iam_role.test_role2.unique_id
]
}
output "iam_user_ids" {
value = [
aws_iam_user.test_user1.unique_id,
aws_iam_user.test_user2.unique_id
]
}
- prepare a run block for the dependency resources. Specify the directory you want to apply in the module.source field. In this case, specify the directory created in the previous step.
run "setup" {
command = apply
module {
source = "./tests/setup"
}
}
- After that, specify the unique_id of the IAM Role and IAM User created in setup in the variables for test.
run "test" {
command = apply
variables {
environment = "tftest"
force_destroy_s3_bucket = true
access_restriction_storage_bucket_config = {
enable_access_restriction = true
storage_access_role_unique_ids = run.setup.iam_role_ids
storage_access_user_unique_ids = run.setup.iam_user_ids
}
}
}
The final form looks like the following.
// global vars
variables {
environment = "tftest"
force_destroy_s3_bucket = true
}
// create dependency resources
run "setup" {
command = apply
module {
source = "./tests/setup"
}
}
// dryrun
run "test_dryrun" {
command = plan
variables {
access_restriction_storage_bucket_config = {
enable_access_restriction = true
storage_access_role_unique_ids = run.setup.iam_role_ids
storage_access_user_unique_ids = run.setup.iam_user_ids
}
}
assert {
condition = length(aws_s3_bucket_policy.deny_access_from_role) == 1
error_message = "Bucket policy is not attached"
}
}
run "test_dryrun" {
command = plan
variables {
access_restriction_storage_bucket_config = {
enable_access_restriction = false
}
}
assert {
condition = length(aws_s3_bucket_policy.deny_access_from_role) == 0
error_message = "Bucket policy is not attached"
}
}
// apply test
run "test" {
command = apply
variables {
access_restriction_storage_bucket_config = {
enable_access_restriction = true
storage_access_role_unique_ids = run.setup.iam_role_ids
storage_access_user_unique_ids = run.setup.iam_user_ids
}
}
assert {
condition = alltrue([
alltrue([
for id in var.access_restriction_storage_bucket_config.storage_access_role_unique_ids :
contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
"${id}:*"
)
]),
alltrue([
for id in var.access_restriction_storage_bucket_config.storage_access_user_unique_ids :
contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
id
) &&
!contains(
jsondecode(aws_s3_bucket_policy.deny_access_from_role[0].policy).Statement[0].Condition.StringNotLike["aws:userId"],
"${id}:*"
)
])
])
error_message = "IAM Role IDs must end with ':*' and IAM User IDs must not."
}
}
Notes and Tips
Here are some notes and tips based on what we noticed while operating terraform test.
Be careful with resources that take a long time to create when running in CI
When you specify command=apply
, the test will actually create resources.
Therefore, if you are dealing with resources that take a long time to create, such as OpenSearch, it can cause a single CI run to take over an hour to complete.
It’s a good idea to choose the command value carefully, considering development efficiency and CI costs.
If you abort during an apply test, the resources created by the test will remain
Resources created in an apply test are generally destroyed after the test finishes.
However, if you abort the test during execution (e.g., pressing CMD+C on a Mac), the resources may not get deleted and will remain in your actual cloud environment.
To avoid this, try not to abort the command and wait until it finishes.
You can't verify that resources created in production environments meet expectations
Due to the nature of terraform test, it cannot verify whether resources created in actual production environments meet expectations.
For example, it can't check if an ingress rule for a security group is set to 0.0.0.0/0
.
Keep in mind that terraform test is solely for testing modules, and not for validating resources deployed in production environments.
In practice, we use tools like Terraform Cloud OPA for such validations. I may cover this in another blog post in the future.
Use the verbose option when tests fail
You can run terraform test with the verbose option.
This lets you view execution logs and the results of the plan phase.
If you’re having trouble getting tests to pass, checking these logs can be very helpful.
Conclusion
That's all for this introduction to terraform test.
Since I often struggled with cases where the plan would succeed but the apply would fail, being able to prevent these issues beforehand has been a great improvement.
Also, being able to easily change parameters and check behavior when dealing with dynamic conditional branching has been very helpful.
I highly recommend trying out terraform test to improve the quality of your modules!