- Published on
Testing Infrastructure as Code — Terratest, Policy-as-Code, and Shift-Left IaC
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Infrastructure as code lets you version control your cloud resources, but untested code is untested code—whether it's application code or Terraform. A typo in a security group rule ships to production. A policy violation (public S3 bucket) gets into your infrastructure. Missing tags prevent cost accounting. Shift-left IaC testing catches these before deployment: linting with tflint, policy validation with OPA/Conftest, module testing with Terratest, and cost estimation with Infracost. This post covers testing strategies from syntax to behavior.
- Terratest for Terraform Module Testing
- OPA/Conftest for Policy-as-Code
- tfsec for Security Scanning
- tflint for Style and Deprecation
- Module Contract Testing
- Infracost for Cost Estimation
- CI/CD Pipeline for IaC Testing
- Checklist
- Conclusion
Terratest for Terraform Module Testing
Terratest runs actual Terraform code and verifies the cloud resources created match expectations:
// test/vpc_test.go
package test
import (
"fmt"
"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{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
"enable_nat": true,
"availability_zones": []string{"us-east-1a", "us-east-1b"},
},
}
// Cleanup resources after test
defer terraform.Destroy(t, terraformOptions)
// Apply Terraform
terraform.InitAndApply(t, terraformOptions)
// Verify outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
assert.True(t, len(vpcId) > 0 && vpcId[:3] == "vpc")
// Verify subnet count
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 2, len(publicSubnetIds))
// Verify tags
tags := terraform.OutputMap(t, terraformOptions, "vpc_tags")
assert.Equal(t, "test", tags["Environment"])
// Verify NAT Gateway exists
natGateways := terraform.OutputList(t, terraformOptions, "nat_gateway_ids")
assert.Greater(t, len(natGateways), 0)
}
func TestVPCWithDisabledNAT(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.1.0.0/16",
"environment": "test",
"enable_nat": false,
"availability_zones": []string{"us-east-1a"},
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Verify no NAT gateways when disabled
natGateways := terraform.OutputList(t, terraformOptions, "nat_gateway_ids")
assert.Equal(t, 0, len(natGateways))
}
VPC module being tested:
# modules/vpc/main.tf
variable "vpc_cidr" {
type = string
description = "CIDR block for VPC"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "environment" {
type = string
}
variable "enable_nat" {
type = bool
default = true
}
variable "availability_zones" {
type = list(string)
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "igw-${var.environment}"
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "subnet-public-${count.index}"
}
}
resource "aws_eip" "nat" {
count = var.enable_nat ? length(var.availability_zones) : 0
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
count = var.enable_nat ? length(var.availability_zones) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
depends_on = [aws_internet_gateway.main]
tags = {
Name = "nat-${count.index}"
}
}
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "nat_gateway_ids" {
value = aws_nat_gateway.main[*].id
}
output "vpc_tags" {
value = aws_vpc.main.tags
}
Run tests:
cd test && go test -v -run TestVPCModule
OPA/Conftest for Policy-as-Code
Define policies to prevent insecure configurations:
# policies/s3.rego
package s3
deny[msg] {
input.resource.aws_s3_bucket[name].acl == "public-read"
msg := sprintf("S3 bucket %s is publicly readable", [name])
}
deny[msg] {
input.resource.aws_s3_bucket[name].acl == "public-read-write"
msg := sprintf("S3 bucket %s is publicly writable", [name])
}
deny[msg] {
bucket := input.resource.aws_s3_bucket[name]
not bucket.server_side_encryption_configuration
msg := sprintf("S3 bucket %s does not have encryption enabled", [name])
}
deny[msg] {
bucket := input.resource.aws_s3_bucket[name]
not bucket.versioning[0].enabled
msg := sprintf("S3 bucket %s does not have versioning enabled", [name])
}
warn[msg] {
bucket := input.resource.aws_s3_bucket[name]
not bucket.tags.CostCenter
msg := sprintf("S3 bucket %s missing CostCenter tag", [name])
}
warn[msg] {
bucket := input.resource.aws_s3_bucket[name]
not bucket.tags.Environment
msg := sprintf("S3 bucket %s missing Environment tag", [name])
}
Security group policy:
# policies/security-groups.rego
package security_groups
deny[msg] {
sg := input.resource.aws_security_group[name]
rule := sg.ingress[_]
rule.from_port == 0
rule.to_port == 65535
rule.protocol == "-1"
rule.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("Security group %s allows all traffic from anywhere", [name])
}
deny[msg] {
sg := input.resource.aws_security_group[name]
rule := sg.ingress[_]
rule.from_port == 3306
rule.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("Security group %s allows MySQL from anywhere", [name])
}
deny[msg] {
sg := input.resource.aws_security_group[name]
rule := sg.ingress[_]
rule.from_port == 5432
rule.cidr_blocks[_] == "0.0.0.0/0"
msg := sprintf("Security group %s allows PostgreSQL from anywhere", [name])
}
deny[msg] {
sg := input.resource.aws_security_group[name]
not sg.tags.Owner
msg := sprintf("Security group %s missing Owner tag", [name])
}
Test policies:
# Convert Terraform to JSON
terraform show -json tfplan > tfplan.json
# Test with Conftest
conftest test -p policies tfplan.json -f json
# Deny anything not explicitly allowed
conftest test -p policies tfplan.json -f json --fail-on-warn
tfsec for Security Scanning
Automated security checks for Terraform:
# Install tfsec
brew install tfsec
# Scan directory
tfsec . -f json > tfsec-report.json
# In CI/CD pipeline
tfsec . --exit-code 1 --minimum-severity MEDIUM
Example tfsec findings:
# If this config exists:
resource "aws_s3_bucket" "logs" {
bucket = "my-logs"
acl = "public-read" # VIOLATION: tfsec AWS019
}
resource "aws_security_group" "web" {
ingress {
from_port = 0
to_port = 65535
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"] # VIOLATION: tfsec AWS021
}
}
resource "aws_rds_cluster" "main" {
# VIOLATION: tfsec AWS074 (encryption not enabled)
storage_encrypted = false
}
tflint for Style and Deprecation
Lint Terraform code for style violations and deprecated syntax:
# .tflint.hcl
plugin "aws" {
enabled = true
version = "0.25.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "aws_instance_invalid_type" {
enabled = true
}
rule "aws_resource_missing_tags" {
enabled = true
tags = ["Environment", "Owner", "CostCenter"]
}
rule "terraform_required_version" {
enabled = true
}
rule "terraform_unused_declarations" {
enabled = true
}
rule "terraform_comment_syntax" {
enabled = true
}
rule "terraform_deprecated_index" {
enabled = true
}
Run tflint:
tflint
tflint --fix # Auto-fix formatting issues
tflint --format json > tflint-report.json
Module Contract Testing
Test that modules accept expected inputs and produce expected outputs:
# test/module_defaults_test.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Test minimal configuration
module "minimal_config" {
source = "../modules/database"
db_name = "testdb"
username = "admin"
password = "testpass123" # Use random password in real tests
}
# Verify outputs exist
output "endpoint" {
value = module.minimal_config.endpoint
}
output "port" {
value = module.minimal_config.port
}
Test invalid inputs:
// test/module_validation_test.go
func TestModuleInputValidation(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/database",
Vars: map[string]interface{}{
"db_name": "invalid-name!", // Invalid characters
"username": "", // Empty username
"password": "short", // Too short
},
}
// Should fail validation
_, err := terraform.InitAndApplyE(t, terraformOptions)
assert.Error(t, err)
assert.Contains(t, err.Error(), "validation failed")
}
Infracost for Cost Estimation
Estimate infrastructure costs from Terraform:
# Install Infracost
brew install infracost
# Generate plan JSON
terraform plan -out tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Estimate costs
infracost breakdown --path tfplan.json --format table
# Compare costs before/after
infracost diff --path tfplan.json
# In CI: fail if cost increases by more than 10%
infracost diff --path tfplan.json --compare-to baseline.json \
--max-percent-increase 10
Set up Infracost in GitHub Actions:
name: Infracost PR Comment
on: [pull_request]
jobs:
infracost:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.0
- uses: infracost/actions/setup@v2
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Generate Terraform plan
run: |
terraform init
terraform plan -out tfplan.binary
- name: Run Infracost
run: |
infracost breakdown --path tfplan.binary --format json \
--out-file infracost.json
- name: Comment on PR
uses: infracost/actions/comment@v2
with:
path: infracost.json
CI/CD Pipeline for IaC Testing
# .github/workflows/terraform-test.yml
name: Terraform Test
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v2
- name: Terraform format check
run: terraform fmt -check -recursive
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: terraform-linters/setup-tflint@v3
- name: Init tflint
run: tflint --init
- name: Run tflint
run: tflint -f json --min-severity warning > tflint.json
continue-on-error: true
- name: Upload tflint results
uses: actions/upload-artifact@v4
with:
name: tflint-report
path: tflint.json
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/tfsec-action@v1.0.0
with:
exit-code: 1
minimum-severity: MEDIUM
policy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: open-policy-agent/conftest-action@v0.9.0
with:
files: terraform/**
policy: policies
options: -f json
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: 1.21
- name: Run Terratest
run: cd test && go test -v -timeout 30m
- name: Upload test logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: terratest-logs
path: test/logs
cost-estimate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v2
- uses: infracost/actions/setup@v2
- name: Generate Terraform plan
run: |
terraform init -backend=false
terraform plan -out tfplan.binary
- name: Run Infracost
env:
INFRACOST_API_KEY: ${{ secrets.INFRACOST_API_KEY }}
run: infracost breakdown --path tfplan.binary --format table
Checklist
- Set up Terratest for module testing
- Define policy-as-code with OPA/Conftest
- Run tfsec security scans in CI
- Configure tflint for style consistency
- Test module input validation
- Add Infracost cost estimation to PRs
- Document module contract (inputs/outputs)
- Verify all resources are tagged
- Test destruction and cleanup
- Monitor for breaking changes in AWS provider
Conclusion
Infrastructure code deserves the same testing rigor as application code. Terraform modules should have contracts (expected inputs and outputs) and tests that verify them. Policies prevent entire categories of bugs (public S3 buckets, overly permissive security groups). Security scanning catches known vulnerability patterns. Cost estimation prevents surprise bill surprises. Start with tflint and tfsec in CI, add Terratest for critical modules, and build OPA policies around your organization's standards. Your future self operating these resources will thank you.