Infrastructure Testing — Terratest, InSpec, and ServerSpec
You wouldn't ship application code without tests, yet most teams deploy infrastructure changes on blind faith. A typo in a Terraform variable can open port 22 to the world, a misconfigured security group can expose your database, and an incorrect IAM policy can grant admin access to every developer. Infrastructure testing catches these mistakes before they become headlines.
Why Test Infrastructure?
Infrastructure as Code (IaC) is still code — it has bugs, regressions, and unintended side effects. But infrastructure bugs are often worse than application bugs because they affect security, compliance, and availability:
Application bug: Button doesn't work → Users annoyed
Infrastructure bug: S3 bucket public → Data breach → $4.88M average cost
Application fix: Deploy new version in minutes
Infrastructure fix: Revoke access, assess damage, notify regulators, months of remediation
Testing infrastructure code gives you confidence that your cloud resources match your expectations before they're created.
The Testing Pyramid for IaC
Just like application testing, infrastructure testing has layers:
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ End-to-End Tests \ Terratest
/ (Deploy + Validate) \ (slow, expensive, high confidence)
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Integration Tests \ InSpec, ServerSpec
/ (Validate running infra) \ (medium speed, real resources)
/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\
/ Static Analysis / Unit Tests \ Checkov, tflint, OPA
/ (Lint, validate, scan) \ (fast, no resources, low confidence)
‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
| Layer | Tools | Speed | Cost | What It Catches |
|---|---|---|---|---|
| Static Analysis | tflint, Checkov, OPA | Seconds | Free | Syntax errors, known misconfigs, policy violations |
| Unit Tests | Terraform validate, plan | Seconds | Free | Reference errors, type mismatches |
| Integration | InSpec, ServerSpec | Minutes | Low (uses existing infra) | Configuration drift, missing patches, compliance gaps |
| End-to-End | Terratest, Kitchen-Terraform | 10-30 min | Medium (creates real resources) | Full deployment works, resources behave correctly |
You need all layers. Static analysis is fast but can't verify that your RDS instance actually accepts connections. E2E tests are thorough but slow and expensive.
Terratest: End-to-End Infrastructure Testing
Terratest is a Go library that deploys real infrastructure, validates it, and tears it down. It's the gold standard for IaC end-to-end testing:
// 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 TestVpcModule(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",
"project_name": "terratest-vpc",
},
// Use a unique region to avoid conflicts with other tests
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-west-2",
},
})
// Destroy infrastructure at the end of the test
defer terraform.Destroy(t, terraformOptions)
// Deploy the infrastructure
terraform.InitAndApply(t, terraformOptions)
// Validate outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
publicSubnets := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 3, len(publicSubnets), "Expected 3 public subnets")
privateSubnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
assert.Equal(t, 3, len(privateSubnets), "Expected 3 private subnets")
// Validate the VPC actually exists in AWS
vpc := aws.GetVpcById(t, vpcId, "us-west-2")
assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)
// Validate DNS settings
assert.True(t, aws.IsVpcDnsSupported(t, vpcId, "us-west-2"))
assert.True(t, aws.IsVpcDnsHostnamesSupported(t, vpcId, "us-west-2"))
// Verify subnets are in different AZs
for _, subnetId := range publicSubnets {
subnet := aws.GetSubnetById(t, subnetId, "us-west-2")
assert.Contains(t, subnet.AvailabilityZone, "us-west-2")
}
}
Run it like any Go test:
cd test/
go test -v -timeout 30m -run TestVpcModule
Terratest also supports Azure, GCP, Kubernetes, Docker, Helm, and SSH-based validation.
Terratest for Azure
// test/azure_rg_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/azure"
"github.com/stretchr/testify/assert"
)
func TestAzureResourceGroup(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/azure-rg",
Vars: map[string]interface{}{
"resource_group_name": "rg-terratest-example",
"location": "eastus2",
"environment": "test",
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
rgName := terraform.Output(t, terraformOptions, "resource_group_name")
// Verify resource group exists
exists := azure.ResourceGroupExists(t, rgName, "")
assert.True(t, exists, "Resource group should exist")
// Verify tags
rg := azure.GetAResourceGroup(t, rgName, "")
assert.Equal(t, "test", rg.Tags["environment"])
}
InSpec: Compliance as Code
InSpec by Chef is a framework for writing human-readable compliance tests. It's particularly strong for CIS benchmark validation and audit requirements:
# controls/aws_security.rb
title 'AWS Security Baseline'
# Ensure no S3 buckets are publicly accessible
control 'aws-s3-no-public-access' do
impact 1.0
title 'S3 buckets should not allow public access'
desc 'Publicly accessible S3 buckets can lead to data exposure'
aws_s3_buckets.bucket_names.each do |bucket_name|
describe aws_s3_bucket(bucket_name: bucket_name) do
it { should_not be_public }
end
end
end
# Ensure RDS instances are encrypted
control 'aws-rds-encryption' do
impact 0.9
title 'RDS instances must be encrypted at rest'
desc 'Data at rest encryption protects against physical theft'
aws_rds_instances.db_instance_identifiers.each do |db_id|
describe aws_rds_instance(db_id) do
it { should have_encrypted_storage }
its('storage_encrypted') { should be true }
end
end
end
# Ensure security groups don't allow unrestricted SSH
control 'aws-sg-no-open-ssh' do
impact 1.0
title 'Security groups should not allow SSH from 0.0.0.0/0'
aws_security_groups.group_ids.each do |sg_id|
describe aws_security_group(group_id: sg_id) do
it { should_not allow_in(port: 22, ipv4_range: '0.0.0.0/0') }
end
end
end
Run InSpec against your AWS account:
# Run against AWS
inspec exec ./profiles/aws-security -t aws://us-east-1
# Run against a remote server via SSH
inspec exec ./profiles/linux-baseline -t ssh://admin@10.0.1.50 -i ~/.ssh/key.pem
# Run against Azure
inspec exec ./profiles/azure-security -t azure://
# Output in JUnit format for CI/CD
inspec exec ./profiles/aws-security -t aws:// --reporter junit:results.xml
ServerSpec: Server Configuration Testing
ServerSpec tests that servers are configured correctly — packages installed, services running, ports open:
# spec/web_server/httpd_spec.rb
require 'spec_helper'
describe 'Web Server Configuration' do
# Package is installed
describe package('nginx') do
it { should be_installed }
its('version') { should match /1\.24/ }
end
# Service is running and enabled
describe service('nginx') do
it { should be_enabled }
it { should be_running }
end
# Port is listening
describe port(443) do
it { should be_listening }
its('protocols') { should include 'tcp' }
end
# SSL certificate exists and isn't expired
describe x509_certificate('/etc/nginx/ssl/server.crt') do
it { should be_valid }
its('validity_in_days') { should be > 30 }
its('subject') { should match /CN=api\.example\.com/ }
end
# Configuration file has correct content
describe file('/etc/nginx/nginx.conf') do
it { should exist }
its('mode') { should cmp '0644' }
its('owner') { should eq 'root' }
its('content') { should match /worker_processes\s+auto/ }
its('content') { should match /ssl_protocols TLSv1\.2 TLSv1\.3/ }
its('content') { should_not match /ssl_protocols.*TLSv1\.0/ }
end
# Firewall rules
describe iptables do
it { should have_rule('-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT') }
it { should have_rule('-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT') }
end
end
Tool Comparison
| Feature | Terratest | InSpec | ServerSpec |
|---|---|---|---|
| Language | Go | Ruby DSL | Ruby DSL |
| Test Type | End-to-end (deploy + validate) | Compliance / audit | Server configuration |
| Speed | Slow (10-30 min) | Fast (seconds to minutes) | Fast (seconds) |
| Cloud Support | AWS, Azure, GCP, K8s | AWS, Azure, GCP | OS-level only |
| Learning Curve | Moderate (requires Go) | Low (human-readable DSL) | Low (RSpec-based) |
| CI Integration | Go test → JUnit | Built-in reporters | RSpec reporters |
| CIS Benchmarks | Manual | Pre-built profiles | Manual |
| Destroys Resources | Yes (creates and destroys) | No (read-only) | No (read-only) |
| Best For | IaC module validation | Compliance audits | Config management validation |
CIS Benchmark Compliance with InSpec
The Center for Internet Security (CIS) publishes benchmarks for securing cloud infrastructure. InSpec has pre-built profiles:
# Run CIS AWS Foundations Benchmark
inspec exec https://github.com/mitre/aws-foundations-cis-baseline \
-t aws://us-east-1 \
--reporter cli json:cis-results.json
# Run CIS Kubernetes Benchmark
inspec exec https://github.com/dev-sec/cis-kubernetes-benchmark \
-t k8s://
# Run CIS Linux Benchmark against a server
inspec exec https://github.com/dev-sec/linux-baseline \
-t ssh://admin@10.0.1.50
These profiles contain hundreds of controls mapped to specific CIS recommendations. Non-compliant findings are reported with remediation steps.
Integrating Infrastructure Tests in CI/CD
# .github/workflows/infra-tests.yaml
name: Infrastructure Tests
on:
pull_request:
paths:
- 'terraform/**'
- 'ansible/**'
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Terraform lint
run: |
cd terraform/
terraform init -backend=false
terraform validate
tflint --recursive
- name: Checkov scan
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
terratest:
runs-on: ubuntu-latest
needs: static-analysis
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run Terratest
working-directory: test/
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: go test -v -timeout 30m ./...
compliance:
runs-on: ubuntu-latest
needs: terratest
steps:
- uses: actions/checkout@v4
- name: Run InSpec compliance checks
run: |
inspec exec profiles/aws-security \
-t aws://us-east-1 \
--reporter junit:inspec-results.xml
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: compliance-results
path: inspec-results.xml
Closing Note
Infrastructure testing transforms "I think this Terraform is correct" into "I know this Terraform is correct because 47 tests pass." Start with static analysis (free, fast), add InSpec for compliance, and use Terratest for critical modules. In the next post, we'll compare API Gateways — Kong, Traefik, and AWS API Gateway — and how to choose the right one for your architecture.
