Infrastructure Testing with Terratest
Write automated tests for your Terraform modules using Terratest. Learn unit testing, integration testing, and test patterns for reliable infrastructure.
Untested infrastructure is broken infrastructure waiting to happen. Terratest brings software engineering testing practices to Infrastructure as Code, allowing you to write automated tests that deploy real infrastructure and verify it works.
Why Test Infrastructure?
Testing Terraform code prevents:
- Syntax errors that
terraform validatemisses - Logical errors in module composition
- Regressions when updating modules
- Configuration drift from documentation
- “Works on my machine” deployments
Getting Started with Terratest
Terratest is a Go library for testing infrastructure code. You write tests in Go, and Terratest handles the heavy lifting.
Project Setup
terraform-modules/
├── modules/
│ └── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── test/
├── go.mod
├── go.sum
└── vpc_test.go
Initialize Go module:
cd test
go mod init github.com/myorg/terraform-modules/test
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert
Basic Test Structure
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
// Path to the Terraform module
TerraformDir: "../modules/vpc",
// Variables to pass to the module
Vars: map[string]interface{}{
"name": "test-vpc",
"cidr_block": "10.0.0.0/16",
"availability_zones": []string{"us-east-1a", "us-east-1b"},
},
// Environment variables
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
},
}
// Clean up resources after test
defer terraform.Destroy(t, terraformOptions)
// Deploy the infrastructure
terraform.InitAndApply(t, terraformOptions)
// Get outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
// Assertions
assert.NotEmpty(t, vpcId)
assert.Equal(t, 2, len(publicSubnetIds))
}
Run the test:
cd test
go test -v -timeout 30m ./...
Testing AWS Resources
Testing EC2 Instances
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestEc2Instance(t *testing.T) {
t.Parallel()
awsRegion := "us-east-1"
terraformOptions := &terraform.Options{
TerraformDir: "../modules/ec2",
Vars: map[string]interface{}{
"instance_type": "t3.micro",
"name": "test-instance",
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get instance ID from Terraform output
instanceId := terraform.Output(t, terraformOptions, "instance_id")
// Verify instance is running using AWS SDK
instance := aws.GetEc2InstanceById(t, instanceId, awsRegion)
assert.Equal(t, "running", instance.State.Name)
assert.Equal(t, "t3.micro", instance.InstanceType)
// Verify tags
expectedTags := map[string]string{
"Name": "test-instance",
"ManagedBy": "terraform",
}
actualTags := aws.GetTagsForEc2Instance(t, awsRegion, instanceId)
for key, expectedValue := range expectedTags {
assert.Equal(t, expectedValue, actualTags[key])
}
}
Testing S3 Buckets
func TestS3Bucket(t *testing.T) {
t.Parallel()
awsRegion := "us-east-1"
bucketName := fmt.Sprintf("test-bucket-%s", random.UniqueId())
terraformOptions := &terraform.Options{
TerraformDir: "../modules/s3",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"enable_versioning": true,
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Verify bucket exists
aws.AssertS3BucketExists(t, awsRegion, bucketName)
// Verify versioning is enabled
actualVersioning := aws.GetS3BucketVersioning(t, awsRegion, bucketName)
assert.Equal(t, "Enabled", actualVersioning)
// Verify bucket is encrypted
actualEncryption := aws.GetS3BucketEncryption(t, awsRegion, bucketName)
assert.NotEmpty(t, actualEncryption)
}
Testing Security Groups
func TestSecurityGroup(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../modules/security-group",
Vars: map[string]interface{}{
"name": "test-sg",
"vpc_id": "vpc-12345",
"allowed_ports": []int{80, 443},
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
sgId := terraform.Output(t, terraformOptions, "security_group_id")
sg := aws.GetEc2SecurityGroup(t, sgId, "us-east-1")
// Verify inbound rules
assert.Len(t, sg.IpPermissions, 2)
// Check port 80 rule exists
found80 := false
for _, rule := range sg.IpPermissions {
if *rule.FromPort == 80 && *rule.ToPort == 80 {
found80 = true
assert.Equal(t, "0.0.0.0/0", *rule.IpRanges[0].CidrIp)
}
}
assert.True(t, found80, "Port 80 rule not found")
}
HTTP Testing
Test that deployed services are actually reachable:
package test
import (
"testing"
"time"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestWebServer(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../modules/web-server",
Vars: map[string]interface{}{
"instance_type": "t3.micro",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get the public URL
publicUrl := terraform.Output(t, terraformOptions, "public_url")
// Verify the service is responding
maxRetries := 30
timeBetweenRetries := 10 * time.Second
http_helper.HttpGetWithRetry(
t,
publicUrl+"/health",
nil,
200,
"OK",
maxRetries,
timeBetweenRetries,
)
}
Test Fixtures
Create reusable test fixtures for complex setups:
// test/fixtures.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
)
type VpcFixture struct {
VpcId string
PublicSubnetIds []string
TerraformOptions *terraform.Options
}
func DeployVpcFixture(t *testing.T) *VpcFixture {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"name": "test-fixture",
"cidr_block": "10.0.0.0/16",
"availability_zones": []string{"us-east-1a", "us-east-1b"},
},
}
terraform.InitAndApply(t, terraformOptions)
return &VpcFixture{
VpcId: terraform.Output(t, terraformOptions, "vpc_id"),
PublicSubnetIds: terraform.OutputList(t, terraformOptions, "public_subnet_ids"),
TerraformOptions: terraformOptions,
}
}
func (f *VpcFixture) Destroy(t *testing.T) {
terraform.Destroy(t, f.TerraformOptions)
}
Use in tests:
func TestEksCluster(t *testing.T) {
t.Parallel()
// Deploy VPC first
vpc := DeployVpcFixture(t)
defer vpc.Destroy(t)
// Deploy EKS using the VPC
eksOptions := &terraform.Options{
TerraformDir: "../modules/eks",
Vars: map[string]interface{}{
"vpc_id": vpc.VpcId,
"subnet_ids": vpc.PublicSubnetIds,
"cluster_name": "test-cluster",
},
}
defer terraform.Destroy(t, eksOptions)
terraform.InitAndApply(t, eksOptions)
// Test EKS cluster...
}
Test Stages
For long-running tests, use stages to skip certain phases:
func TestCompleteInfrastructure(t *testing.T) {
t.Parallel()
workingDir := "../examples/complete"
terraformOptions := &terraform.Options{
TerraformDir: workingDir,
}
// Stage: Deploy
defer test_structure.RunTestStage(t, "cleanup", func() {
terraform.Destroy(t, terraformOptions)
})
test_structure.RunTestStage(t, "deploy", func() {
terraform.InitAndApply(t, terraformOptions)
})
// Stage: Validate
test_structure.RunTestStage(t, "validate", func() {
validateInfrastructure(t, terraformOptions)
})
// Stage: Redeploy (test idempotency)
test_structure.RunTestStage(t, "redeploy", func() {
terraform.ApplyAndIdempotent(t, terraformOptions)
})
}
Skip stages with environment variables:
# Skip cleanup for debugging
SKIP_cleanup=true go test -v -timeout 60m
# Only run validation (assumes deploy already done)
SKIP_deploy=true go test -v -timeout 10m
Testing Best Practices
Use Unique Names
import "github.com/gruntwork-io/terratest/modules/random"
func TestModule(t *testing.T) {
uniqueId := random.UniqueId()
terraformOptions := &terraform.Options{
Vars: map[string]interface{}{
"name": fmt.Sprintf("test-%s", uniqueId),
},
}
// ...
}
Parallel Testing
func TestVpc(t *testing.T) {
t.Parallel() // Always add this
// ...
}
func TestEks(t *testing.T) {
t.Parallel()
// ...
}
Timeouts
# Tests can be slow - increase timeout
go test -v -timeout 60m ./...
Environment Isolation
func TestModule(t *testing.T) {
// Use different region per test to avoid conflicts
awsRegion := aws.GetRandomRegion(t, nil, []string{"us-east-1", "us-west-2"})
terraformOptions := &terraform.Options{
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
}
}
CI/CD Integration
GitHub Actions
# .github/workflows/test.yml
name: Test Terraform Modules
on:
pull_request:
paths:
- 'modules/**'
- 'test/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Run tests
run: |
cd test
go test -v -timeout 60m ./...
Key Takeaways
- Test real infrastructure — mocks miss real-world issues
- Always clean up — use
defer terraform.Destroy() - Use unique names — prevents conflicts in parallel tests
- Test idempotency — run apply twice, verify no changes
- Test outputs — verify modules produce expected values
- HTTP testing — verify services actually respond
- Use stages for debugging long-running tests
“If you can’t test it, you can’t trust it. Infrastructure is no different from application code.”