Going to AWS re:Invent 2024?

➡️ Book a meeting with Spacelift

Terraform

How to Test Terraform Code – Strategies & Tools

How to test your Terraform code

In this article, we will take a look at Terraform testing strategies using different types of commonly used testing, tools and platforms. Let’s jump straight in!

We will cover:

  1. Terraform test file syntax
  2. Terraform test command
  3. Terraform testing strategies

Terraform test file syntax

Terraform discovers test files inside your configuration by checking the following extensions:

  • .tftest.hcl
  • .tftest.json

Every test file is composed of root-level attributes and blocks as follows:

  • One or more run blocks
  • One or zero variables block
  • Zero to many provider blocks.

Terraform carries out run blocks sequentially, mimicking a sequence of Terraform commands being run in the configuration directory. The sequence in which the variables and provider blocks are arranged doesn’t matter, as Terraform evaluates all values within these blocks at the start of the testing process. For clarity, placing your variables and provider blocks at the start of the test file is advisable.

Terraform test file example

The run block command can be either plan or apply, if you omit to add it, the default value is a plan.

run "example_test" {
 command = plan

 // Define any variables required for your test
 variables {
   example_variable = "value"
 }

 // Assertions to validate the test outcome
 assert {
   condition     = example_resource.attribute == var.example_variable
   error_message = "Test failed: the attribute value was not as expected."
 }

 // Optionally, you can specify providers if your test requires it
 provider "aws" {
   region = "us-west-2"
 }
}

Inside the run block, you can have optional variables and assertions, and you can use the variables inside the assertions as you would use a variable inside any terraform configuration by prefixing it with var. Variables can also be defined globally (outside the run block), or just inside the run block for them to be accessed only by that particular run block.

Assertions in run blocks check a condition, and if that condition is failed, it will be presented at the end of the Terraform verification.

You can define how many providers you want and specify them as part of the run block or outside of it.

Terraform test command

The terraform test command is used to run all the test files defined in your configuration.

What does the Terraform test command do?

The terraform test command reads the Terraform test files defined in your configuration files by looking at all the files that have the .tftest.json and .tftest.hcl extensions and executes the tests defined in those files. By default, Terraform will search for these files in the current directory and also in the specified testing directory (by default, it is tests).

Terraform test command usage examples

The terraform test command can receive a couple of optional attributes:

  • -test-directory=<directory> – overrides the directory that Terraform looks into for test files
  • -json – displays JSON output for your testing results
  • -verbose – prints out the plan or state for each run block within a test file
  • -filter=testfile – will limit the test operation on specific testfiles only

Terraform test command examples

terraform test -filter=tests/my_test.tftest.hcl – this will test only the tests/my_test.tftest.hcl

terraform test -verbose – will print out the plan or state for each run block

terraform test -test-directory=my_tests – will run the tests from the current directory and my_tests directory

Terraform testing strategies

As with any code, you should implement some testing strategies to help validate your code and ensure that your changes do not cause any unexpected issues or break existing functionality.

Common Terraform testing strategies include:

  • Integration testing
  • Unit testing
  • End-to-end (E2E) testing
  • Linting
  • Compliance testing
  • Drift testing

In this article, we will take a look at each and how to use them with Terraform code.

Note: New versions of Terraform are placed under the BUSL license, but everything created before version 1.5.x stays open-source. OpenTofu is an open-source version of Terraform that expands on Terraform’s existing concepts and offerings. It is a viable alternative to HashiCorp’s Terraform, being forked from Terraform version 1.5.6.

Integration testing

Integration testing involves testing your entire infrastructure configuration, including any dependencies on other resources, to ensure that they work together correctly. Terraform has built-in dependency mapping and management that can be utilized to make sure the changes being made are as expected.

You can use tools like Terratest or Kitchen-Terraform for integration testing.

The following tools can also be used in the workflow, and including them in the CI pipeline will form Integration Testing:

  • Terraform fmt — to format the code correctly.
  • Terraform validate — to verify the syntax.
  • Terraform plan — to verify the config file will work as expected.
  • TFLint — to verify the contents of the configuration as well as the syntax and structure, also checks account limits (e.g. that a VM instance type is valid, and that the limit on the number of VMs in Azure has not been reached).
  • Kitchen-Terraform Kitchen-Terraform is an open-source tool that provides a framework for writing automated tests that validate the configuration and behavior of Terraform code, including testing for errors, resource creation, destruction, and validation of outputs. Kitchen-Terraform uses a combination of Ruby, Terraform, and Test Kitchen to spin up infrastructure in a sandbox environment, run tests, and destroy the infrastructure once testing is complete.
  • Terratest — Terratest is an open-source testing framework for testing Terraform that can also be used to test Kubernetes, Docker, and Packer, amongst others. Terratest enables automated testing of infrastructure code in a sandbox environment to validate that it meets requirements, functions as expected, and is reliable. Tests are written in Go, and it provides a rich set of testing functions that allow the user to automate the entire testing process, including provisioning resources, running tests, and cleaning up resources. Terratest also provides built-in support for popular testing frameworks like Ginkgo and Gomega.

