Published on

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

Authors

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

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.