Writing Good Terraform Tests
I have been exploring the relatively new world of native Terraform testing that arrived in Terraform 1.7. The approach is built around a fresh command, terraform test, which follows a workflow similar to our beloved terraform plan, terraform apply, and terraform destroy commands. Like those core commands, terraform test also requires you to run terraform init before anything else can happen. One minor caveat is that you must have at least one Terraform file (*.tf) in the root of your working directory for terraform test to function.
In this article, I’ll walk through how I set up my Terraform tests, highlighting what I learned along the way. We’ll look at how Terraform testing references source code, how we can “arrange” our environment, how we “act” by running the module under test, and how we “assert” that our code did what we wanted. Essentially, we follow the well-known testing pattern of Arrange, Act, and Assert.
Terraform Testing and Relative Paths
A big piece of the puzzle in native Terraform testing is understanding how Terraform references the codebase. By default, references to files and modules are relative to the root directory. This means, for instance, if you have multiple modules referencing one another, you don’t have to worry about complicated ../.. pathing. It’s a small relief from the manual path gymnastics we often deal with when referencing local modules.
When you’re working with the new testing command, be prepared to structure your files so that the root directory (the one containing at least one *.tf file) can serve as the base for all your references. This consistency is one of the pluses of the new native testing workflow.
Module Under Test: codebase-terraform-azure-fn-app
For context, the sub-module I set up tests for is part of a larger solution called the GitHub AT-AT. This sub-module provisions Terraform files that deploy an Azure Function App. It depends on a foundational module called azure-fn-core, which sets up the shared environment where the Function App will live.
I call this sub-module codebase-terraform-azure-fn-app, and it’s essentially one cog in a (continually growing) mechanism that automates infrastructure in Azure. Because it’s a small piece of a bigger puzzle, each test needs to verify that the sub-module not only works on its own but also aligns with the environment that azure-fn-core sets up.
Designing the Test: Arrange, Act, Assert
When writing any good unit test, there’s a proven pattern that I like to follow:
- Arrange (Setup)
- Act (Execution)
- Assert (Validation)
Terraform’s native testing mechanism encourages the same workflow. We create “runs” in our test structure (the same structure we get in the terraform test command), and each run can execute a chunk of Terraform code. Below, I’ll show how I’ve broken down my test runs.
Arrange
HashiCorp documentation often calls this phase Setup, but since we’re following a classic testing pattern, I chose to rename it Arrange. This phase is responsible for creating the necessary resources my test depends on. In my case, it’s about spinning up a GitHub repository that I can push code to.
run "arrange" {
module {
source = "./tests/setup"
}
}
Inside my ./tests/setup folder, I provision a GitHub repository using the github_repository resource. I also generate a random string to ensure the repository name is unique, preventing naming conflicts with my existing repos:
resource "random_string" "repository_name" {
length = 6
special = false
upper = false
}
data "github_user" "current" {
username = ""
}
resource "github_repository" "main" {
name = "atat-test-${random_string.repository_name.result}"
description = "Used by GitHub AT-AT Automated Tests"
visibility = "public"
delete_branch_on_merge = true
auto_init = true
}
resource "github_branch" "main" {
repository = github_repository.main.name
branch = "main"
}
resource "github_branch_default" "default" {
repository = github_repository.main.name
branch = github_branch.main.branch
}
By running terraform apply (under the test context), I get a fresh GitHub repository created on the fly. I add outputs to pass along to the rest of the test:
output "repository_name" {
value = github_repository.main.name
}
output "username" {
value = data.github_user.current.login
}
The repository_name is fed into the next phase to tell Terraform where to deploy code, and username is handy for building URLs to my new repo.
Act
Now we move into the Act phase, where we run the main module under test. In this scenario, that’s the codebase-terraform-azure-fn-app module, which drops the Terraform code for provisioning an Azure Function App into the newly-created GitHub repository.
run "act" {
module {
source = "./"
}
variables {
repository = run.arrange.repository_name
branch = "main"
path = "src"
primary_location = "westus3"
os_type = "Windows"
core_name = "atat-core"
commit_user = {
name = var.github_username
email = var.github_email
}
}
}
Within this module, I have a *.tfvars file holding sane defaults like the Function App’s OS type (Windows or Linux), the Azure region (primary_location), and the name of the Functions core environment (core_name). This module structure not only places the code in the GitHub repo but also ensures that the next time we run Terraform in that repo, it will create the Azure Function App itself.
Assert
Finally, we have Assert, which verifies that everything worked as intended. Here, I reference a final module that checks for the existence of a file in the new GitHub repository.
run "assert" {
module {
source = "./tests/final"
}
variables {
endpoint = "https://www.github.com/${run.arrange.username}/${run.arrange.repository_name}/blob/main/README.md"
}
assert {
condition = data.http.readme.status_code == 200
error_message = "GitHub with HTTP status ${data.http.readme.status_code}"
}
}
In the tests/final folder, I have a data.http resource that queries the endpoint (the URL to README.md in my brand-new repo). If the status code is 200, the test passes. If it’s not, Terraform will throw an error, letting me know something went wrong with my repository creation or file placement.
Conclusion
Terraform testing has taken a significant leap forward with the introduction of terraform test in version 1.7. By following the well-known Arrange, Act, Assert pattern, we can simplify how we structure our test runs. Using relative paths keeps things cleaner and helps avoid the dreaded ../../../../ references when building multi-module projects.
For my scenario, setting up a new GitHub repository before dropping code into it, then validating that code actually shows up, provides exactly the confidence I need. Now that testing is easier to integrate into a Terraform workflow, we can keep our infrastructure code robust, consistent, and well-documented.
Happy Azure Terraform Testing!