How to use Terratest

In the Terratest example below, a function is created to test the creation of an Azure storage account in a resource group, and a container in the storage account, then verifies that the storage account and container exist. The resources are then cleaned up after the test is complete.

package test

import (
 "context"
 "fmt"
 "testing"
 "time"

 "github.com/Azure/azure-sdk-for-go/storage"
 "github.com/gruntwork-io/terratest/modules/azure"
 "github.com/gruntwork-io/terratest/modules/random"
 "github.com/gruntwork-io/terratest/modules/testing"
)

func TestAzureStorageAccount(t *testing.T) {
 t.Parallel()

 // Set the Azure subscription ID and resource group where the Storage Account will be created
 uniqueID := random.UniqueId()
 subscriptionID := "YOUR_AZURE_SUBSCRIPTION_ID"
 resourceGroupName := fmt.Sprintf("test-rg-%s", uniqueID)

 // Create the resource group
 azure.CreateResourceGroup(t, resourceGroupName, "uksouth")

 // Set the Storage Account name and create the account
 accountName := fmt.Sprintf("teststorage%s", uniqueID)
 accountType := storage.StandardZRS
 location := "uksouth"
 account := azure.CreateStorageAccount(t, subscriptionID, resourceGroupName, accountName, accountType, location)

 // Create a container in the Storage Account
 ctx := context.Background()
 client, err := storage.NewBasicClient(accountName, azure.GenerateAccountKey(t, accountName))
 if err != nil {
  t.Fatalf("Failed to create Storage Account client: %v", err)
 }
 service := client.GetBlobService()
 containerName := fmt.Sprintf("testcontainer%s", uniqueID)
 if _, err := service.CreateContainer(ctx, containerName, nil); err != nil {
  t.Fatalf("Failed to create container: %v", err)
 }

 // Check if the container exists
 testing.WaitForTerraform(ctx, terraformOptions, nil)

 // Cleanup resources
 defer azure.DeleteResourceGroup(t, resourceGroupName)
}

Unit testing

Unit testing involves testing individual modules or resources in isolation to ensure that they work as expected. This can again be done using tools like Terratest or using Terraform’s built-in testing functionality.

Unit testing with CDKTF

AWS CDK for Terraform (CDKTF) is a flexible open-source framework that provides a high-level object-oriented abstraction on top of Terraform to enable infrastructure-as-code development using popular programming languages such as TypeScriptPython, and Java. To unit test your CDKTF code, you can use a testing framework such as unittestJest or Mocha.

In the example below, CDKTF is used with Python to validate the creation of an AWS S3 bucket region, lifecycle rules, and access control policies, using the unittest framework. The stack is deployed using the app.synth() method, which generates the Terraform configuration and applies it to create the S3 bucket.

import unittest
from aws_cdk import App, Stack
from my_constructs import S3Bucket

class TestS3Bucket(unittest.TestCase):
    def test_creates_bucket_with_correct_configuration(self):
        app = App()
        stack = Stack(app, 'test-stack', env={'region': 'eu-west-2'})

        # Create the construct
        bucket_name = 'my-test-bucket'
        lifecycle_rules = [
            {'expiration': {'days': 90}, 'id': 'log-files', 'prefix': 'logs/'},
            {'expiration': {'days': 120}, 'id': 'backup-files', 'prefix': 'backup/'},
        ]
        access_control_policy = {
            'actions': ['s3:GetObject'],
            'effect': 'Allow',
            'principals': [{'service': 'lambda.amazonaws.com'}],
            'resources': [f'arn:aws:s3:::{bucket_name}/backup/*'],
        }
        S3Bucket(stack, 's3-bucket', bucket_name=bucket_name, lifecycle_rules=lifecycle_rules, access_control_policy=access_control_policy)

        # Deploy the stack
        synthed = app.synth()
        template = synthed.get_stack(stack.stack_name).template

        # Verify that the bucket was created with the correct configuration
        self.assertEqual(template['Resources'][bucket_name]['Type'], 'AWS::S3::Bucket')
        self.assertEqual(template['Resources'][bucket_name]['Properties']['BucketName'], bucket_name)
        self.assertEqual(template['Resources'][bucket_name]['Properties']['LifecycleConfiguration']['Rules'], lifecycle_rules)
        self.assertEqual(template['Resources'][bucket_name]['Properties']['PublicAccessBlockConfiguration'], {'BlockPublicAcls': True, 'BlockPublicPolicy': True, 'IgnorePublicAcls': True, 'RestrictPublicBuckets': True})
        self.assertEqual(template['Resources'][bucket_name]['Properties']['CorsConfiguration']['CorsRules'], [])
        self.assertEqual(template['Resources'][bucket_name]['Properties']['BucketEncryption']['ServerSideEncryptionConfiguration'], [])
        self.assertEqual(template['Resources'][bucket_name]['Properties']['AccessControl'], 'Private')
        self.assertEqual(template['Resources'][bucket_name]['Properties']['AnalyticsConfigurations'], [])
        self.assertEqual(template['Resources'][bucket_name]['Properties']['InventoryConfigurations'], [])
        self.assertEqual(template['Resources'][bucket_name]['Properties']['LoggingConfiguration'], {'DestinationBucketName': bucket_name, 'LogFilePrefix': 's3-logs/'})
        self.assertEqual(template['Resources'][bucket_name]['Properties']['ObjectLockConfiguration'], {'ObjectLockEnabled': 'Enabled'})
        self.assertEqual(template['Resources'][bucket_name]['Properties']['VersioningConfiguration'], {'Status': 'Enabled'})
        self.assertEqual(template['Resources'][bucket_name]['Properties']['WebsiteConfiguration'], {})

if __name__ == '__main__':
    unittest.main()

The next example shows a similar test using Typescript in the Jest framework. The code uses the Jest expect function to compare the actual and expected values of the resource properties.

import { App } from 'cdktf';
import { S3Bucket } from './s3-bucket';
import { TerraformStack } from 'cdktf/provider/terraform';

describe('S3Bucket', () => {
  it('creates bucket with correct configuration', () => {
    const app = new App();
    const stack = new TerraformStack(app, 'test-stack');
    const bucketName = 'my-test-bucket';
    const lifecycleRules = [
      { expiration: { days: 90 }, id: 'log-files', prefix: 'logs/' },
      { expiration: { days: 120 }, id: 'backup-files', prefix: 'backup/' },
    ];
    const accessControlPolicy = {
      actions: ['s3:GetObject'],
      effect: 'Allow',
      principals: [{ service: 'lambda.amazonaws.com' }],
      resources: [`arn:aws:s3:::${bucketName}/backup/*`],
    };
    new S3Bucket(stack, 's3-bucket', {
      bucketName,
      lifecycleRules,
      accessControlPolicy,
      region: 'eu-west-2',
    });

    const config = app.synth().getStackArtifact(stack.artifactId).template;
    expect(config.Resources[bucketName].Type).toEqual('AWS::S3::Bucket');
    expect(config.Resources[bucketName].Properties.BucketName).toEqual(bucketName);
    expect(config.Resources[bucketName].Properties.LifecycleConfiguration.Rules).toEqual(lifecycleRules);
    expect(config.Resources[bucketName].Properties.PublicAccessBlockConfiguration).toEqual({
      BlockPublicAcls: true,
      BlockPublicPolicy: true,
      IgnorePublicAcls: true,
      RestrictPublicBuckets: true,
    });
    expect(config.Resources[bucketName].Properties.CorsConfiguration.CorsRules).toEqual([]);
    expect(config.Resources[bucketName].Properties.BucketEncryption.ServerSideEncryptionConfiguration).toEqual([]);
    expect(config.Resources[bucketName].Properties.AccessControl).toEqual('Private');
    expect(config.Resources[bucketName].Properties.AnalyticsConfigurations).toEqual([]);
    expect(config.Resources[bucketName].Properties.InventoryConfigurations).toEqual([]);
    expect(config.Resources[bucketName].Properties.LoggingConfiguration).toEqual({
      DestinationBucketName: bucketName,
      LogFilePrefix: 's3-logs/',
    });
    expect(config.Resources[bucketName].Properties.ObjectLockConfiguration).toEqual({
      ObjectLockEnabled: 'Enabled',
    });
    expect(config.Resources[bucketName].Properties.VersioningConfiguration).toEqual({ Status: 'Enabled' });
    expect(config.Resources[bucketName].Properties.WebsiteConfiguration).toEqual({});
  });
});

