Terraform with Terratest
Introduction
Testing infrastructure code is just as important as testing application code. However, testing infrastructure presents unique challenges since it often involves creating real resources in cloud providers. Terratest is a Go library developed by Gruntwork that provides a powerful framework for testing Terraform code. It allows you to write automated tests that deploy your infrastructure, validate it works as expected, and then tear it down safely.
In this tutorial, we'll explore how to use Terratest to effectively test your Terraform configurations. We'll learn how to set up your testing environment, write basic and advanced tests, and integrate testing into your development workflow.
Prerequisites
Before getting started with Terratest, make sure you have:
- Basic knowledge of Terraform concepts
- Go installed on your machine (version 1.13+)
- Terraform installed (version 0.12+)
- AWS account (for examples) or another cloud provider
Understanding Terratest
Terratest is not a standalone application but a Go library that provides:
- Utility functions - Helper functions to work with Terraform, AWS, GCP, Kubernetes, etc.
- Testing patterns - Common testing patterns for infrastructure code
- Parallel execution - Ability to run tests in parallel to speed up execution
Here's how Terratest fits into your infrastructure testing workflow:
Setting Up Your Testing Environment
Let's start by setting up a simple project structure:
project/
├── main.tf # Your Terraform configuration
├── variables.tf # Terraform variables
├── outputs.tf # Terraform outputs
└── test/
└── terraform_test.go # Your Terratest test file
1. Install Go and set up a Go module
First, you need to initialize a Go module:
mkdir -p test
cd test
go mod init terraform-test
2. Install Terratest and required packages
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert
Your First Terratest Test
Let's create a simple Terraform configuration that provisions an AWS S3 bucket:
main.tf
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "test_bucket" {
bucket = var.bucket_name
tags = {
Name = var.bucket_name
Environment = "Test"
}
}
variables.tf
variable "aws_region" {
description = "The AWS region to deploy to"
default = "us-east-1"
}
variable "bucket_name" {
description = "The name of the S3 bucket"
type = string
}
outputs.tf
output "bucket_id" {
value = aws_s3_bucket.test_bucket.id
}
output "bucket_arn" {
value = aws_s3_bucket.test_bucket.arn
}
Now, let's write a Terratest test to verify this Terraform code. Create a file called terraform_test.go
in the test directory:
package test
import (
"fmt"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestTerraformAwsS3Example(t *testing.T) {
// Generate a unique bucket name to avoid conflicts
uniqueID := random.UniqueId()
bucketName := fmt.Sprintf("terratest-s3-example-%s", uniqueID)
// Construct the terraform options with default retryable errors
terraformOptions := &terraform.Options{
// The path to where our Terraform code is located
TerraformDir: "..",
// Variables to pass to our Terraform code using -var options
Vars: map[string]interface{}{
"bucket_name": bucketName,
},
}
// At the end of the test, run `terraform destroy` to clean up any resources that were created
defer terraform.Destroy(t, terraformOptions)
// Run `terraform init` and `terraform apply`
terraform.InitAndApply(t, terraformOptions)
// Get the bucket ID from the Terraform outputs
bucketID := terraform.Output(t, terraformOptions, "bucket_id")
// Verify that the bucket exists
assert.Equal(t, bucketName, bucketID)
}
Running Your Terratest Tests
To run your test, navigate to the test directory and execute:
go test -v
This will:
- Run
terraform init
andterraform apply
with your Terraform configuration - Create the actual S3 bucket in AWS
- Validate that the bucket ID matches what you expect
- Clean up by running
terraform destroy
Advanced Testing Techniques
1. Testing Infrastructure Properties
We can extend our tests to validate specific properties of the created infrastructure:
func TestS3BucketProperties(t *testing.T) {
// Setup code (same as before)...
// Get the bucket ARN from the Terraform outputs
bucketARN := terraform.Output(t, terraformOptions, "bucket_arn")
// Verify the bucket ARN format
assert.Contains(t, bucketARN, "arn:aws:s3:::")
assert.Contains(t, bucketARN, bucketName)
// You could also use the AWS SDK to check bucket properties
// (requires additional AWS SDK Go packages)
}
2. Testing Multiple Terraform Modules Together
Terratest allows you to test multiple modules working together:
func TestMultipleModules(t *testing.T) {
// For a VPC module
vpcOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_name": "terratest-vpc",
},
}
// For an EC2 module that depends on the VPC
ec2Options := &terraform.Options{
TerraformDir: "../modules/ec2",
Vars: map[string]interface{}{
"vpc_id": terraform.Output(t, vpcOptions, "vpc_id"),
},
}
// Clean up in reverse order
defer terraform.Destroy(t, ec2Options)
defer terraform.Destroy(t, vpcOptions)
// Deploy VPC first
terraform.InitAndApply(t, vpcOptions)
// Then deploy EC2 instances
terraform.InitAndApply(t, ec2Options)
// Run your assertions...
}
3. Testing HTTP Endpoints
If your infrastructure sets up web servers or APIs, you can test them:
func TestWebServer(t *testing.T) {
// Deploy infrastructure...
// Get the public URL output
url := terraform.Output(t, terraformOptions, "public_url")
// Make HTTP requests to verify the server works
http_helper.HttpGetWithRetry(t, url, nil, 200, "Hello, World!", 30, 5*time.Second)
}
4. Parallel Test Execution
To speed up testing, Terratest supports parallel execution:
func TestInParallel(t *testing.T) {
t.Parallel()
// Your test code here...
}
When running multiple tests with the -parallel
flag, Go will run them concurrently:
go test -v -parallel 4
Best Practices for Terratest
- Use unique identifiers: Always use random generators to create unique resource names to avoid conflicts in concurrent tests.
uniqueID := random.UniqueId()
bucketName := fmt.Sprintf("test-bucket-%s", uniqueID)
- Always clean up resources: Use
defer
statements to ensure resources are cleaned up even if the test fails.
defer terraform.Destroy(t, terraformOptions)
- Set short timeouts for dev/test: Configure shorter retry attempts during development to fail fast.
terraformOptions := &terraform.Options{
TerraformDir: "..",
MaxRetries: 3,
TimeBetweenRetries: 5 * time.Second,
}
-
Use test stages: Break your tests into setup, validation, and teardown stages.
-
Mock external services: For faster tests, consider mocking external services when possible.
Real-World Example: Testing an AWS Web Application
Let's create a more comprehensive example that tests a web application deployed on AWS:
Terraform Configuration (main.tf)
provider "aws" {
region = var.aws_region
}
module "vpc" {
source = "./modules/vpc"
vpc_cidr_block = var.vpc_cidr_block
}
module "web_server" {
source = "./modules/web_server"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnet_id
instance_type = var.instance_type
app_name = var.app_name
}
output "web_url" {
value = module.web_server.public_url
}
Terratest Test
package test
import (
"fmt"
"testing"
"time"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestWebApplication(t *testing.T) {
t.Parallel()
// Generate a unique app name
uniqueID := random.UniqueId()
appName := fmt.Sprintf("terratest-app-%s", uniqueID)
terraformOptions := &terraform.Options{
TerraformDir: "..",
Vars: map[string]interface{}{
"app_name": appName,
"instance_type": "t3.micro",
"vpc_cidr_block": "10.0.0.0/16",
},
}
// Clean up resources when the test completes
defer terraform.Destroy(t, terraformOptions)
// Deploy the infrastructure
terraform.InitAndApply(t, terraformOptions)
// Get the URL of the deployed web server
webURL := terraform.Output(t, terraformOptions, "web_url")
// Test that the web server returns a 200 status code and contains the expected text
maxRetries := 30
timeBetweenRetries := 10 * time.Second
http_helper.HttpGetWithRetry(
t,
webURL,
nil,
200,
"Welcome to my web application",
maxRetries,
timeBetweenRetries,
)
// Additional tests for specific application functionality
// For example, testing API endpoints
apiURL := fmt.Sprintf("%s/api/status", webURL)
statusCode, body := http_helper.HttpGet(t, apiURL, nil)
assert.Equal(t, 200, statusCode)
assert.Contains(t, body, "\"status\":\"ok\"")
}
Integrating Terratest into CI/CD Pipelines
For effective infrastructure testing, you should integrate Terratest into your CI/CD pipeline:
GitHub Actions Example
name: Terraform Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Install Terraform
uses: hashicorp/setup-terraform@v1
with:
terraform_version: 1.0.0
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
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 30m
Common Testing Patterns
1. Deployment Testing
Tests that infrastructure can be deployed without errors.
2. Functional Testing
Tests that infrastructure performs its intended function (e.g., serving web pages).
3. Integration Testing
Tests that multiple infrastructure components work together.
4. Security Testing
Tests that infrastructure meets security requirements:
func TestSecurityGroups(t *testing.T) {
// Deploy infrastructure...
// Get security group ID
sgID := terraform.Output(t, terraformOptions, "security_group_id")
// Use AWS SDK to verify no ports are exposed to 0.0.0.0/0 except 443
// This would require AWS SDK code
}
5. Performance Testing
Tests that infrastructure meets performance requirements:
func TestLoadBalancerPerformance(t *testing.T) {
// Deploy infrastructure...
// Get endpoint URL
url := terraform.Output(t, terraformOptions, "load_balancer_url")
// Make concurrent requests to verify response time
// This would require custom HTTP benchmarking code
}
Debugging Terratest
When your tests fail, here are some strategies to debug:
- Examine Terraform logs: Terratest stores Terraform output in
.test-data
directory - Add
t.Log
statements: Add detailed logging to your tests - Turn off auto-destroy: To inspect resources, use
SkipCleanupOnError
option:
terraformOptions := &terraform.Options{
TerraformDir: "..",
Vars: map[string]interface{}{
"bucket_name": bucketName,
},
// For debugging: skip destroy if test fails
NoDestroy: testing.Short(),
}
Then run tests with the -short
flag to skip cleanup on failures:
go test -v -short
Remember to clean up resources manually after debugging!
Summary
Terratest provides a powerful framework for testing Terraform code by deploying real infrastructure and validating it works as expected. In this tutorial, we covered:
- Setting up your Terratest environment
- Writing basic and advanced infrastructure tests
- Testing multiple modules together
- Validating infrastructure properties
- Integrating tests into CI/CD pipelines
- Common testing patterns and debugging strategies
By incorporating Terratest into your infrastructure development workflow, you can catch issues early, validate infrastructure functionality, and deploy with confidence.
Additional Resources
Exercises
-
Create a Terraform configuration that deploys a simple VPC with a public subnet, then write Terratest tests to verify the CIDR blocks and route tables.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)