Test Terraform Modules With Terratest

Testing with terratest

Several years ago, I wrote a blog post about how to test your AWS infrastructure using Test Kitchen and AWSpec. Many things have changed since then, like for instance, I haven’t used AWS since mid-2019, Hashicorp released their first Terraform’s stable release, and I completely forgot what Test Kitchen was in the first place (no, seriously; what was that all about?!).

Back then, testing framework options for IaC were fairly limited, in fact, I remember that it hadn’t been too long before writing that post that I had finally managed to say goodbye to Test Kitchen to test Ansible Roles in favour of Molecule, and then I discovered that the only way to test Terraform modules was to use - you guessed it - Test Flippin’ Kitchen.

Over the years, I've worked at various companies and constantly sought out new ways to create cloud infrastructure. After starting a new job at Microsoft, I realized that my old testing framework, Test Kitchen, was no longer cutting it. I needed something new and more efficient to ensure the quality and reliability of my infrastructure code.

That's when I stumbled upon Terratest, a Go library that provides patterns and helper functions for testing infrastructure. Developed by the engineers at Gruntwork.io, ”Terratest offers first-class support for Terraform, Packer, Docker, Kubernetes, AWS, GCP, and more.” I was immediately intrigued by its wide range of capabilities and decided to give it a try. After just a few tests, I was blown away by its speed and ease of use. Terratest has quickly become my go-to tool for testing infrastructure, and I couldn't be happier with the results.

As my team and I continue to create more Terraform modules at Microsoft, I've realized the importance of testing our infrastructure code to ensure its quality and reliability. In this post, I'll show you how to write simple yet effective Terratest files for your Terraform modules, using an Azure module as an example. Whether you're new to Terratest or a seasoned user, I hope you'll find this tutorial useful and informative. So let's dive in and start testing our Azure infrastructure code together!

Disclaimer: Terratest is a go library, which means that we’ll be writing some basic go code. If you don’t have enough experience with the language, I strongly recommend checking the Go Playground to get started with some basics of the language.

What We’re Building

For the sake of this post, we’re going to create something very simple and yet complex enough to allow us to write a few test cases for it. We’ll build a module that creates an Azure Virtual network and subnets.

NB: Since I’d like to spend most of the time breaking down the testing section, I’ll leave comments in the Terraform snippets before bits of code that might require some explaining.

Creating the Azure Virtual Network Module

Browse to your workspace and create a folder called terraform-module-azure-vnet. Once this is done, inside the new directory, create a file called versions.tf and add some basic version constraints:

terraform {
  required_version = ">= 1.4.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 3.40.0, <= 3.50.0"
    }
  }
}

With that out of the way, let’s create a main.tf file with the following code:

resource "azurerm_virtual_network" "this" {
 # Code Goes Here
}

That’s all the Terraform code you’ll get for the time being because now we go ahead and start to write some tests. That’s right, folks: this is a very shy attempt to apply TDD to IaC (what a mouthful).

Let’s create a new folder called tests and within it, let’s create a file called vnet_test.go. Let’s give this test file an initial structure:

package tests

import (
 "testing"
)

func TestVirtualNetwork(t *testing.T) {
 // Let's define all test cases down below
 t.Run(`JustVNet`, func(t *testing.T) {
  t.FailNow()
 })

 t.Run(`WithListOfSubnets`, func(t *testing.T) {
  t.FailNow()
 })

 t.Run(`WithVNetsLocationDifferentFromRGs`, func(t *testing.T) {
  t.FailNow()
 })
}

By doing this, we’re defining what our MVP and basic behaviour of this terraform module are going to be. Each of those t.Run() functions call a t.FailNow() as we currently have no code to test, therefore it is expected to make them fail should we run go test -v ./....

Great, now that the foundations are set, let’s move on and start to work on each one of those test cases.

Just Virtual Network

Let’s start to add the code to create an Azure Virtual Network in the main.tf file:

resource "azurerm_virtual_network" "this" {
  name = var.vnet_name
  // resource_group_name: as good practice, the RG will be created
  // in the root module and its information is passed in as a variable
  resource_group_name = var.resource_group.name
  // location: we check if `var.location` is passed into the module
  // if yes, we use it
  // if not, we default to the location the resource group is deployed in
  location      = var.location == null ? var.resource_group.location : var.location
  address_space = var.address_space
}

Next, we want to set up the outputs.tf files.

output "vnet_id" {
  value = azurerm_virtual_network.this.id
}

output "vnet_name" {
  value = azurerm_virtual_network.this.name
}

output "vnet_location" {
  value = azurerm_virtual_network.this.location
}

output "vnet_address_space" {
  value = azurerm_virtual_network.this.address_space
}

Here’s what the variable definitions in the variables.tf file look like:

variable "vnet_name" {
  type = string
}

variable "resource_group" {
  // This object will make sense once we have created
  // our root module in the `fixture` folder
  type = object({
    id       = string
    name     = string
    location = string
  })
}

variable "location" {
  type    = string
  default = null
}

variable "address_space" {
  type = list(string)
}

This is enough to satisfy the first test case, which is to create a simple Virtual Network without any subnet. Next, we want to create the root module to be used by our test. To do so, create a new folder called fixture:

mkdir tests/fixture

Inside this folder, we want to create the following files:

versions.tf:

terraform {
  required_version = "1.4.0"
}

provider "azurerm" {
  // the following parameters will be passed by the user in testing phase
  subscription_id = var.subscription_id
  tenant_id       = var.tenant_id
  features {}
}

main.tf:

resource "random_id" "this" {
  byte_length = 8
}

locals {
  // this will allow us to have a unique name to use when creating resources
  // it is very useful when testing infrastructure that relies on unique names
  // especially when running parallel tests
  resources_name = format("%s-%s", var.base_name, random_id.this.hex)
}

resource "azurerm_resource_group" "this" {
  name     = local.resources_name
  location = var.location
}

module "vnet" {
  source    = "../../"
  vnet_name = local.resources_name // using same name for both vnet and rg is totally fine
  // by passing the entire rg object,
  // we have access to the attributes we defined previously in the child module
  // (id, name, and location)
  resource_group = azurerm_resource_group.this
  address_space  = var.vnet_address_space
}

variables.tf:

variable "subscription_id" {}

variable "tenant_id" {}

variable "base_name" {
  type = string
  // I personally like to prefix all my resources with `terratest`
  // this helps me target them with clean up scripts that I run daily
  // (yes, it does happen that terratest fails and orphan resources are left behind)
  default = "terratest-vnet"
}

variable "vnet_address_space" {}

variable "location" {
  default = "northeurope"
}

outputs.tf:

//these outputs will be used to do our validations when running terratest
output "resource_group_name" {
  value = azurerm_resource_group.this.name
}

output "resource_group_location" {
  value = azurerm_resource_group.this.location
}

output "vnet_name" {
  value = module.vnet.vnet_name
}

output "vnet_location" {
  value = module.vnet.vnet_location
}

Lastly, we create one tfvars file for each use case; it’ll be clear why in a moment, but for now let’s create a file called just_vnet.tfvars:

address_space = ["10.0.0.0/24"]

This is exciting: the fixture folder is ready. It is time now to write our first test!

Let’s go back to our tests/vnet_test.go file and add some imports:

import (
 "os"
 "testing"

 "github.com/gruntwork-io/terratest/modules/terraform" // this is where the magic happens
 "github.com/stretchr/testify/require" // I personally like this library but any assert one would do too
)

Remember those subscription_id and tenant_id variables we declared in the module? It is time to fetch those values. This can be achieved in different ways, but the easiest one that comes to mind is to assign them to environment variables, respectively TEST_AZURE_SUBSCRIPTION_ID and TEST_AZURE_TENANT_ID. Once that’s done, we can create a helper function to ensure they are set correctly:

type config struct {
 subscriptionID string
 tenantID       string
}

func getTestConfig(t *testing.T) *config {
 t.Helper()
 config := config{
  subscriptionID: os.Getenv("TEST_AZURE_SUBSCRIPTION_ID"),
  tenantID:       os.Getenv("TEST_AZURE_TENANT_ID"),
 }
 if config.subscriptionID == "" || config.tenantID == "" {
  t.Fatal("AZURE_SUBSCRIPTION_ID and AZURE_TENANT_ID must be set")
 }

 return &config
}

Awesome. Now let’s go back to our test function, and add the following code:

// folder where we created the root module
// the path is relative to where the test file is created
var fixtureFolder = "./fixture"

func TestVirtualNetwork(t *testing.T) {
 // here we validate and fetch the config needed to run the test
 config := getTestConfig(t)

 t.Run(`JustVNet`, func(t *testing.T) {
  // set up our terraform options
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
   },
   VarFiles: []string{"just_vnet.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  rg_name := terraform.Output(t, terraformOptions, "resource_group_name")
  vnet_name := terraform.Output(t, terraformOptions, "vnet_name")
  vnet_location := terraform.Output(t, terraformOptions, "vnet_location")
  rg_location := terraform.Output(t, terraformOptions, "resource_group_location")

  require.Equal(t, vnet_location, rg_location)
  require.Equal(t, vnet_name, rg_name)
 })

 t.Run(`WithListOfSubnets`, func(t *testing.T) {
  t.FailNow()
 })

 t.Run(`WithVNetsLocationDifferentFromRGs`, func(t *testing.T) {
  t.FailNow()
 })
}

This test is very basic, but let me break it down a little bit:

  • we create a terraformOptions passing in a few important parameters, such as:
    • a map that represents additional variables for terraform (the equivalent of passing the -var flag to a terraform command).
    • a directory where our test root module is stored (./fixture in our case).
    • a slice of strings which represents all the tfvars files we want to use.
  • defer terraform.Destroy(t, terraformOptions) - if you’ve worked with go before, you know the importance of defer, if not this is our assurance to destroy the infrastructure created whenever the function exits, no matter it fails or not.
  • terraform.InitAndApply(t, terraformOptions) does exactly what you could imagine: runs terraform init && terraform apply using the terraformOptions we created before.
  • Then we have the assert section; here we run some very simple assertions against the output we defined in terraform. For the first case, we just ensure that the Resource Group and Virtual Network names, and location are correct.

Time to run this little one: first, ensure you’re logged to azure correctly (az login), and that you have access to the subscription and tenant you’re going to set; then, export the following environment variables:

export TEST_AZURE_SUBSCRIPTION_ID="<your_subscription_id>"
export TEST_AZURE_TENANT_ID="<your_tenant_id>"

Open your terminal and browse to the tests folder; once there, type go test ./... -v -count=1; Hopefully, you’re test will report the following:

=== RUN   TestVirtualNetwork/WithListOfSubnets
=== RUN   TestVirtualNetwork/WithVNetsLocationDifferentFromRGs
--- FAIL: TestVirtualNetwork (115.08s)
    --- PASS: TestVirtualNetwork/JustVNet (115.08s)
    --- FAIL: TestVirtualNetwork/WithListOfSubnets (0.00s)
    --- FAIL: TestVirtualNetwork/WithVNetsLocationDifferentFromRGs (0.00s)
FAIL
FAIL    github.com/darkraiden/terraform-module-azure-vnet/tests 115.490s

Don’t let the FAIL status fool you, that’s expected; the very first test case, TestVirtualNetwork/JustVNet, passed and that’s amazing. The others are failing because currently the only line they contain is t.FailNow(), remember?

Congratulations, you’ve run your very first terratest unit test. Now, let’s work on the remaining test cases to achieve a 100% test coverage.

Virtual Network With List of Subnets

The goal now is to give the use the ability to add some subnets alongside the virtual network. To do so, we first need to modify the Terraform module and add the resource and logic to do so.

Let’s define the data structure we’ll use to create the subnets in the variables.tf:

variable "subnets" {
 // I personally like to have strict types in terraform
 // it's just a nice way to always ensure the correct data type
 // is passed in to the module
  type = list(object({
    name             = string
    address_prefixes = list(string)
  }))
 // This is essential as we want the default behaviour to create
 // a virtual network without subnets, hence the empty list
  default = []
}

Next, let’s define the resource to create Azure subnets in the main.tf file:

resource "azurerm_subnet" "this" {
 // this might seem like an odd line if you're not familiar with terraform
 // but basically we're mutating the list of object into a map
 // this is due to the fact that for_each loops iterate over maps only
  for_each             = {for subnet in var.subnets : subnet.name => subnet}

  name                 = each.key
  resource_group_name  = var.resource_group.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = each.value.address_prefixes
}

Now that we have this new resource that creates n subnets, let’s output their IDs, from the outputs.tf, to allow us to run some basic assertions:

output "subnet_ids" {
 // here, unlike in the `azurerm_subnet` resource, we want to create a list
 // this `list(string)` will contain all the IDs of the subnets we'll create
 // if no subnets are created, it'll return an empty list `[]`
  value = [for subnet in azurerm_subnet.this : subnet.id]
}

This is great so far, but we’re not done yet. Now we need to modify the test root module in the tests/fixture folder; let’s follow the same order we just used and let’s start by adding the data structures modifying, respectively, tests/fixture/variables.tf, and then adding a file called tests/fixture/with_subnets.tfvars:

tests/fixture/variables.tf

variable "subnets" {
  default = []
}

tests/fixture/with_subnets.tfvars

vnet_address_space = ["10.0.0.0/24"]
subnets = [
  {
    name             = "subnet1"
    address_prefixes = ["10.0.0.0/25"]
  },
  {
    name             = "subnet2"
    address_prefixes = ["10.0.0.128/25"]
  }
]

Let’s now head over to the tests/fixture/main.tf file and modify the vnet module to make it look as follows:

module "vnet" {
  source    = "../../"
  vnet_name = local.resources_name // using same name for both vnet and rg is totally fine
  // by passing the entire rg object,
  // we have access to the attributes we defined previously in the child module
  // (id, name, and location)
  resource_group = azurerm_resource_group.this
  address_space  = var.vnet_address_space
  subnets        = var.subnets
}

We’re nearly there, let’s add now the relevant output in the tests/fixture/outputs.tf file:

output "subnet_ids" {
  value = module.vnet.subnet_ids
}

Fantastic. Last step: let’s open the tests/vnet_test.go file and modify the test function to make it look like this:

func TestVirtualNetwork(t *testing.T) {
 config := getTestConfig(t)

 t.Run(`JustVNet`, func(t *testing.T) {
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
   },
   VarFiles: []string{"just_vnet.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  rg_name := terraform.Output(t, terraformOptions, "resource_group_name")
  vnet_name := terraform.Output(t, terraformOptions, "vnet_name")
  vnet_location := terraform.Output(t, terraformOptions, "vnet_location")
  rg_location := terraform.Output(t, terraformOptions, "resource_group_location")

  require.Equal(t, vnet_location, rg_location)
  require.Equal(t, vnet_name, rg_name)
 })

 t.Run(`WithListOfSubnets`, func(t *testing.T) {
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
   },
   VarFiles: []string{"with_subnets.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  // remember we added that for loop in the subnet_ids output?
  // this how we convert that output into a go slice
  // similarly, the library offers a way to output maps, objects, list of objects, etc
  subnet_ids := terraform.OutputList(t, terraformOptions, "subnet_ids")
  require.GreaterOrEqual(t, len(subnet_ids), 2)
 })

 t.Run(`WithVNetsLocationDifferentFromRGs`, func(t *testing.T) {
  t.FailNow()
 })
}

Okay, are we ready? Time to run the test one more time and hopefully your output will look as follows:

=== RUN   TestVirtualNetwork/WithVNetsLocationDifferentFromRGs
--- FAIL: TestVirtualNetwork (225.81s)
    --- PASS: TestVirtualNetwork/JustVNet (92.63s)
    --- PASS: TestVirtualNetwork/WithListOfSubnets (133.18s)
    --- FAIL: TestVirtualNetwork/WithVNetsLocationDifferentFromRGs (0.00s)
FAIL
FAIL    github.com/darkraiden/terraform-module-azure-vnet/tests 226.103s

🙌 On we go to write the last test case!

Virtual Network With Location Different From Resource Group’s

This one should be relatively simple. To understand better what we’re trying to achieve, it’s worth revisiting some logic we added to the module:

 // location: we check if `var.location` is passed in to the module
  // if yes, we use it
  // if not, we default to the location the resource group is deployed in
  location      = var.location == null ? var.resource_group.location : var.location

In previous tests, we let the module assume that the value was null by not passing anything in from the root module. Let’s modify the fixture module slightly to allow the user to pass in a vnet-specific location. Let’s start by defining a new variable in the tests/fixture/variables.tf file:

variable "vnet_location" {
  type = string
  // defaulting once again to null to allow us to use
 // the location of the resource group
  default = null
}

Let’s modify the tests/fixture/main.tf file one more time; this is what the final version should look like

module "vnet" {
  source    = "../../"
  vnet_name = local.resources_name // using same name for both vnet and rg is totally fine
  // by passing the entire rg object,
  // we have access to the attributes we defined previously in the child module
  // (id, name, and location)
 resource_group = azurerm_resource_group.this
  location       = var.vnet_location
  address_space  = var.vnet_address_space
  subnets        = var.subnets
}

One last time: let’s modify the tests/vnet_test.go file to satisfy all test cases:

func TestVirtualNetwork(t *testing.T) {
 config := getTestConfig(t)

 t.Run(`JustVNet`, func(t *testing.T) {
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
   },
   VarFiles: []string{"just_vnet.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  rg_name := terraform.Output(t, terraformOptions, "resource_group_name")
  vnet_name := terraform.Output(t, terraformOptions, "vnet_name")
  vnet_location := terraform.Output(t, terraformOptions, "vnet_location")
  rg_location := terraform.Output(t, terraformOptions, "resource_group_location")

  require.Equal(t, vnet_location, rg_location)
  require.Equal(t, vnet_name, rg_name)
 })

 t.Run(`WithListOfSubnets`, func(t *testing.T) {
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
   },
   VarFiles: []string{"with_subnets.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  subnet_ids := terraform.OutputList(t, terraformOptions, "subnet_ids")
  require.GreaterOrEqual(t, len(subnet_ids), 2)
 })

 t.Run(`WithVNetsLocationDifferentFromRGs`, func(t *testing.T) {
  terraformOptions := &terraform.Options{
   TerraformDir: fixtureFolder,
   Vars: map[string]interface{}{
    "subscription_id": config.subscriptionID,
    "tenant_id":       config.tenantID,
    // we could simply create a new tfvars file
    // but given the small change, I think it's fine passing it as a cli variable
    "vnet_location": "westeurope",
   },
   VarFiles: []string{"just_vnet.tfvars"},
  }
  defer terraform.Destroy(t, terraformOptions)

  terraform.InitAndApply(t, terraformOptions)

  subnet_ids := terraform.OutputList(t, terraformOptions, "subnet_ids")
  rg_location := terraform.Output(t, terraformOptions, "resource_group_location")
  vnet_location := terraform.Output(t, terraformOptions, "vnet_location")

  // we could also just check whether the two locations are not equal
  // but this way we are sure that the vnet is in the right location
  // I like to be thourough (someone would argue this is redundant :D)
  require.Equal(t, vnet_location, "westeurope")
  require.Equal(t, rg_location, "northeurope")
  require.Equal(t, len(subnet_ids), 0)
 })
}

Time to run this test. If everything goes as expected, boom…

--- PASS: TestVirtualNetwork (515.96s)
    --- PASS: TestVirtualNetwork/JustVNet (157.58s)
    --- PASS: TestVirtualNetwork/WithListOfSubnets (192.25s)
    --- PASS: TestVirtualNetwork/WithVNetsLocationDifferentFromRGs (166.12s)
PASS
ok      github.com/darkraiden/terraform-module-azure-vnet/tests 516.269s

Congratulations, you successfully tested a Terraform module with terratest. Now, think about other scenarios or behaviour of this module and try to write some more tests yourself. Make sure you keep the terratest library documentation open as there are many more methods that can be used that we haven’t explored in this post.

Conclusions

  • As mentioned on their website, terratest can do much more than just testing Terraform modules. Consider checking out their documentation to see what they offer, but here are a few ideas of what else can be done with it:
    • End-to-End + Integration testing of your infrastructure;
    • Mock test Kubernetes Deployments;
    • Test helm charts;
    • And much more.
  • These tests we wrote, even if they create uniquely named Azure resources, aren’t designed to run in parallel due to the fact that terratest uses the same plan file by default. To go around this problem, I recommend looking into their test_structure.CopyTerraformFolderToTemp() feature. It does what the name implies: copies your fixture folder to your /tmp and gives it a unique name; this way, you can run as many parallel tests as you want.
  • terratest runs can fail and thus leave stale infrastructure behind that could result in unexpected spikes in your cloud bills ($$$). For this reason, I recommend setting up a nightly pipeline that targets all your test resources and cleans them up; this is why I suggested prefixing all your resources with terratest- :).
  • You certainly noticed we have wrote the same code quite a few times, for this reason I recommend writing your own terratest wrapper that includes the logic that most suit you. As a reference, you can check out the one I wrote over here.
Spread the love