End-to-End (E2E) testing

End-to-end testing involves testing your infrastructure configuration in a production-like environment to ensure that it works as expected in a real-world scenario. This can be done using tools like Terratest or manual testing in a staging environment.

Continuing with the Azure storage account example, let’s look at how we can use Terratest to implement end-to-end testing.

  1. Create a Terraform configuration file that defines an Azure Storage account and container:

main.tf

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "test" {
  name     = "test-resource-group"
  location = "uksouth"
}

resource "azurerm_storage_account" "test" {
  name                     = "teststorageaccount"
  resource_group_name      = azurerm_resource_group.test.name
  location                 = azurerm_resource_group.test.location
  account_tier             = "Standard"
  account_replication_type = "ZRS"

  tags = {
    environment = "test"
  }
}

resource "azurerm_storage_container" "test" {
  name                  = "testcontainer"
  storage_account_name  = azurerm_storage_account.test.name
  container_access_type = "private"
}
  1. Create a Terratest test file to deploy the Terraform configuration and validate the created Azure Storage account exists, along with testing that the container has the correct name and access type.

storage_test.go

package test

import (
 "context"
 "fmt"
 "os"
 "testing"

 "github.com/Azure/azure-sdk-for-go/profiles/latest/storage/mgmt/storage"
 "github.com/gruntwork-io/terratest/modules/azure"
 "github.com/gruntwork-io/terratest/modules/terraform"
 "github.com/stretchr/testify/assert"
)

func TestAzureStorageAccountAndContainer(t *testing.T) {
 t.Parallel()

 // Define the Terraform options with the desired variables
 terraformOptions := &terraform.Options{
  // The path to the Terraform code to be tested.
  TerraformDir: "../",

  // Variables to pass to our Terraform code using -var options
  Vars: map[string]interface{}{
   "environment": "test",
  },
 }

 // Deploy the Terraform code
 defer terraform.Destroy(t, terraformOptions)
 terraform.InitAndApply(t, terraformOptions)

 // Retrieve the Azure Storage account and container information using the Azure SDK
 storageAccountName := terraform.Output(t, terraformOptions, "storage_account_name")
 storageAccountGroupName := terraform.Output(t, terraformOptions, "resource_group_name")
 containerName := terraform.Output(t, terraformOptions, "container_name")

 // Authenticate using the environment variables or Azure CLI credentials
 azClient, err := azure.NewClientFromEnvironmentWithResource("https://storage.azure.com/")
 if err != nil {
  t.Fatal(err)
 }

 // Get the container properties
 container, err := azClient.GetBlobContainer(context.Background(), storageAccountGroupName, storageAccountName, containerName)
 if err != nil {
  t.Fatal(err)
 }

 // Check that the container was created with the expected configuration
 assert.Equal(t, "testcontainer", *container.Name)
 assert.Equal(t, "private", string(container.Properties.PublicAccess))

 fmt.Printf("Container %s was created successfully\n", containerName)
}
  1. Run the test by navigating to the directory where the go file is saved and run the following command.
go test -v

Contract testing

Contract testing checks if a configuration using a Terraform module gives properly formatted inputs. They verify the compatibility between the expected and actual inputs of a Terraform module, ensuring the module is used as intended by its author.

These tests utilize input validations, along with preconditions and postconditions, to check input combinations and errors, facilitating clear communication from the module creator to the users about the intended use of the module:

variable "instance_type" {
 type        = string
 description = "The instance type for the EC2 instance."

 validation {
   condition     = contains(["t2.micro", "t2.small", "t3.micro"], var.instance_type)
   error_message = "The instance type must be t2.micro, t2.small, or t3.micro."
 }
}

resource "aws_instance" "example" {
 instance_type = var.instance_type
}

In the above example, we ensure that an instance type can be only t2.micro, t2.small or t3.micro.

Mocks

Test mocking is a beta feature that lets you mock providers, resources and datasource for your tests, which lets you use terraform test without creating infrastructure or requiring credentials. They can be only used with terraform test and generate fake data for all the computed attributes.

Mock Providers

Mock Providers are used to create a dummy version of a provider, enabling the testing of Terraform configurations without actual cloud interactions. They can be used instead of a traditional provider block in your test, and they share the global namespace. Terraform won’t distinguish between a real and a mocked provider.

They will be defined with a “mock_provider” entry in your test files like so:

mock_provider "aws" {}

Note: Test mocking is available in Terraform v1.7.0 and later. This feature is in beta.

Overrides

Overrides can modify existing configurations for testing purposes, allowing for the alteration of resource attributes, data source attributes or even provider settings.

Each override block identifies the target—be it a resource, data source, or module—through a target attribute. For modules, an outputs attribute is used within override_module blocks to specify overrides. In contrast, override_resource and override_data blocks use a values attribute to define the overrides for resources and data sources, respectively.

Repeated blocks and nested attributes

Repeated blocks and nested attributes can also be tested with mocks to ensure that configurations with complex structures behave as expected, even when actual resources are not being manipulated.

For example, for every block inside of a resource that supports a dynamic block, you can build mock attributed that will mimic this behavior

Linting

Linting is very important in every programming language and even though HCL is a declarative language, linting can also ensure that your code is written correctly.

In the Terraform context, linting refers to the process of analyzing code to detect syntax errors, enforce style guidelines, and identify potential issues before doing an actual terraform plan. This step is crucial for maintaining code quality and consistency, and it enhances collaboration. Tools designed for Terraform, such as tflint, offer customizable rulesets that cater to different project needs and standards, facilitating the automation of code review processes and integration into CI pipelines.

  • Terraform fmt: This is a built-in Terraform command that should be the first port of call. The command formats your Terraform code based on a set of standard formatting rules.

Use terraform fmt -check and terraform validate to format and validate the correctness of your Terraform configuration.

  • TFLint: This is a popular open-source tool that checks for syntax errors, best practices, and code style consistency. Once installed, simply run it using the command:
tflint /path/to/terraform/code
pip install checkov
checkov -d /path/to/terraform/code

Checkov will identify security issues and provides recommendations for how to fix the issue, along with the location of the relevant code, such as publically accessible storage accounts.

  • Terrascan: This open-source tool performs static code analysis to identify security vulnerabilities and compliance violations in Terraform code. Example output is shown below for a publically accessible storage account:
=== [azure_storage_account] ===
Rules:
Risky network access configuration for storage account
Rule ID: AWS_3_2
Description: A storage account with unrestricted network access configuration may result in unauthorized access to the account and its data.
Severity: CRITICAL
Tags: [network,storage]
Status: FAILED
Resource:

Compliance Testing

terraform-compliance enables you to write a set of conditions in YAML files that must be met and test your code against them.

It can easily be installed using pip and run using the command shown below:

pip install terraform-compliance
terraform-compliance -p /path/to/policy/file.yaml -f /path/to/terraform/code

For example, the YAML file below specifies that Azure Storage Account should not be publicly accessible:

controls:
  - name: azure-storage-not-publicly-accessible
    description: Azure Storage Account should not be publicly accessible
    rules:
      - azure_storage_account:
          public_access_allowed: False

Drift Testing

Terraform will natively test for drift between your code and the real infrastructure when terraform plan is run. Terraform will compare the current state of your infrastructure to the state saved in the state file.

If there are any differences, Terraform will display an execution plan that shows how to update your infrastructure to match your configuration.

You can also make use of driftctl which is a free open-source tool that can report on infrastructure drift. Example output from the tool is shown below:

Found 11 resource(s) – 73% coverage
8 managed by terraform
2 not managed by terraform
1 found in terraform state but missing on the cloud provider

Periodic monitoring of the IaC-managed infrastructure to proactively check for drifts is a challenge. Drift detection provided by Spacelift helps with identifying and highlighting infrastructure drifts on time. This makes it easy to know what has changed and provides us with the direction to investigate. Configuring a drift monitor is as simple as configuring a cron job. Read more in the documentation.

Key points

By combining the testing strategies mentioned in this article and taking a holistic approach, you can ensure that your Terraform code is well-tested and reliable and that changes are deployed quickly and safely.

We encourage you also to explore how Spacelift makes it easy to work with Terraform. If you need any help managing your Terraform infrastructure, building more complex workflows based on Terraform, and managing AWS credentials per run, instead of using a static pair on your local machine, Spacelift is a fantastic tool for this.

Manage Terraform Better with Spacelift

Build more complex workflows based on Terraform using policy as code, programmatic configuration, context sharing, drift detection, resource visualization and many more.

Start free trial

The Practitioner’s Guide to Scaling Infrastructure as Code

Transform your IaC management to scale

securely, efficiently, and productively

into the future.

ebook global banner
Share your data and download the guide