TL;DR

  • Terratest tests actual infrastructure via cloud APIs—not Terraform state—catching bugs that native testing misses
  • The defer terraform.Destroy pattern guarantees cleanup even when tests fail, preventing orphaned resources
  • Test stages let you skip slow steps during local development (skip AMI build, reuse deployed infra)

Best for: Teams needing to validate real cloud behavior, not just configuration correctness Skip if: You only need to validate Terraform syntax/logic (use native terraform test instead) Read time: 12 minutes

Here’s a hard truth about infrastructure testing: Terraform’s native test framework validates state, not reality. If a provider bug creates a misconfigured resource but reports success, your tests pass while your infrastructure fails.

Terratest solves this by querying actual cloud resources through their native APIs. Your S3 bucket isn’t just “created” in Terraform state—Terratest verifies it actually exists, has the right encryption, and serves content correctly.

Prerequisites

Before starting, ensure you have:

  • Go 1.21+ installed (go version)
  • Terraform 1.0+ installed (terraform version)
  • AWS CLI configured with credentials (aws sts get-caller-identity)
  • Basic Go knowledge (functions, structs, error handling)
  • A dedicated AWS account or sandbox for testing

Step 1: Project Setup

Create a new directory structure for your Terraform module and tests:

mkdir -p terraform-s3-module/test
cd terraform-s3-module

Initialize the Go module:

cd test
go mod init github.com/yourorg/terraform-s3-module/test
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/stretchr/testify/assert

Your go.mod should now reference Terratest v0.53.0 or later.

Step 2: Create a Terraform Module to Test

Create main.tf in the root directory:

variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
}

variable "environment" {
  description = "Environment tag"
  type        = string
  default     = "test"
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = "Enabled"
  }
}

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

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

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

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

Step 3: Write Your First Terratest

Create test/s3_bucket_test.go:

package test

import (
	"fmt"
	"testing"

	"github.com/gruntwork-io/terratest/modules/aws"
	"github.com/gruntwork-io/terratest/modules/random"
	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

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

	// Generate unique bucket name to avoid conflicts
	uniqueID := random.UniqueId()
	bucketName := fmt.Sprintf("terratest-example-%s", uniqueID)
	awsRegion := "us-west-2"

	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../",
		Vars: map[string]interface{}{
			"bucket_name": bucketName,
			"environment": "test",
		},
		EnvVars: map[string]string{
			"AWS_DEFAULT_REGION": awsRegion,
		},
	})

	// CRITICAL: Always clean up resources
	defer terraform.Destroy(t, terraformOptions)

	// Deploy infrastructure
	terraform.InitAndApply(t, terraformOptions)

	// Get outputs
	bucketID := terraform.Output(t, terraformOptions, "bucket_id")

	// Validate ACTUAL infrastructure via AWS API
	aws.AssertS3BucketExists(t, awsRegion, bucketID)

	// Verify versioning is actually enabled (not just in state)
	versioning := aws.GetS3BucketVersioning(t, awsRegion, bucketID)
	assert.Equal(t, "Enabled", versioning)

	// Verify encryption configuration
	encryption := aws.GetS3BucketEncryption(t, awsRegion, bucketID)
	assert.Equal(t, "AES256", encryption)
}

Key patterns in this test:

  1. t.Parallel() — Runs tests concurrently for faster execution
  2. random.UniqueId() — Prevents resource name collisions
  3. defer terraform.Destroy() — Guarantees cleanup even on failure
  4. aws.AssertS3BucketExists() — Validates real AWS resources, not state

Step 4: Run the Test

Execute from the test directory:

go test -v -timeout 30m

Expected output:

=== RUN   TestS3BucketCreation
=== PAUSE TestS3BucketCreation
=== CONT  TestS3BucketCreation
TestS3BucketCreation 2026-01-11T10:15:30Z command.go:158: Running command terraform with args [init]
TestS3BucketCreation 2026-01-11T10:15:35Z command.go:158: Running command terraform with args [apply -auto-approve]
...
TestS3BucketCreation 2026-01-11T10:16:45Z command.go:158: Running command terraform with args [destroy -auto-approve]
--- PASS: TestS3BucketCreation (95.23s)
PASS

Step 5: Test Stages for Faster Iteration

Real-world tests are slow. Building AMIs, deploying clusters, and running validations can take 30+ minutes. Test stages let you skip completed stages during development:

package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	test_structure "github.com/gruntwork-io/terratest/modules/test-structure"
)

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

	workingDir := test_structure.CopyTerraformFolderToTemp(t, "../", ".")

	// Stage: Deploy
	defer test_structure.RunTestStage(t, "teardown", func() {
		terraformOptions := test_structure.LoadTerraformOptions(t, workingDir)
		terraform.Destroy(t, terraformOptions)
	})

	test_structure.RunTestStage(t, "deploy", func() {
		uniqueID := random.UniqueId()
		terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
			TerraformDir: workingDir,
			Vars: map[string]interface{}{
				"bucket_name": fmt.Sprintf("terratest-%s", uniqueID),
			},
		})
		test_structure.SaveTerraformOptions(t, workingDir, terraformOptions)
		terraform.InitAndApply(t, terraformOptions)
	})

	// Stage: Validate
	test_structure.RunTestStage(t, "validate", func() {
		terraformOptions := test_structure.LoadTerraformOptions(t, workingDir)
		bucketID := terraform.Output(t, terraformOptions, "bucket_id")
		aws.AssertS3BucketExists(t, "us-west-2", bucketID)
	})
}

Skip stages during development:

# Skip deploy, only run validation (assumes infra exists)
SKIP_deploy=true go test -v -run TestWithStages

# Skip teardown to keep resources for debugging
SKIP_teardown=true go test -v -run TestWithStages

AI-Assisted Approaches

In 2026, AI accelerates Terratest development significantly.

What AI does well:

  • Generating boilerplate test structure from Terraform modules
  • Writing assertions based on resource configurations
  • Suggesting edge cases (what if bucket name has special characters?)
  • Converting HCL outputs to Go struct mappings

What still needs humans:

  • Deciding what behaviors actually matter to test
  • Understanding blast radius if tests fail in production accounts
  • Architecting test isolation strategies
  • Reviewing AI-generated assertions for false positives

Useful prompt:

Given this Terraform module that creates an RDS instance:

resource "aws_db_instance" "main" {
  identifier           = var.db_identifier
  engine               = "postgres"
  engine_version       = "15.4"
  instance_class       = var.instance_class
  allocated_storage    = 20
  storage_encrypted    = true
  deletion_protection  = var.enable_deletion_protection
}

Generate a Terratest that:
1. Deploys the RDS instance with test variables
2. Validates encryption is actually enabled via AWS API
3. Verifies the correct Postgres version is running
4. Tests that deletion protection is set correctly
5. Uses proper cleanup with defer

Include error handling and meaningful assertion messages.

Decision Framework

When to Use Terratest

This approach works best when:

  • You need to validate actual cloud resource behavior
  • Testing interactions between multiple services (VPC + EC2 + RDS)
  • Your team has Go experience or willingness to learn
  • Integration tests are critical to your deployment pipeline
  • You’re testing Kubernetes, Docker, or Packer alongside Terraform

Consider alternatives when:

  • You only need to validate Terraform logic (use terraform test)
  • Fast feedback is critical and Go learning curve is too steep
  • Your infrastructure is simple (<5 resources)
  • Cost constraints limit ephemeral resource deployment

Terratest vs Terraform Native Test

AspectTerratestTerraform Test
What it testsActual infrastructure via APIsTerraform state
LanguageGoHCL
Learning curveHigherLower
Catches provider bugsYesNo
Multi-tool supportTerraform, K8s, Docker, PackerTerraform/OpenTofu only
SpeedSlower (real deploys)Faster (plan-based)

My recommendation: Use both. Terraform native tests for fast unit testing of module logic. Terratest for integration tests that validate real infrastructure before production deployments.

Measuring Success

MetricBaselineTargetHow to Track
Test coverage0%80%+ of critical modulesCount tested vs total modules
Test runtimeN/A< 30 min for full suiteCI pipeline metrics
Orphaned resourcesUnknown0AWS Cost Explorer tags
Flaky test rateHigh< 5%CI failure analysis
Mean time to detect issuesDaysHoursIncident tracking

Warning signs:

  • Tests pass but production deployments fail — tests aren’t comprehensive enough
  • Orphaned resources accumulating — cleanup not working properly
  • Tests take > 1 hour — need to parallelize or use test stages
  • High flakiness — missing retry logic or timing issues

Common Pitfalls

1. Missing defer Destroy

// BAD: If Apply fails, resources are orphaned
terraform.InitAndApply(t, options)
terraform.Destroy(t, options) // Never reached if Apply fails

// GOOD: Cleanup runs regardless of test outcome
defer terraform.Destroy(t, options)
terraform.InitAndApply(t, options)

2. Resource Name Collisions

// BAD: Will conflict with other tests or runs
bucketName := "my-test-bucket"

// GOOD: Unique per test run
uniqueID := random.UniqueId()
bucketName := fmt.Sprintf("test-%s", uniqueID)

3. Hardcoded Regions

// BAD: Fails if default region differs
aws.AssertS3BucketExists(t, "us-east-1", bucketID)

// GOOD: Consistent with Terraform deployment
awsRegion := terraform.Output(t, options, "region")
aws.AssertS3BucketExists(t, awsRegion, bucketID)

4. Insufficient Timeouts

# BAD: Default 10m timeout kills long-running tests
go test -v

# GOOD: Allow time for real infrastructure
go test -v -timeout 30m

What’s Next

Start with one critical module—your VPC, primary database, or main compute cluster. Write a single test that validates it actually works, not just deploys. Run it in CI before every merge.

Once comfortable, expand to test interactions: can your application actually connect to that database? Does the load balancer route traffic correctly?

The goal isn’t 100% coverage—it’s confidence that your infrastructure works in reality, not just in Terraform’s view of reality.


Related articles:

External resources: