Terraform Testing Unleashed: From Examples to Assertions in Real-World Scenarios
Terraform’s test command, introduced in version 1.6.0, opens up the possibility to integrate testing directly within Terraform configurations. This feature allows module developers to verify functionality, ensure compatibility, and simulate different scenarios that the module might encounter.
Like in traditional software development, when writing a Unit Test you can have some code that executes before the test is executed. This is often called “Test Setup” or “Test Initialization” and it prepares the test runtime with the necessary in-memory state to execute your Unit Tests. In traditional programming, that means loading certain values into memory, creating mock objects that override very specific methods to avoid spending unnecessary time or adding unnecessary dependencies on external components.
However, in Terraform that means provisioning the pre-requisite resources that you need in order to execute your test. Just like in traditional programming, a lot of this depends on on what your module does and how you have designed its scope.
For example, if your module provisions a Virtual Machine, you would likely need to set up a Virtual Network with a subnet and some default settings to allow your virtual machine to be created.
Likewise, if your module provisions a Virtual Machine Extension, that expects to be attached to a Virtual Machine, then you would need to provision a Virtual Machine that you can attach the Virtual Machine Extension to.
With provider mocking introduced in Terraform 1.7.0, there’s even more flexibility in isolating parts of the infrastructure for efficient testing. This guide will walk through setting up tests, using examples, and creating a structure that maximizes the benefits of Terraform’s testing tools.
Start from an Example
Examples are essential when developing Terraform modules, both for user guidance and for testing. Good examples demonstrate how to configure your module and serve as the foundation for setting up test scenarios.
I have a directory that I setup to show end users how to use my modules. The directory is called examples
. In this directory, I create a folder for each distinct example. Each example is intended to be a deployable root module. Therefore it has input variables, and a provider block in it.
My examples have a common structure. I setup a Resource Group with a random name. For example, a basic setup would look like this:
resource "random_string" "suffix" {
length = 6
upper = false
special = false
}
resource "azurerm_resource_group" "main" {
name = "rg-${var.application_name}-${random_string.suffix.result}"
location = var.location
}
Then I reference the module using relative path.
module "vwan" {
source = "../../"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
name = "${var.application_name}-${random_string.suffix.result}"
primary_address_prefix = var.address_space
additional_regions = var.additional_regions
}
If the module repository is a single module repo then that relative path just points back to the root directory. Since I keep my examples directory flat, this relative path will always be ../../
since we need to go up exactly two directorys to get to the root directory of the repo from the sample scenario directory examples\scenarioXXX
. In your examples, keep the directory structure clear and concise, making it easy to reference relative paths and adjust configurations as needed.
Setting Up the Testing Folder Structure
In Terraform testing, each module’s prerequisites are defined in a setup configuration, usually under a folder structure like testing/setup
. This folder acts as the initial state setup, similar to test initialization in traditional unit testing frameworks. Unlike other languages where teardown is needed, Terraform handles cleanup automatically through terraform destroy. The setup typically involves specifying provider versions and any basic configurations required for your test module, such as:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 3.66.0"
}
}
}
This is where you define pre-requisite resources needed for your module to run. Now, because my example is designed to be a root module already, that doesn’t have any external dependencies, that means that the “Test Setup” will not have anything in it. Everything is already setup in my example. Therefore, in the setup I only need to specify the desired version of the provider.
I wonder if creating different “Test Setup” folders specifying different discrete versions of the azurerm
provider might be a good way to do provider version testing. I’ll have to explore that with one of my modules in the future.
Defining Test Scenarios
For each example in your module, you’ll create a dedicated *.tftest.hcl
file. This file defines a run block for each testing phase, where you specify the scenario, initialize top-level variables, and set up modules.
We’ll be using an existing example which allows the examples to be useful for both purposes: humans trying to understand how to use my module and tests verifying that my module works as expected.
My approch is to create a new *.tftest.hcl
file for each of the examples. Within each test scenario file I initialize the provider.
provider "azurerm" {
features {}
}
Then i initialize some top-level variables that I want the values to be the same across all steps in my test.
variables {
application_name = "ena-test"
location = "westus3"
}
Then I execute the “Test Setup” step:
run "setup" {
module {
source = "./testing/setup"
}
providers = {
azurerm = azurerm
}
}
Finally, I execute my sample by directly reference it in the examples
directory.
run "simple-vwan" {
module {
source = "./examples/vwan-simple"
}
variables {
address_space = "10.8.0.0/23"
additional_regions = {}
}
providers = {
azurerm = azurerm
}
assert {
condition = length(module.vwan.id) > 0
error_message = "Must have a valid V-WAN ID"
}
}
Each run
block configures the desired module to deploy, the providers to use, and input variable values for a specific scenario. Assertions validate module’s behavior, and you can reference module resources directly without needing excessive output variables.
You can have zero to many assert blocks. It’s important to note that the assert blocks can reference resources within the module you specify in the module block. This is a bit counter-intuitive as I would expect to only be able to access the module’s outputs–not the internal configuration of the module.
However, as you can see, I am referencing module.vwan
. This is not an output. This is just the module that I declare in my example. This is actually a good thing as it saves a lot of time for the developer by not requiring me to create a bunch of output boiler plate to get the values I need to evaluate in my test’s assertion logic.
Executing Tests
To execute tests, use the terraform test
command with the -filter
flag to specify which *.tftest.hcl
file to run and the -verbose
flag for detailed output.
For example:
terraform test -filter='tests\vwan-simple.tftest.hcl' -verbose
Each step in the process will be executed and after each step you will see the output of the Terraform plan. For example, when the “setup” stage gets executed I see that there is nothing to be provisioned.
tests\vwan-simple.tftest.hcl... in progress
run "setup"... pass
The state file is empty. No resources are represented.
This is as expected. That’s because my example module has no dependencies that need to be staged by the setup
module. However, things get interesting when we get to the next step of the process: “simple-vwan”.
The output contains all the details of the resources we provision during the apply.
The Terraform core workflow is executed essentially with -auto-approve
option and then once all testing stages are executed terraform destroy
is executed during “tear down”.
Conclusion
Testing Terraform modules can transform your development workflow by making your examples double as functional tests. This approach ensures that documentation and module testing are aligned, providing immediate feedback on both usability and correctness.
Although, I haven’t played with it yet, I’ll have to check out the provider mocking capabilities with the azurerm
provider to see what use cases are effective.
Until next time–Happy Azure Terraforming!