Test Driven Terraform
An Effortless Guide to implementing Test-Driven Development (TDD) with Terraform for IaC.
Terraform is one of the most versatile Infrastructure-as-Code (IaC) tools to manage and provision your cloud, on-premises, or virtualized infrastructures with code. So, thus, in managing and provisioning your cloud concepts like version control, testing, CI/CD, etc. can really ease your pain and fear in your DevOps journey.
Starting with version 1.6, Terraform introduced its own testing and mocking framework, making it easier to validate and streamline your configurations. So, without further ado, let’s dive into the code!
Setup Requirements:
- Terraform must already be installed on your system.
- In this example, I will include the AWS provider, but for simplicity, I will not add any AWS resources.
Tip: If you’ve already installed the providers or are setting them up for the first time, you can configure your terraform cli to avoid downloading the provider binaries again and again.
Project Setup
Create a directory, add a provider, provider.tf
and then initialize with terraform init
.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.46"
}
}
}
provider "aws" {
region = "us-east-1"
}
In you project directory create a variables.tf
file.
variable "create" {
type = bool
default = true
}
variable "mock_services" {
type = any
default = {}
}
In terraform, everything is defined using blocks. To declare a variable, you use a special variable
block. It has several arguments, including:
type
: Specifies the data type of the variable, such asstring
,bool
,map
,list
, etc.description
: Provides a description for the variable.default
: Sets a default value for the variable. If this is not provided, the variable becomes required.
Check out the full details here.
The main.tf
file contains all the logic and configuration for your cloud infrastructure. In this file, you can:
- Add resources using
resource
block provided by your provider (e.g., AWS, Azure, Google Cloud). - Use
local
blocks to access and transform variables for simplified and reusable configurations. - Define the overall structure and behavior of your Terraform deployment.
locals {
# create a map
services = {
s3 = {
service = "s3"
region = "us-east-1"
endpoint = "http://localhost:4566"
private_dns_enabled = true
}
rds = {
service = "rds"
region = "us-east-1"
endpoint = "http://localhost:4566"
private_dns_enabled = true
create = false
}
}
all_services = merge(local.services, var.mock_services) # var and local to reference corresponding variable
result = { for k, v in local.all_services : k => v if var.create && try(v.create, true) }
}
# add any number of resources here...
- The
merge()
method merges two maps into one. - The
try()
function checks each argument in order and returns the first one that doesn't cause an error. - We’ve defined two sets of services,
s3
andrds
, each with specific attributes. Theresult
variable will create an entry if the conditions are met.
create an outputs.tf
file that contains all output results:
output "result" {
value = local.result
}
Tests
- Now, In your project, create a directory named
tests
to store all test files. The directory name must betests
. - Inside the
tests
directory, create a filemain.tftest.hcl
. The file extension must be*.tftest.hcl
for terraform to recognize it as a test file. - Optionally, for unit tests, you can use a mock provider to simulate resources without affecting real infrastructure.
|-- main.tf
|-- outputs.tf
|-- provider.tf
|-- variables.tf
`-- tests
|-- main.tftest.hcl
`-- providers/mocks/aws
`-- data.tfmock.hcl
In the main.tftest.hcl
, I will define a mock provider named aws
with the alias mock_aws
. The mock_provider
block can include mock_data
and mock_resource
to mock specific resources and data blocks. Additionally, we have override blocks: override_resource
, override_data
, and override_module
. You can find more in details here.
If you define mock resources in a separate *.tfmock.hcl
file, the mock_provider
block will have a source
attribute where you specify the file path.
mock_provider "aws" {
alias = "mock_aws"
source = "./tests/providers/mocks/aws"
}
Next, we will create a variables
block to define a global set of variables that can be used throughout the main.tftest.hcl
file. The names of the variables should match those defined in the variables.tf
file.
variables {
mock_services = {
ec2 = {
service = "aws_instance"
subnet_id = "subnet-e45f10"
associate_public_ip_address = false
}
}
}
Here we have defined another service, ec2
in the mock_services
variable.
Now we can run unit test against the output block result
in the run
block:
run "validate_output" {
providers = {
aws = aws.mock_aws
}
variables {
mock_endpoints = var.mock_endpoints
}
assert {
condition = contains(keys(run.setup.result), "ec2")
error_message = "key not found"
}
}
- First, we reference the mock provider block. If this block is not provided, the
run
block will try to test conditions against real infrastructure. - Then we have a
variables
block which is a local variable block and it’s scope is inside of therun
block itself. - The
assert
block checks conditions and generates error messages if they are unmet. - To access the
result
output, we use anotherrun
block, as outputs cannot be accessed directly within arun
block. This is why we create a primaryrun
block calledsetup
.
run "setup" {
providers = {
aws = aws.mock_aws
}
}
Here, we don’t have any test cases. This setup is used solely to access all output variables defined in the outputs.tf
file. Now to access any of the output variable from run
block, we do like this:
run "another_test_block" {
...
variables {
result = run.setup.result
}
}
Here is the full test code for reference:
mock_provider "aws" {
source = "./tests/providers/mocks/aws"
alias = "mock_aws"
}
variables {
mock_services = {
ec2 = {
service = "aws_instance"
region = "us-east-1"
subnet_id = "subnet-e45f10"
associate_public_ip_address = false
}
}
}
run "setup" {
providers = {
aws = aws.mock_aws
}
}
run "validate_output" {
providers = {
aws = aws.mock_aws
}
variables {
mock_services = var.mock_services
}
assert {
condition = contains(keys(run.setup.result), "ec2")
error_message = "key not found"
}
assert {
condition = contains(keys(run.setup.result), "s3")
error_message = "key not found"
}
assert {
condition = !contains(keys(run.setup.result), "rds")
error_message = "key found"
}
}
Terraform’s own testing framework definitely enhances reliability and simplifies debugging, making your IaC workflow smoother and more enjoyable. Integrate test driven development with your workflow to ensure a stable and resilient cloud infrastructure.
Happy coding!
Reference Links: