How do you know your Terraform code works? Manual apply in dev environment? That's slow and error-prone. Terratest lets you write Go tests that actually provision infrastructure, validate resources, and clean up automatically. In this lesson, you'll learn to test Terraform modules with confidence.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure Terratest
- Write Go tests for Terraform modules
- Test resource properties and outputs
- Test AWS infrastructure
- Integrate tests into CI/CD pipelines
- Understand test patterns and best practices
2. Why This Matters
Real-world scenario: A junior engineer changes a security group rule. Without testing, they apply directly to staging. Production has the same broken rule. With Terratest, a PR runs tests that verify the security group configuration before merging.
3. Core Concepts
Terratest Setup
# Install Go
# Ubuntu/Debian
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# Verify installation
go version
# Create test directory
mkdir terraform-tests
cd terraform-tests
go mod init terraform-tests
# Install Terratest
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/gruntwork-io/terratest/modules/random
# Directory structure
terraform-tests/
├── go.mod
├── go.sum
├── test/
│ ├── vpc_test.go
│ ├── ec2_test.go
│ └── s3_test.go
└── fixtures/
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── ec2/
│ └── main.tf
└── s3/
└── main.tf
Terratest setup
Basic Terratest Example
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/stretchr/testify/assert"
)
func TestVPC(t *testing.T) {
t.Parallel()
// Terraform options
terraformOptions := &terraform.Options{
// Path to Terraform code
TerraformDir: "../fixtures/vpc",
// Variables to pass
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
},
// Environment variables
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
},
}
// Clean up resources after test
defer terraform.Destroy(t, terraformOptions)
// Apply Terraform
terraform.InitAndApply(t, terraformOptions)
// Get outputs
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
// Validate VPC exists
vpc := aws.GetVpcById(t, vpcID, "us-east-1")
assert.Equal(t, "10.0.0.0/16", vpc.CidrBlock)
assert.Equal(t, "test", vpc.Tags["Environment"])
// Get subnet outputs
publicSubnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 3, len(publicSubnetIDs))
// Validate each subnet
for _, subnetID := range publicSubnetIDs {
subnet := aws.GetSubnetById(t, subnetID, "us-east-1")
assert.True(t, subnet.MapPublicIpOnLaunch)
}
}
Basic Terratest for VPC module
Testing EC2 Module
// test/ec2_test.go
package test
import (
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEC2Instance(t *testing.T) {
t.Parallel()
// Generate unique name for resources
instanceName := "test-instance-" + random.UniqueId()
terraformOptions := &terraform.Options{
TerraformDir: "../fixtures/ec2",
Vars: map[string]interface{}{
"instance_name": instanceName,
"instance_type": "t3.micro",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get instance ID from output
instanceID := terraform.Output(t, terraformOptions, "instance_id")
// Validate instance exists
instance := aws.GetEc2InstanceById(t, instanceID, "us-east-1")
assert.Equal(t, "t3.micro", instance.InstanceType)
assert.Equal(t, instanceName, getTagValue(instance.Tags, "Name"))
// Test HTTP endpoint if web server
publicIP := terraform.Output(t, terraformOptions, "public_ip")
if publicIP != "" {
// Wait for instance to be ready
time.Sleep(30 * time.Second)
// Test HTTP response
url := "http://" + publicIP
// Use HTTP helper to test endpoint
// assertHTTPResponse(t, url, 200)
}
}
func getTagValue(tags []*ec2.Tag, key string) string {
for _, tag := range tags {
if *tag.Key == key {
return *tag.Value
}
}
return ""
}
Testing EC2 module
Testing S3 Bucket
// test/s3_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/stretchr/testify/assert"
)
func TestS3Bucket(t *testing.T) {
t.Parallel()
bucketName := "test-bucket-" + random.UniqueId()
terraformOptions := &terraform.Options{
TerraformDir: "../fixtures/s3",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get bucket region
region := aws.GetAmazonS3BucketRegion(t, bucketName)
assert.Equal(t, "us-east-1", region)
// Check bucket exists
exists := aws.IsS3BucketExists(t, bucketName)
assert.True(t, exists)
// Check bucket versioning
versioning := aws.GetS3BucketVersioning(t, bucketName, region)
assert.Equal(t, "Enabled", versioning)
// Check bucket encryption
encryption := aws.GetS3BucketEncryption(t, bucketName, region)
assert.NotNil(t, encryption)
// Check bucket public access block
publicAccessBlock := aws.GetS3BucketPublicAccessBlock(t, bucketName, region)
assert.True(t, publicAccessBlock.BlockPublicAcls)
assert.True(t, publicAccessBlock.BlockPublicPolicy)
// Upload test file
testContent := []byte("Hello, S3!")
aws.UploadStringToS3(t, region, bucketName, "test.txt", string(testContent))
// Download and verify
downloadedContent := aws.DownloadStringFromS3(t, region, bucketName, "test.txt")
assert.Equal(t, string(testContent), downloadedContent)
}
Testing S3 bucket
Testing with HTTP Validation
// test/http_test.go
package test
import (
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/stretchr/testify/assert"
)
func TestWebServer(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../fixtures/webserver",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get load balancer DNS
albDNS := terraform.Output(t, terraformOptions, "alb_dns_name")
// Wait for ALB to be ready
time.Sleep(60 * time.Second)
// Test HTTP endpoint
url := "http://" + albDNS
statusCode, body := http_helper.HttpGet(t, url, 30, 5)
assert.Equal(t, 200, statusCode)
assert.Contains(t, body, "Hello from Web Server")
// Test health endpoint
healthURL := url + "/health"
statusCode, body = http_helper.HttpGet(t, healthURL, 30, 5)
assert.Equal(t, 200, statusCode)
assert.Contains(t, body, "healthy")
// Test that we can't access admin endpoint
adminURL := url + "/admin"
statusCode, _ = http_helper.HttpGet(t, adminURL, 30, 5)
assert.Equal(t, 403, statusCode)
}
func TestAPIEndpoint(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../fixtures/api",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
apiURL := terraform.Output(t, terraformOptions, "api_url")
// Test GET endpoint
url := apiURL + "/users"
statusCode, body := http_helper.HttpGet(t, url, 30, 5)
assert.Equal(t, 200, statusCode)
// Test POST endpoint
postBody := `{"name": "John Doe", "email": "john@example.com"}`
statusCode, body = http_helper.HttpPost(t, apiURL+"/users", postBody, nil, 30, 5)
assert.Equal(t, 201, statusCode)
// Validate response
assert.Contains(t, body, "John Doe")
}
Testing HTTP endpoints
4. Complete Project: CI/CD Integration
# .github/workflows/terraform-test.yml
name: Terraform Test
on:
pull_request:
paths:
- 'terraform/**'
- 'test/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.6.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: Cache Go modules
uses: actions/cache@v3
with:
path: |
~/.cache/go-mod
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run Terratest
run: |
cd test
go mod download
go test -v -timeout 30m -run TestVPC
go test -v -timeout 30m -run TestEC2
go test -v -timeout 30m -run TestS3
env:
AWS_DEFAULT_REGION: us-east-1
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
# Parallel test execution
test-parallel:
runs-on: ubuntu-latest
strategy:
matrix:
module: [vpc, ec2, s3, rds]
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Configure AWS
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: Test Module
run: |
cd test
go test -v -timeout 20m -run Test${{ matrix.module }}
test-report:
runs-on: ubuntu-latest
needs: [test, test-parallel]
if: always()
steps:
- name: Test Summary
run: |
echo "## Terraform Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Module | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| VPC | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "| EC2 | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
echo "| S3 | ✅ Passed |" >> $GITHUB_STEP_SUMMARY
CI/CD integration for Terratest
5. Common Errors & Solutions
6. Summary Checklist
7. Next Steps
Next lesson: Terraform Lesson 8.6: Terraform in CI/CD
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment