TL;DR
- Terratest tests actual infrastructure via cloud APIs—not Terraform state—catching bugs that native testing misses
- The
defer terraform.Destroypattern 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 testinstead) 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:
t.Parallel()— Runs tests concurrently for faster executionrandom.UniqueId()— Prevents resource name collisionsdefer terraform.Destroy()— Guarantees cleanup even on failureaws.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
| Aspect | Terratest | Terraform Test |
|---|---|---|
| What it tests | Actual infrastructure via APIs | Terraform state |
| Language | Go | HCL |
| Learning curve | Higher | Lower |
| Catches provider bugs | Yes | No |
| Multi-tool support | Terraform, K8s, Docker, Packer | Terraform/OpenTofu only |
| Speed | Slower (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
| Metric | Baseline | Target | How to Track |
|---|---|---|---|
| Test coverage | 0% | 80%+ of critical modules | Count tested vs total modules |
| Test runtime | N/A | < 30 min for full suite | CI pipeline metrics |
| Orphaned resources | Unknown | 0 | AWS Cost Explorer tags |
| Flaky test rate | High | < 5% | CI failure analysis |
| Mean time to detect issues | Days | Hours | Incident 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:
- Pulumi Testing Best Practices
- Terraform Testing and Validation Strategies
- Test Automation Pyramid Strategy
- CloudFormation Template Testing
External resources: