What Is Terratest?
Terratest is a Go library for testing infrastructure code. It deploys real resources, validates they work, then destroys them:
Write test → terraform apply → validate → terraform destroy
Unlike terraform validate (syntax only) or tfsec (static analysis), Terratest proves your infrastructure actually works by deploying it.
Install Terratest
# Initialize Go module
mkdir test && cd test
go mod init github.com/myorg/myproject/test
# Add Terratest
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert
Your First Test
Terraform Module
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
output "bucket_name" {
value = aws_s3_bucket.this.id
}
Go Test
// test/s3_bucket_test.go
package test
import (
"fmt"
"strings"
"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 TestS3Bucket(t *testing.T) {
t.Parallel()
// Random name to avoid collisions
uniqueID := strings.ToLower(random.UniqueId())
bucketName := fmt.Sprintf("test-bucket-%s", uniqueID)
awsRegion := "us-east-1"
// Configure Terraform
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/s3-bucket",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"environment": "test",
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
})
// Destroy after test
defer terraform.Destroy(t, terraformOptions)
// Deploy
terraform.InitAndApply(t, terraformOptions)
// Validate outputs
outputBucketName := terraform.Output(t, terraformOptions, "bucket_name")
assert.Equal(t, bucketName, outputBucketName)
// Validate the bucket actually exists in AWS
aws.AssertS3BucketExists(t, awsRegion, bucketName)
}
Run
cd test
go test -v -timeout 30m
=== RUN TestS3Bucket
TestS3Bucket: s3_bucket_test.go:30: Running terraform init...
TestS3Bucket: s3_bucket_test.go:33: Running terraform apply...
TestS3Bucket: s3_bucket_test.go:38: bucket_name = "test-bucket-abc123"
TestS3Bucket: s3_bucket_test.go:42: Bucket test-bucket-abc123 exists
TestS3Bucket: s3_bucket_test.go:45: Running terraform destroy...
--- PASS: TestS3Bucket (120.5s)
Test Patterns
EC2 Instance Test
func TestEC2Instance(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/ec2",
Vars: map[string]interface{}{
"instance_type": "t3.micro",
"environment": "test",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get the instance ID
instanceID := terraform.Output(t, terraformOptions, "instance_id")
// Verify instance is running
instance := aws.GetInstancesByIdE(t, instanceID, "us-east-1")
assert.Equal(t, "running", instance)
// Verify we can SSH
publicIP := terraform.Output(t, terraformOptions, "public_ip")
ssh.CheckSshCommand(t, publicIP, "ubuntu", keyPair, "echo hello")
}
VPC Test
func TestVPC(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.99.0.0/16",
"environment": "test",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
publicSubnets := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
privateSubnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
// Validate subnet counts
assert.Equal(t, 2, len(publicSubnets))
assert.Equal(t, 2, len(privateSubnets))
// Validate VPC exists
vpc := aws.GetVpcById(t, vpcID, "us-east-1")
assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)
}
Terraform Built-in Tests (v1.6+)
Terraform 1.6 added native testing without Go:
# tests/s3_bucket.tftest.hcl
run "create_bucket" {
command = apply
variables {
bucket_name = "test-bucket-tftest"
environment = "test"
}
assert {
condition = output.bucket_name == "test-bucket-tftest"
error_message = "Bucket name doesn't match"
}
assert {
condition = output.bucket_arn != ""
error_message = "Bucket ARN should not be empty"
}
}
Run:
terraform test
When to Use Each
| Terratest (Go) | terraform test (native) | |
|---|---|---|
| Language | Go | HCL |
| Deploys real infra | Yes | Yes (or plan-only) |
| HTTP validation | Yes | No |
| SSH validation | Yes | No |
| Custom logic | Full Go stdlib | Limited to HCL |
| CI/CD integration | Standard Go testing | Built-in |
| Best for | Complex validation | Quick assertions |
CI/CD Integration
GitLab CI
test:
stage: test
image: golang:1.22
services:
- docker:dind
before_script:
- apt-get update && apt-get install -y unzip
- curl -LO https://releases.hashicorp.com/terraform/1.9.0/terraform_1.9.0_linux_amd64.zip
- unzip terraform_*.zip && mv terraform /usr/local/bin/
script:
- cd test
- go test -v -timeout 30m -count=1
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Best Practices for CI
- Use
t.Parallel()— run tests concurrently - Random names — avoid resource naming conflicts between parallel tests
- Set timeout —
go test -timeout 30m(default 10m is too short) - Always
defer Destroy— prevent resource leaks - Use
WithDefaultRetryableErrors— handles transient AWS errors
Test Organization
project/
├── modules/
│ ├── vpc/
│ ├── ec2/
│ └── rds/
├── test/
│ ├── go.mod
│ ├── go.sum
│ ├── vpc_test.go
│ ├── ec2_test.go
│ └── rds_test.go
└── tests/ # Native terraform test
└── basic.tftest.hcl
Hands-On Courses
Learn by doing with interactive courses on CopyPasteLearn:
Conclusion
Use Terratest for complex validation (HTTP checks, SSH, custom logic) and terraform test for quick assertions. Both deploy real infrastructure and destroy it after. Always use t.Parallel(), random resource names, and defer Destroy to keep tests fast and clean. Run tests in CI on every merge request to catch infrastructure bugs before they reach production.




