Introduction
Infrastructure as Code (IaC) brings software engineering practices to infrastructure management, but testing remains an afterthought in many teams. Without proper testing, misconfigured infrastructure causes outages, security vulnerabilities, and costly re-provisioning. This guide covers practical approaches to testing Terraform configurations, cloud resources, and compliance policies using tools like Terratest, OPA, and tflint.
Unit Testing Terraform with Terratest
Terratest is a Go library for writing automated tests against infrastructure. For unit-level tests, validate Terraform outputs and resource configurations:
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{
TerraformDir: "../examples/vpc",
// Use mock variables for unit testing
Vars: map[string]interface{}{
"region": "us-east-1",
"vpc_cidr": "10.0.0.0/16",
"enable_nat_gateway": false,
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID, "VPC ID should not be empty")
assert.Contains(t, vpcID, "vpc-", "VPC ID should start with vpc-")
subnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Len(t, subnetIDs, 3, "Should have 3 public subnets")
}
Integration Testing Cloud Resources
Integration tests validate real cloud resources are configured correctly:
package test
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestSecurityGroupCompliance(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../examples/web-app",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
sgID := terraform.Output(t, terraformOptions, "web_sg_id")
// Create EC2 client
ec2Client := ec2.New(session.New(), &aws.Config{
Region: aws.String("us-east-1"),
})
// Describe security group rules
result, err := ec2Client.DescribeSecurityGroupRules(&ec2.DescribeSecurityGroupRulesInput{
Filters: []*ec2.Filter{
{
Name: aws.String("group-id"),
Values: []*string{aws.String(sgID)},
},
},
})
assert.NoError(t, err)
// Verify no public ingress from 0.0.0.0/0 on port 22
for _, rule := range result.SecurityGroupRules {
if *rule.CidrIpv4 == "0.0.0.0/0" && *rule.FromPort == 22 {
t.Error("Found SSH open to the world - security violation!")
}
}
}
Compliance Testing with OPA and Sentinel
Open Policy Agent (OPA) enforces policies at plan time:
# policies/terraform/restrict_public_s3.rego
package terraform
import future.keywords.if
import future.keywords.in
default deny = false
deny if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket_public_access_block"
resource.change.after.block_public_acls == false
}
deny if {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not aws_s3_bucket_public_access_block_exists(resource.address)
}
aws_s3_bucket_public_access_block_exists(address) {
block := input.resource_changes[_]
block.type == "aws_s3_bucket_public_access_block"
startswith(block.address, address)
}
Run OPA in CI pipeline:
# Generate a plan JSON
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
# Evaluate policies
opa eval --data policies/ --input plan.json "data.terraform.deny"
Static Analysis with tflint and tfsec
Integrate static analysis into your pre-commit hooks and CI:
# .github/workflows/terraform-lint.yml
name: Terraform Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.0"
- name: Terraform fmt
run: terraform fmt -check -recursive
working-directory: terraform/
- name: tflint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: "v0.50.0"
- run: tflint --init && tflint --format compact
working-directory: terraform/
- name: tfsec
uses: aquasecurity/tfsec-action@v1
with:
working_directory: terraform/
format: sarif
Example `tflint` configuration:
# .tflint.hcl
plugin "aws" {
enabled = true
version = "0.26.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "aws_instance_invalid_type" {
enabled = false
}
rule "aws_resource_missing_tags" {
enabled = true
}
config {
module = true
force = false
}
Testing Terragrunt Configurations
For teams using Terragrunt, test the generated Terraform configurations:
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestTerragruntDevEnvironment(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../terragrunt/dev/us-east-1/vpc",
NoColor: true,
}
// Dry-run validation
stdout := terraform.InitAndPlan(t, opts)
assert.Contains(t, stdout, "Plan:", "Plan output expected")
assert.NotContains(t, stdout, "Error:", "No errors in plan")
}
Test Validation Pipeline
Combine all testing stages in a CI pipeline:
stages:
- validate # terraform validate
- lint # tflint, tfsec, fmt
- unit # Terratest unit tests (mock)
- compliance # OPA policy checks
- plan # terraform plan
- test # Terratest integration (real resources)
- deploy # terraform apply
Use environment variables to skip integration tests when not needed:
func TestSkipIfShort(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Integration test logic...
}
Best Practices
A comprehensive infrastructure testing strategy combines static analysis for fast feedback, unit tests for module validation, integration tests for real resource behavior, and policy-as-code for compliance. The upfront investment pays dividends when a test catches a misconfiguration before it reaches production.