Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 049 / 100

Testing Terraform: a Terraform & IaC guide for developers

Confidence before applying.

Expona Admin8 min read

Introduction

We've all felt that moment of dread when running terraform apply without knowing exactly what will happen. Testing Terraform: a Terraform & IaC guide for developers is how we build confidence that our infrastructure code does what we intend before touching production. Whether you're managing a few resources or thousands across multiple environments, testing transforms Terraform from a risky deployment tool into a reliable, verifiable system.

In this guide, we'll walk through the testing strategies that teams use in production: native Terraform testing, plan-only assertions, integration tests against real infrastructure, and Terratest for the most complex scenarios. Each approach solves a different problem, and together they form a safety net that catches bugs early and prevents silent failures.

terraform test — Native tests with run blocks and assertions

Terraform 1.6 introduced the terraform test command, bringing native testing directly into the language. This is a game-changer for teams that want to validate logic without leaving the Terraform ecosystem or learning a new language.

A Terraform test file uses the .tftest.hcl extension and contains run blocks that execute specific scenarios. Each run block can set up variables, apply configurations, and then assert that outputs or state values match expectations. Think of it as unit testing for your infrastructure code.

Here's a concrete example. Suppose we have a module that creates an S3 bucket with encryption and versioning:

hcl
resource "aws_s3_bucket" "data" {
  bucket = var.bucket_name
}

resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
  bucket = aws_s3_bucket.data.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = var.encryption_algorithm
    }
  }
}

output "bucket_arn" {
  value = aws_s3_bucket.data.arn
}

output "bucket_id" {
  value = aws_s3_bucket.data.id
}

Now we test it with a .tftest.hcl file:

hcl
run "bucket_created" {
  command = apply

  variables {
    bucket_name           = "test-bucket-${run.terraform.workspace}"
    encryption_algorithm  = "AES256"
  }

  assert {
    condition     = aws_s3_bucket.data.bucket_contains_bucket == false
    error_message = "Bucket name should not include 'bucket'"
  }

  assert {
    condition     = can(regex("^test-bucket", aws_s3_bucket.data.id))
    error_message = "Bucket ID should start with test-bucket prefix"
  }
}

run "encryption_enabled" {
  command = plan

  variables {
    bucket_name           = "test-bucket-${run.terraform.workspace}"
    encryption_algorithm  = "aws:kms"
  }

  assert {
    condition     = aws_s3_bucket_server_side_encryption_configuration.data.rule[0].apply_server_side_encryption_by_default[0].sse_algorithm == "aws:kms"
    error_message = "KMS encryption should be configured"
  }
}

Run terraform test and it executes both scenarios, reporting pass or fail. The beauty here is that tests live alongside your code, use the same syntax, and require no external tooling. Teams typically commit test files to version control and run them in CI/CD pipelines before merging.

Plan-only tests — Assert planned values without creating resources

Not every test needs to create real infrastructure. Many bugs are caught at the planning stage, where we inspect what would change without actually provisioning anything. Plan-only tests are faster, cheaper, and safer for validating logic.

The pattern is straightforward: set command = plan in your run block, then inspect planned values using the values object. This gives us instant feedback on whether variable defaults, computed values, and resource attributes will be set correctly.

Imagine a module that deploys a Lambda function with environment variables. We want to ensure that certain variables are always set, even if the caller provides partial input:

hcl
resource "aws_lambda_function" "api" {
  filename      = var.zip_file
  function_name = var.function_name
  role          = aws_iam_role.lambda.arn
  handler       = var.handler

  environment {
    variables = merge(
      var.environment_variables,
      {
        LOG_LEVEL = var.log_level
        SERVICE   = "api"
      }
    )
  }
}

variable "log_level" {
  type    = string
  default = "INFO"
}

variable "environment_variables" {
  type    = map(string)
  default = {}
}

A plan-only test validates that defaults are applied:

hcl
run "lambda_defaults" {
  command = plan

  variables {
    function_name       = "my-api"
    zip_file            = "function.zip"
    handler             = "index.handler"
    environment_variables = {
      DATABASE_URL = "postgres://localhost"
    }
  }

  assert {
    condition     = aws_lambda_function.api.environment[0].variables.LOG_LEVEL == "INFO"
    error_message = "LOG_LEVEL should default to INFO"
  }

  assert {
    condition     = aws_lambda_function.api.environment[0].variables.SERVICE == "api"
    error_message = "SERVICE should always be set to api"
  }

  assert {
    condition     = aws_lambda_function.api.environment[0].variables.DATABASE_URL == "postgres://localhost"
    error_message = "Custom environment variables should be merged"
  }
}

This test runs in milliseconds, doesn't create a Lambda function, and catches misconfigurations before they reach staging. Many teams run plan-only tests on every pull request, then reserve integration tests for merge validation.

Integration tests — Create real resources in a sandbox account, then destroy

Plan-level assertions find logic errors, but some bugs only surface when actual infrastructure is provisioned. Integration tests create real resources in a dedicated AWS account (or equivalent sandbox environment), verify their behavior, then clean up.

The advantage is confidence: we know the code will work in production because we've actually run it. The downside is cost and time. A full integration test suite might take minutes and cost a few dollars per run. That's why teams typically run integration tests less frequently—perhaps on merges to main, not on every commit.

Here's an integration test that provisions an RDS database, confirms it's accessible, and then tears it down:

hcl
run "rds_integration" {
  command = apply

  variables {
    db_identifier        = "test-db-${run.terraform.workspace}"
    db_name              = "testdb"
    master_username      = "admin"
    master_password      = "TestPass123!Safe"
    instance_class       = "db.t3.micro"
    allocated_storage    = 20
  }

  assert {
    condition     = aws_db_instance.main.engine == "postgres"
    error_message = "Database engine should be PostgreSQL"
  }

  assert {
    condition     = aws_db_instance.main.publicly_accessible == false
    error_message = "Database should not be publicly accessible"
  }

  assert {
    condition     = aws_db_instance.main.storage_encrypted == true
    error_message = "Database should have encryption enabled"
  }
}

run "rds_cleanup" {
  command = destroy

  variables {
    db_identifier        = "test-db-${run.terraform.workspace}"
    db_name              = "testdb"
    master_username      = "admin"
    master_password      = "TestPass123!Safe"
    instance_class       = "db.t3.micro"
    allocated_storage    = 20
  }
}

The destroy run ensures cleanup even if earlier assertions fail. In CI/CD, you'd set up a dedicated AWS account, assume a test role, run terraform test, then revoke access. This isolates test failures from production and prevents accidental resource leaks.

Terratest — Go-based tests for complex modules

As module complexity grows—especially when you need to validate cross-module interactions, test drift detection, or verify dynamic orchestration—Terraform's native test tooling has limits. That's where Terratest steps in.

Terratest is a Go library that orchestrates Terraform runs and then executes assertions in Go code. It's powerful because Go lets you SSH into instances, make HTTP requests, query databases, parse JSON outputs, and write sophisticated validation logic that would be unwieldy in pure Terraform.

A typical Terratest flow:

  1. Initialize Terraform with test variables.
  2. Run terraform plan and inspect the output.
  3. Run terraform apply to provision resources.
  4. Execute custom validation logic (SSH, HTTP, SQL queries, etc.).
  5. Validate outputs match expectations.
  6. Run terraform destroy and confirm cleanup.

Here's a simplified Terratest example in Go that tests an EC2 instance with a web server:

hcl
package test

import (
	"testing"
	"fmt"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/gruntwork-io/terratest/modules/http-helper"
)

func TestEC2WebServer(t *testing.T) {
	terraformOptions := &terraform.Options{
		TerraformDir: "../",
		Vars: map[string]interface{}{
			"instance_type": "t3.micro",
			"ami":           "ami-0c55b159cbfafe1f0",
		},
	}

	defer terraform.Destroy(t, terraformOptions)

	terraform.InitAndApply(t, terraformOptions)

	publicIP := terraform.Output(t, terraformOptions, "public_ip")

	http_helper.HttpGetWithRetry(
		t,
		fmt.Sprintf("http://%s", publicIP),
		nil,
		200,
		"Hello World",
		30,
		5 * time.Second,
	)
}

Terratest handles retries, timeouts, and cleanup. It's especially useful for infrastructure that depends on distributed systems, load balancers, or services that take time to start. Teams often write Terratest suites for critical modules and run them nightly or on-demand.

Common mistakes

  • Testing without isolation: Running integration tests against a shared account is a recipe for flaky tests and resource conflicts. Always use dedicated test accounts or namespaces.
  • Forgetting to clean up: A test that creates resources but doesn't destroy them will accumulate costs and clutter. Always include cleanup logic, even if a test fails midway.
  • Over-testing trivial defaults: Not every variable deserves an assertion. Focus on business logic, security settings, and complex conditionals. Trivial defaults rarely change.
  • Assuming plan == reality: A Terraform plan can succeed but apply fail due to API rate limits, permissions issues, or eventual consistency bugs. Integration tests catch these; plan-only tests don't.
  • Ignoring test performance: Slow tests get skipped. Keep unit tests (plan-only) under a second. Batch integration tests so they run in parallel. Use smaller instance types in test environments.

Key takeaways

  • Use terraform test with run blocks and assertions for fast, native validation of module logic and outputs.
  • Plan-only tests are your first defense, catching configuration errors before any resources are provisioned.
  • Integration tests in a sandbox account verify that code works in real AWS environments, but should run less frequently due to cost and time.
  • Terratest is the tool of choice for complex modules that need programmatic validation beyond Terraform assertions.
  • Always include cleanup (destroy) runs to prevent resource leaks and keep test costs under control.
  • Run tests in CI/CD on every pull request (unit tests) and on merge to main (integration tests).
  • Test failures are cheap feedback; production failures are expensive. Invest in testing infrastructure early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles