Testing Infrastructure as Code
Testing infrastructure code prevents costly production failures, validates configurations before deployment, and enables confident changes. Comprehensive testing strategies combine static analysis, unit tests, integration tests, and compliance validation.
Why Test Infrastructure Code
Infrastructure failures disrupt services and impact customers. Testing catches issues before production deployment.
Cost of Failures
Infrastructure mistakes have serious consequences:
# Dangerous change without testing
resource "aws_security_group" "api" {
ingress {
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Opens all ports to internet!
}
}
Without testing, this security group exposes all ports publicly, creating major security vulnerability.
Confidence in Changes
Testing enables safe refactoring and improvements:
# Before refactoring
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
EOF
}
# After refactoring with modules
module "web_server" {
source = "./modules/web-server"
ami_id = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
install_nginx = true
}
Tests verify the refactored module behaves identically to the original configuration.
graph TD
A[Write Infrastructure Code] --> B[Run Tests]
B --> C{Tests Pass?}
C -->|No| D[Fix Issues]
D --> A
C -->|Yes| E[Deploy to Staging]
E --> F{Staging Validation}
F -->|Issues Found| D
F -->|Success| G[Deploy to Production]
style C fill:#fff4e6
style G fill:#d4f1d4
Static Analysis
Static analysis examines code without executing it, catching syntax errors, security issues, and best practice violations.
Syntax Validation
Verify code syntax correctness:
# Terraform syntax validation
terraform fmt -check
terraform validate
# Example output on success:
# Success! The configuration is valid.
# Example output on error:
# Error: Unsupported argument
# on main.tf line 15:
# instance_typ = "t3.medium"
# An argument named "instance_typ" is not expected here.
# Did you mean "instance_type"?
Syntax validation catches typos and structural errors immediately.
Security Scanning
Identify security vulnerabilities in configuration:
# tfsec - Terraform security scanner
tfsec infrastructure/
# Example findings:
# Result #1 CRITICAL Security group rule allows ingress from 0.0.0.0/0
# infrastructure/security.tf:10-15
#
# 10 | resource "aws_security_group_rule" "allow_all" {
# 11 | type = "ingress"
# 12 | from_port = 0
# 13 | to_port = 65535
# 14 | cidr_blocks = ["0.0.0.0/0"]
# 15 | }
#
# Impact: Unrestricted network access increases attack surface
# Resolution: Set explicit CIDR blocks for source addresses
Security scanners detect common misconfigurations:
- Publicly accessible storage buckets
- Unencrypted data stores
- Overly permissive security groups
- Missing logging configurations
- Weak SSL/TLS configurations
# Checkov - Multi-cloud security scanner
checkov -d infrastructure/
# Example output:
# Check: CKV_AWS_23: "Ensure security group has description"
# FAILED for resource: aws_security_group.api
# File: /security.tf:5-10
# Guide: https://docs.bridgecrew.io/docs/networking_1
Policy as Code
Enforce organizational standards:
# Sentinel policy (HashiCorp)
import "tfplan/v2" as tfplan
# Require all EC2 instances to have specific tags
required_tags = ["Environment", "Owner", "CostCenter"]
main = rule {
all tfplan.resource_changes as _, rc {
rc.type is "aws_instance" implies
all required_tags as tag {
rc.change.after.tags contains tag
}
}
}
Policy enforcement prevents non-compliant infrastructure:
# This would fail policy
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
# Missing required tags!
}
# This passes policy
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
tags = {
Environment = "production"
Owner = "platform-team"
CostCenter = "engineering"
}
}
Open Policy Agent (OPA) provides flexible policy language:
# OPA policy for Kubernetes
package kubernetes.admission
deny[msg] {
input.request.kind.kind == "Pod"
not input.request.object.spec.containers[_].resources.limits.memory
msg := "Containers must have memory limits"
}
deny[msg] {
input.request.kind.kind == "Service"
input.request.object.spec.type == "LoadBalancer"
not input.request.object.metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-internal"]
msg := "LoadBalancer services must be internal"
}
Unit Testing
Unit tests validate individual components in isolation.
Testing Terraform Modules
Test modules with Terratest (Go):
// 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) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"cidr_block": "10.0.0.0/16",
"environment": "test",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Verify VPC CIDR
vpcCidr := terraform.Output(t, terraformOptions, "vpc_cidr")
assert.Equal(t, "10.0.0.0/16", vpcCidr)
// Verify subnet count
subnetIds := terraform.OutputList(t, terraformOptions, "subnet_ids")
assert.Equal(t, 3, len(subnetIds))
}
Run tests:
cd test
go test -v -timeout 30m
Testing Kubernetes Manifests
Validate Kubernetes resources:
# test_deployment.py
import pytest
import yaml
def load_manifest(filename):
with open(filename) as f:
return list(yaml.safe_load_all(f))
def test_deployment_has_replicas():
manifests = load_manifest("../k8s/deployment.yaml")
deployment = [m for m in manifests if m['kind'] == 'Deployment'][0]
assert deployment['spec']['replicas'] >= 3, \
"Production deployments must have at least 3 replicas"
def test_deployment_has_resource_limits():
manifests = load_manifest("../k8s/deployment.yaml")
deployment = [m for m in manifests if m['kind'] == 'Deployment'][0]
container = deployment['spec']['template']['spec']['containers'][0]
assert 'resources' in container
assert 'limits' in container['resources']
assert 'cpu' in container['resources']['limits']
assert 'memory' in container['resources']['limits']
def test_service_type_is_clusterip():
manifests = load_manifest("../k8s/deployment.yaml")
service = [m for m in manifests if m['kind'] == 'Service'][0]
assert service['spec']['type'] == 'ClusterIP', \
"Services should not be directly exposed"
Run Kubernetes tests:
pytest test_deployment.py -v
Helm Chart Testing
Test Helm charts with helm unittest:
# charts/api-service/tests/deployment_test.yaml
suite: test deployment
templates:
- deployment.yaml
tests:
- it: should set replica count from values
set:
replicaCount: 5
asserts:
- equal:
path: spec.replicas
value: 5
- it: should set resource limits
asserts:
- exists:
path: spec.template.spec.containers[0].resources.limits
- exists:
path: spec.template.spec.containers[0].resources.requests
- it: should use correct image
set:
image:
repository: myapp
tag: v2.0.0
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: myapp:v2.0.0
Run Helm tests:
helm unittest charts/api-service
Integration Testing
Integration tests validate infrastructure in real environments.
End-to-End Testing
Test complete infrastructure stacks:
// test/integration_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 TestCompleteInfrastructure(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../infrastructure/staging",
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Get load balancer URL
lbUrl := terraform.Output(t, terraformOptions, "load_balancer_url")
// Test HTTP endpoint responds
http_helper.HttpGetWithRetry(
t,
lbUrl,
nil,
200,
"OK",
30,
10*time.Second,
)
// Test database is accessible
dbEndpoint := terraform.Output(t, terraformOptions, "database_endpoint")
assert.NotEmpty(t, dbEndpoint)
// Could add database connection test here
}
Contract Testing
Verify infrastructure meets application requirements:
# infrastructure-contract.yaml
version: 1.0
requirements:
networking:
- name: VPC exists
resource: aws_vpc.main
assertions:
- cidr_block_size >= 16
- enable_dns_hostnames == true
- name: Public subnets available
resource: aws_subnet.public
assertions:
- count >= 2
- map_public_ip_on_launch == true
database:
- name: Database encrypted
resource: aws_db_instance.main
assertions:
- storage_encrypted == true
- multi_az == true
- name: Automated backups enabled
resource: aws_db_instance.main
assertions:
- backup_retention_period >= 7
- backup_window != null
Validate contracts:
# validate_contracts.py
import yaml
import json
def validate_terraform_output(contract_file, terraform_state):
with open(contract_file) as f:
contract = yaml.safe_load(f)
with open(terraform_state) as f:
state = json.load(f)
failures = []
for category, requirements in contract['requirements'].items():
for req in requirements:
resource_name = req['resource']
# Find resource in state
resource = find_resource(state, resource_name)
for assertion in req['assertions']:
if not evaluate_assertion(resource, assertion):
failures.append(f"{req['name']}: {assertion}")
return failures
Compliance Testing
Verify infrastructure meets regulatory requirements.
Compliance Frameworks
Test against standards:
# InSpec - Compliance testing framework
inspec exec aws-compliance-profile \
--target aws://us-east-1 \
--reporter cli json:compliance-report.json
# Example InSpec test
describe aws_s3_bucket('production-data') do
it { should exist }
it { should have_default_encryption_enabled }
it { should_not be_public }
its('bucket_policy') { should include 'AES256' }
end
describe aws_rds_instance('production-database') do
it { should be_encrypted }
it { should have_multi_az_enabled }
its('backup_retention_period') { should be >= 30 }
end
Custom Compliance Rules
Enforce organization-specific requirements:
# compliance_tests.py
import boto3
import pytest
@pytest.fixture
def aws_resources():
return {
's3': boto3.client('s3'),
'rds': boto3.client('rds'),
'ec2': boto3.client('ec2')
}
def test_all_s3_buckets_encrypted(aws_resources):
"""All S3 buckets must have encryption enabled"""
s3 = aws_resources['s3']
buckets = s3.list_buckets()['Buckets']
for bucket in buckets:
bucket_name = bucket['Name']
encryption = s3.get_bucket_encryption(Bucket=bucket_name)
assert 'ServerSideEncryptionConfiguration' in encryption, \
f"Bucket {bucket_name} lacks encryption"
def test_rds_automated_backups(aws_resources):
"""RDS instances must have 30-day backup retention"""
rds = aws_resources['rds']
instances = rds.describe_db_instances()['DBInstances']
for instance in instances:
retention = instance['BackupRetentionPeriod']
assert retention >= 30, \
f"Instance {instance['DBInstanceIdentifier']} has insufficient backup retention: {retention} days"
def test_ec2_instances_tagged(aws_resources):
"""All EC2 instances must have required tags"""
ec2 = aws_resources['ec2']
instances = ec2.describe_instances()
required_tags = ['Environment', 'Owner', 'CostCenter']
for reservation in instances['Reservations']:
for instance in reservation['Instances']:
tags = {tag['Key']: tag['Value'] for tag in instance.get('Tags', [])}
for required_tag in required_tags:
assert required_tag in tags, \
f"Instance {instance['InstanceId']} missing tag: {required_tag}"
Run compliance tests:
pytest compliance_tests.py -v --tb=short
Testing in CI/CD Pipeline
Integrate testing into continuous integration workflows.
Complete Pipeline
# .github/workflows/infrastructure-test.yaml
name: Infrastructure Testing
on:
pull_request:
paths:
- 'infrastructure/**'
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: infrastructure
- name: Terraform Validate
run: |
terraform init -backend=false
terraform validate
working-directory: infrastructure
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1.0.0
with:
working_directory: infrastructure
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: infrastructure
framework: terraform
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run Terratest
run: |
cd test
go test -v -timeout 30m
env:
AWS_DEFAULT_REGION: us-east-1
integration-tests:
runs-on: ubuntu-latest
needs: [static-analysis, unit-tests]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
- name: Deploy to Test Environment
run: |
terraform init
terraform apply -auto-approve
working-directory: infrastructure/test
env:
AWS_DEFAULT_REGION: us-east-1
- name: Run Integration Tests
run: |
cd test
go test -v -run Integration
env:
AWS_DEFAULT_REGION: us-east-1
- name: Cleanup Test Environment
if: always()
run: terraform destroy -auto-approve
working-directory: infrastructure/test
compliance-check:
runs-on: ubuntu-latest
needs: integration-tests
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install Dependencies
run: |
pip install pytest boto3 pyyaml
- name: Run Compliance Tests
run: pytest compliance_tests.py -v
env:
AWS_DEFAULT_REGION: us-east-1
graph TB
A[Pull Request] --> B[Static Analysis]
B --> C{Pass?}
C -->|No| D[Report Failures]
C -->|Yes| E[Unit Tests]
E --> F{Pass?}
F -->|No| D
F -->|Yes| G[Integration Tests]
G --> H{Pass?}
H -->|No| D
H -->|Yes| I[Compliance Check]
I --> J{Pass?}
J -->|No| D
J -->|Yes| K[Approve PR]
style K fill:#d4f1d4
style D fill:#ffe6e6
Testing Best Practices
Effective infrastructure testing requires discipline and structure.
Test Pyramid
Balance different test types:
/\
/ \ E2E Tests (Few)
/____\
/ \ Integration Tests (Some)
/________\
/ \ Unit Tests (Many)
/__Static____\
/ Analysis \ (Most)
- Static Analysis: Run on every commit, fast feedback
- Unit Tests: Test modules independently, quick execution
- Integration Tests: Validate complete stacks, slower
- E2E Tests: Full system validation, slowest and most expensive
Fail Fast
Order tests from fastest to slowest:
# Run quick static analysis first
stages:
- lint
- unit-test
- integration-test
- compliance
# Static analysis completes in seconds
lint:
stage: lint
script:
- terraform fmt -check
- terraform validate
- tfsec
# Unit tests complete in minutes
unit-test:
stage: unit-test
script:
- go test -v -short
# Integration tests take 10-30 minutes
integration-test:
stage: integration-test
script:
- go test -v -run Integration
Isolated Test Environments
Use dedicated environments for testing:
# test/main.tf
terraform {
backend "s3" {
bucket = "terraform-state-test"
key = "test/infrastructure-${var.test_id}.tfstate"
region = "us-east-1"
}
}
resource "random_id" "test_suffix" {
byte_length = 4
}
module "test_infrastructure" {
source = "../modules/complete-stack"
environment = "test-${random_id.test_suffix.hex}"
# Use small, cheap resources for testing
instance_type = "t3.small"
database_size = "db.t3.small"
}
Common Pitfalls
Testing Only Happy Path: Tests must cover failure scenarios. Test what happens when resources don't exist, permissions are denied, or dependencies fail.
No Cleanup: Failed tests leave orphaned resources. Always use defer terraform.Destroy() in Go tests or cleanup blocks in other frameworks.
Slow Test Suites: Hour-long test runs discourage running tests. Optimize by parallelizing tests and using smaller resource sizes.
Ignoring Test Failures: Treating test failures as acceptable defeats the purpose. Fix failures immediately or delete unreliable tests.
Key Takeaways
- Test infrastructure code to prevent costly production failures, validate configurations, and enable confident changes
- Static analysis catches syntax errors, security issues, and policy violations without deploying infrastructure
- Unit tests validate individual modules in isolation, using frameworks like Terratest for Terraform or pytest for Kubernetes
- Integration tests verify complete infrastructure stacks work correctly, testing real deployments in isolated environments
- Compliance testing ensures infrastructure meets regulatory requirements using frameworks like InSpec or custom Python tests
- Integrate all testing into CI/CD pipelines, running static analysis first, then unit tests, integration tests, and finally compliance checks
- Follow testing best practices: maintain test pyramid balance, fail fast with quick tests first, use isolated test environments, and always clean up test resources