Steven's Knowledge

Testing

Infrastructure testing pyramid - static analysis, Terraform test framework, Terratest integration tests, policy-as-code, and CI pipeline integration

Testing

Infrastructure code changes production systems. A bug in a Terraform module can delete a database or open a security group to the internet. Testing catches these mistakes before they reach terraform apply.

The Infrastructure Testing Pyramid

Like application code, infrastructure tests form a pyramid -- fast, cheap tests at the bottom and slow, expensive tests at the top:

        ┌───────────┐
        │   E2E     │  Deploy full environment, run smoke tests
       ─┤           ├─
      ┌─┴───────────┴─┐
      │  Integration   │  Apply real resources, validate behavior
     ─┤                ├─
    ┌─┴────────────────┴─┐
    │    Unit / Contract  │  terraform test, mock providers
   ─┤                    ├─
  ┌─┴────────────────────┴─┐
  │   Static Analysis       │  validate, lint, scan, fmt
  └─────────────────────────┘
LayerSpeedCostCatches
Static analysisSecondsFreeSyntax, style, known misconfigurations, security issues
Unit testsSecondsFreeLogic errors in modules, variable validation
Integration testsMinutesCloud costsReal resource behavior, API compatibility
E2E tests10-30 minCloud costsFull stack interactions, cross-module issues

Static Analysis

terraform validate and fmt

# Check syntax and internal consistency
terraform init -backend=false
terraform validate

# Format check (CI should fail on unformatted code)
terraform fmt -check -recursive

TFLint

Catches provider-specific mistakes that validate misses:

# Install and initialize
tflint --init

# Run against a module
tflint --recursive

Configure .tflint.hcl with provider-specific plugins (e.g., tflint-ruleset-aws) and enable rules like terraform_naming_convention and terraform_documented_variables.

Security Scanning

Multiple tools catch security misconfigurations before deploy:

tfsec .                                  # Terraform-specific security scanner
checkov -d . --framework terraform       # policy-as-code for IaC
trivy config .                           # unified scanner (containers, SBOM, IaC)

Unit Testing with terraform test

The native terraform test framework (Terraform 1.6+) runs tests without deploying real infrastructure.

Test File Structure

modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
    ├── defaults.tftest.hcl
    └── custom_cidr.tftest.hcl

Basic Test

# tests/defaults.tftest.hcl

# Override the provider to avoid real API calls
mock_provider "aws" {}

variables {
  name       = "test-vpc"
  cidr_block = "10.0.0.0/16"
  azs        = ["us-east-1a", "us-east-1b"]
}

run "creates_vpc_with_correct_cidr" {
  command = plan

  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block does not match input"
  }

  assert {
    condition     = aws_vpc.main.enable_dns_hostnames == true
    error_message = "DNS hostnames should be enabled by default"
  }
}

run "creates_correct_number_of_subnets" {
  command = plan

  assert {
    condition     = length(aws_subnet.private) == 2
    error_message = "Expected 2 private subnets, one per AZ"
  }
}

Test variable validation by passing invalid inputs and using expect_failures to assert the correct variable raises an error.

Running Tests

# Run all tests in the module
terraform test

# Run a specific test file
terraform test -filter=tests/defaults.tftest.hcl

# Verbose output
terraform test -verbose

Integration Testing with Terratest

Terratest deploys real infrastructure, runs assertions, then destroys everything. Written in Go.

Basic Terratest Example

// test/vpc_test.go
package test

import (
    "testing"

    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVpcModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "name":       "test-vpc",
            "cidr_block": "10.99.0.0/16",
            "azs":        []string{"us-east-1a", "us-east-1b"},
        },
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": "us-east-1",
        },
    }

    // Destroy resources after test
    defer terraform.Destroy(t, terraformOptions)

    // Deploy the module
    terraform.InitAndApply(t, terraformOptions)

    // Validate outputs
    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcId)

    // Validate the VPC exists in AWS
    vpc := aws.GetVpcById(t, vpcId, "us-east-1")
    assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)

    // Validate subnets
    subnets := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    assert.Len(t, subnets, 2)
}

For expensive tests, use test_structure.RunTestStage to split into deploy/validate/teardown stages. Set SKIP_teardown=true during development to keep resources alive for debugging.

Policy Testing

OPA / Conftest

Write policies in Rego and test them against terraform plan output:

# Generate plan JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json

# Test against policies
conftest test tfplan.json -p policy/
# policy/security.rego
package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_security_group_rule"
  resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
  resource.change.after.from_port <= 22
  resource.change.after.to_port >= 22
  msg := sprintf("Security group rule '%s' allows SSH from 0.0.0.0/0", [resource.address])
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket"
  not resource.change.after.server_side_encryption_configuration
  msg := sprintf("S3 bucket '%s' does not have encryption enabled", [resource.address])
}

For Terraform Cloud / Enterprise, HashiCorp Sentinel provides native policy enforcement with its own policy language.

CI Pipeline Integration

A typical GitHub Actions workflow has two jobs:

  1. Validate -- runs fmt -check, validate, tflint, trivy config, and terraform test on all modules
  2. Plan -- runs terraform plan, posts the output as a PR comment for review
# .github/workflows/terraform.yml
name: Terraform CI
on:
  pull_request:
    paths: ["infrastructure/**"]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive infrastructure/
      - run: tflint --init && tflint --recursive infrastructure/
      - run: trivy config infrastructure/
  plan:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: terraform -chdir=infrastructure/production/us-east-1/vpc init
      - run: terraform -chdir=infrastructure/production/us-east-1/vpc plan -no-color

Cost Estimation

Use Infracost to check cost impact before applying:

infracost breakdown --path infrastructure/production/us-east-1/vpc
infracost diff --path infrastructure/production/us-east-1/vpc --compare-to infracost-base.json

Drift Detection and Pre-Publish Testing

Detect when real infrastructure diverges from state by running terraform plan -detailed-exitcode on a nightly CI cron. Exit code 2 means changes detected -- alert the team.

Before publishing a module to a registry, run the full suite: fmt -check, validate, tflint, trivy, terraform test, and go test for integration tests. Then tag and push.

What's Next

Now see where these runs can live: managed platforms that host state, runs, and policy → HCP Terraform & Automation Platforms.

On this page