Testing Terraform with Terratest: A Practical Guide
Untested Infrastructure Is a Liability
You wouldn't ship application code without tests. So why are you shipping infrastructure without them? Every terraform apply on untested code is a gamble — and in production, the house always wins.
Terratest is a Go library that deploys real infrastructure, validates it, and tears it down. No mocks — real resources in a real cloud account. Here's how I test every module before it gets near production.
The Module Under Test
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = "${var.name_prefix}-${var.environment}-${var.bucket_name}"
tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.enable_versioning ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Test Fixture
# modules/s3-bucket/test/fixtures/basic/main.tf
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
default = "us-east-1"
}
variable "test_id" {
description = "Unique ID for test isolation"
type = string
}
module "s3_bucket" {
source = "../../../"
name_prefix = "test"
environment = "ci"
bucket_name = var.test_id
enable_versioning = true
tags = { Test = "true" }
}
output "bucket_id" {
value = module.s3_bucket.bucket_id
}
Every fixture takes a test_id. Terratest generates a unique ID per run so parallel tests never collide.
Writing the Integration Test
package test
import (
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestS3BucketCreation(t *testing.T) {
t.Parallel()
uniqueID := strings.ToLower(random.UniqueId())
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "./fixtures/basic",
Vars: map[string]interface{}{
"test_id": uniqueID,
"aws_region": "us-east-1",
},
})
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
bucketID := terraform.Output(t, opts, "bucket_id")
assert.Contains(t, bucketID, uniqueID)
// Validate actual AWS resource, not just Terraform output
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
}))
svc := s3.New(sess)
enc, err := svc.GetBucketEncryption(&s3.GetBucketEncryptionInput{
Bucket: aws.String(bucketID),
})
require.NoError(t, err)
assert.Equal(t, "aws:kms",
*enc.ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm)
acl, err := svc.GetPublicAccessBlock(&s3.GetPublicAccessBlockInput{
Bucket: aws.String(bucketID),
})
require.NoError(t, err)
assert.True(t, *acl.PublicAccessBlockConfiguration.BlockPublicAcls)
}
Key decisions: t.Parallel() for concurrency, defer Destroy for cleanup, and AWS SDK calls to validate what actually happened — not what Terraform thinks happened.
Unit Tests with Plan
Not every test needs real infrastructure. Plan-based tests give fast feedback.
func TestS3BucketPlanOnly(t *testing.T) {
t.Parallel()
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "./fixtures/basic",
Vars: map[string]interface{}{
"test_id": strings.ToLower(random.UniqueId()),
},
})
plan := terraform.InitAndPlanAndShowWithStruct(t, opts)
// Verify no destructive changes
for _, changes := range plan.ResourceChangesMap {
for _, c := range changes {
assert.NotContains(t, c.Change.Actions, "delete")
}
}
}
Plan tests run in seconds. Use them in pre-commit hooks. Save integration tests for CI.
Running Tests in CI
# .github/workflows/terraform-test.yml
name: Terraform Module Tests
on:
pull_request:
paths: ['modules/**']
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Validate All Modules
run: |
for dir in modules/*/; do
terraform -chdir="$dir" init -backend=false
terraform -chdir="$dir" validate
done
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-arn: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/ci-terratest
aws-region: us-east-1
- name: Run Terratest
run: go test -v -timeout 30m -count=1 ./...
working-directory: modules/s3-bucket/test
Testing Terraform Modules with the Built-in Test Framework
Terraform 1.6+ includes a native test framework. No Go required.
# modules/s3-bucket/tests/basic.tftest.hcl
variables {
name_prefix = "test"
environment = "ci"
bucket_name = "tftest-basic"
enable_versioning = true
tags = { Test = "true" }
}
run "creates_bucket_with_correct_tags" {
command = plan
assert {
condition = aws_s3_bucket.this.tags["Environment"] == "ci"
error_message = "Expected Environment tag to be 'ci'"
}
assert {
condition = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
error_message = "Expected ManagedBy tag to be 'terraform'"
}
}
run "versioning_is_enabled" {
command = plan
assert {
condition = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
error_message = "Versioning should be enabled"
}
}
run "public_access_is_blocked" {
command = plan
assert {
condition = aws_s3_bucket_public_access_block.this.block_public_acls == true
error_message = "Public ACLs should be blocked"
}
assert {
condition = aws_s3_bucket_public_access_block.this.restrict_public_buckets == true
error_message = "Public buckets should be restricted"
}
}
Run with:
terraform test
Native tests run in seconds and need no additional tooling. Use them for plan-level validations. Keep Terratest for integration tests that need AWS API verification.
Testing Patterns: What to Test and When
| Test Type | Tool | Speed | What It Validates | When to Run |
|---|---|---|---|---|
terraform validate | Terraform CLI | Seconds | Syntax, type errors | Pre-commit hooks |
terraform plan assertions | tftest.hcl | Seconds | Resource configuration | Pre-commit, CI |
| Plan-based Go tests | Terratest | Seconds | Complex plan logic | CI on every PR |
| Integration tests | Terratest | Minutes | Real cloud resources | CI on module changes |
| Contract tests | Terratest | Seconds | Module interface stability | CI on every PR |
Contract Tests: Protect Your Module's API
When other teams consume your module, changes to outputs are breaking changes. Test for that.
func TestModuleOutputsExist(t *testing.T) {
t.Parallel()
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "./fixtures/basic",
Vars: map[string]interface{}{
"test_id": strings.ToLower(random.UniqueId()),
},
})
plan := terraform.InitAndPlanAndShowWithStruct(t, opts)
// Verify expected outputs exist in the plan
expectedOutputs := []string{
"bucket_id",
"bucket_arn",
"bucket_domain_name",
}
for _, output := range expectedOutputs {
_, exists := plan.RawPlan.OutputChanges[output]
assert.True(t, exists, "Expected output '%s' to exist", output)
}
}
If someone removes or renames an output, the contract test fails before the change reaches consumers.
Testing with Mocks: AWS LocalStack
For faster integration tests without real AWS costs, use LocalStack:
func TestS3BucketWithLocalStack(t *testing.T) {
t.Parallel()
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "./fixtures/basic",
Vars: map[string]interface{}{
"test_id": strings.ToLower(random.UniqueId()),
},
EnvVars: map[string]string{
"AWS_ACCESS_KEY_ID": "test",
"AWS_SECRET_ACCESS_KEY": "test",
"AWS_DEFAULT_REGION": "us-east-1",
},
BackendConfig: map[string]interface{}{
"endpoint": "http://localhost:4566",
},
})
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
bucketID := terraform.Output(t, opts, "bucket_id")
assert.NotEmpty(t, bucketID)
}
LocalStack tests run in 10-20 seconds instead of 2-5 minutes. The tradeoff: some AWS behaviors differ from the real API. Use LocalStack for fast feedback loops and real AWS for final validation.
Test Organization for Large Module Libraries
modules/
├── s3-bucket/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── tests/
│ │ ├── basic.tftest.hcl # Native Terraform tests
│ │ ├── fixtures/
│ │ │ ├── basic/main.tf
│ │ │ └── with-replication/main.tf
│ │ ├── s3_test.go # Terratest integration tests
│ │ └── go.mod
│ └── README.md
├── vpc/
│ ├── ...
│ └── tests/
│ ├── basic.tftest.hcl
│ ├── fixtures/
│ └── vpc_test.go
└── Makefile
A Makefile to run everything:
.PHONY: test-all test-unit test-integration
test-all: test-unit test-integration
test-unit:
@for dir in modules/*/; do \
echo "=== Testing $$dir ==="; \
terraform -chdir="$$dir" init -backend=false; \
terraform -chdir="$$dir" validate; \
terraform -chdir="$$dir" test 2>/dev/null || true; \
done
test-integration:
@for dir in modules/*/test; do \
if [ -f "$$dir/go.mod" ]; then \
echo "=== Integration tests for $$dir ==="; \
cd "$$dir" && go test -v -timeout 30m -count=1 ./... && cd -; \
fi \
done
test-module:
terraform -chdir="modules/$(MODULE)" test
cd modules/$(MODULE)/test && go test -v -timeout 30m -count=1 ./...
Run make test-unit for fast feedback. Run make test-integration in CI. Run make test-module MODULE=s3-bucket when working on a specific module.
Cleanup Strategy for Test Resources
Test resources that survive their test run are orphaned infrastructure. You pay for them until someone notices.
// In every test: tag resources for cleanup
func TestVPCCreation(t *testing.T) {
opts := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "./fixtures/basic",
Vars: map[string]interface{}{
"test_id": strings.ToLower(random.UniqueId()),
"tags": map[string]string{
"Test": "true",
"CreatedAt": time.Now().UTC().Format(time.RFC3339),
"TestName": t.Name(),
},
},
})
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
}
Nightly cleanup Lambda:
import boto3
from datetime import datetime, timedelta, timezone
def handler(event, context):
ec2 = boto3.resource('ec2')
threshold = datetime.now(timezone.utc) - timedelta(hours=24)
instances = ec2.instances.filter(
Filters=[{'Name': 'tag:Test', 'Values': ['true']}]
)
for instance in instances:
created_tag = next(
(t['Value'] for t in instance.tags if t['Key'] == 'CreatedAt'),
None
)
if created_tag:
created_at = datetime.fromisoformat(created_tag)
if created_at < threshold:
print(f"Terminating orphaned test instance: {instance.id}")
instance.terminate()
Troubleshooting
Problem: Tests pass locally but fail in CI.
Fix: CI roles have narrower permissions. Run aws sts get-caller-identity in both environments and compare. Add missing permissions to the CI role.
Problem: Parallel tests fail with naming collisions.
Fix: Every resource name must include the unique test ID. Hardcoded names are the bug. Grep your fixtures for any resource name that doesn't include var.test_id.
Problem: Destroy fails, leaving orphaned resources.
Fix: Tag-based cleanup. A nightly Lambda deletes resources tagged Test = true older than 24 hours. Also check for resources with DeletionProtection enabled — Terratest can't destroy those without disabling protection first.
Problem: Tests take 20+ minutes. Fix: Split into plan-only unit tests (seconds) and deploy-based integration tests (minutes). Only run integration tests on module changes using path filters in CI.
Problem: Flaky tests due to AWS eventual consistency.
Fix: Use terraform.WithDefaultRetryableErrors() which retries on common AWS errors. For custom retry logic, use retry.DoWithRetry() from the Terratest library.
Conclusion
Testing infrastructure is not optional. Use native terraform test for fast plan-level validation, Terratest for integration tests that verify real cloud resources, and contract tests to protect module interfaces. Run unit tests on every commit, integration tests on module changes, and clean up test resources nightly. The cost of a test AWS account is a fraction of the cost of a production outage from an untested module change.
Related Articles
Platform Engineer
Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.
Related Articles
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.
Terraform CLI: Cheat Sheet
Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.
Terraform Module Design Patterns for Large Teams
Battle-tested Terraform module patterns for teams — from file structure to versioning to composition. If it's not in code, it doesn't exist.