Test Driven Terraform

An Effortless Guide to implementing Test-Driven Development (TDD) with Terraform for IaC.

The Coding Cube
5 min readDec 6, 2024

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:

  1. Terraform must already be installed on your system.
  2. 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.tffile.

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 as string, 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 and rds, each with specific attributes. The result 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 be tests.
  • Inside the tests directory, create a file main.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 the run block itself.
  • The assert block checks conditions and generates error messages if they are unmet.
  • To access the result output, we use another run block, as outputs cannot be accessed directly within a run block. This is why we create a primary run block called setup.
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:

--

--

No responses yet