Unit and Integration Testing for Terraform Modules

A comprehensive guide to testing Terraform modules at every level—from static analysis to integration tests—ensuring infrastructure code is reliable before it reaches production.

#terraform#testing#infrastructure-as-code#quality
Cover image for the article: Unit and Integration Testing for Terraform Modules

The Problem: Infrastructure Code Without Tests

Infrastructure-as-code promised repeatability and reliability. But most teams treat Terraform modules like untested scripts—they work until they don't, and failures happen in production.

Our platform team manages 89 Terraform modules used across 14 AWS accounts. Before implementing a testing strategy, we averaged 2.3 production infrastructure incidents per month caused by module changes that "worked in dev." After building a multi-layer testing pipeline, that number dropped to 0.2 per month—a 91% reduction.

The Testing Pyramid for Infrastructure

Just like application code, infrastructure benefits from a testing pyramid:

Terraform Testing Pyramid

  1. Static Analysis (fastest, cheapest): Linting, formatting, validation
  2. Unit Tests: Testing module logic in isolation with mocked providers
  3. Contract Tests: Verifying module outputs match expected schemas
  4. Integration Tests: Deploying real infrastructure and validating behavior
  5. End-to-End Tests: Full environment provisioning with cross-module dependencies

Layer 1: Static Analysis

Static analysis catches 60% of issues in seconds. We run these checks on every commit:

# .tflint.hcl
plugin "aws" {
  enabled = true
  version = "0.31.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "aws_instance_invalid_type" {
  enabled = true
}

Our CI pipeline runs validation in parallel:

#!/bin/bash
# scripts/static-analysis.sh
set -euo pipefail

echo "Running terraform fmt check..."
terraform fmt -check -recursive -diff

echo "Running terraform validate..."
for module in modules/*/; do
  echo "Validating ${module}..."
  (cd "$module" && terraform init -backend=false && terraform validate)
done

echo "Running tflint..."
for module in modules/*/; do
  echo "Linting ${module}..."
  (cd "$module" && tflint --init && tflint)
done

echo "Running tfsec..."
tfsec . --minimum-severity HIGH --format json > tfsec-results.json

Layer 2: Unit Testing with Terraform Test

Terraform 1.6+ introduced native testing. We use tftest files to validate module behavior without provisioning real resources:

# tests/vpc_module.tftest.hcl
provider "aws" {
  region = "us-east-1"
}

variables {
  environment    = "test"
  vpc_cidr       = "10.0.0.0/16"
  azs            = ["us-east-1a", "us-east-1b", "us-east-1c"]
  enable_nat     = true
  single_nat_gw  = false
}

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 expected value"
  }
}

run "creates_three_public_subnets" {
  command = plan

  assert {
    condition     = length(aws_subnet.public) == 3
    error_message = "Expected 3 public subnets, got ${length(aws_subnet.public)}"
  }
}

run "creates_three_private_subnets" {
  command = plan

  assert {
    condition     = length(aws_subnet.private) == 3
    error_message = "Expected 3 private subnets, got ${length(aws_subnet.private)}"
  }
}

run "nat_gateways_per_az_when_multi_nat" {
  command = plan

  assert {
    condition     = length(aws_nat_gateway.main) == 3
    error_message = "Expected one NAT GW per AZ when single_nat_gw=false"
  }
}

run "tags_include_environment" {
  command = plan

  assert {
    condition     = aws_vpc.main.tags["Environment"] == "test"
    error_message = "VPC missing Environment tag"
  }
}

Layer 3: Contract Testing

Contract tests verify that module outputs match the schema expected by consumers. This prevents breaking changes from propagating:

# tests/vpc_contract.tftest.hcl
run "output_contract_validation" {
  command = plan

  # Verify output types and structure
  assert {
    condition     = can(output.vpc_id)
    error_message = "Module must output vpc_id"
  }

  assert {
    condition     = can(output.private_subnet_ids)
    error_message = "Module must output private_subnet_ids"
  }

  assert {
    condition     = length(output.private_subnet_ids) > 0
    error_message = "private_subnet_ids must not be empty"
  }

  assert {
    condition     = can(cidrhost(output.vpc_cidr, 0))
    error_message = "vpc_cidr output must be a valid CIDR"
  }
}

Layer 4: Integration Testing with Terratest

For full integration tests that provision real infrastructure, we use Terratest in Go:

// test/vpc_integration_test.go
package test

import (
    "testing"
    "fmt"

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

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

    awsRegion := "us-east-1"
    uniqueID := fmt.Sprintf("test-%s", random.UniqueId())

    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "environment": uniqueID,
            "vpc_cidr":    "10.99.0.0/16",
            "azs":         []string{"us-east-1a", "us-east-1b"},
            "enable_nat":  true,
            "single_nat_gw": true,
        },
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    })

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // Validate VPC exists and has correct CIDR
    vpcID := terraform.Output(t, terraformOptions, "vpc_id")
    vpc := aws.GetVpcById(t, vpcID, awsRegion)
    assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)

    // Validate subnets are in correct AZs
    subnetIDs := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    require.Len(t, subnetIDs, 2)

    for _, subnetID := range subnetIDs {
        subnet := aws.GetSubnetById(t, subnetID, awsRegion)
        assert.True(t, subnet.MapPublicIpOnLaunch == false,
            "Private subnets should not map public IPs")
    }

    // Validate NAT Gateway connectivity
    natGWIDs := terraform.OutputList(t, terraformOptions, "nat_gateway_ids")
    assert.Len(t, natGWIDs, 1, "Single NAT GW mode should create one gateway")
}

CI/CD Pipeline Integration

Our pipeline runs tests in order of speed, failing fast on cheap checks:

# .github/workflows/terraform-test.yaml
name: Terraform Module Tests
on:
  pull_request:
    paths: ['modules/**', 'tests/**']

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      - name: Format Check
        run: terraform fmt -check -recursive
      - name: Validate All Modules
        run: ./scripts/validate-modules.sh
      - name: TFLint
        run: ./scripts/run-tflint.sh
      - name: Security Scan
        run: tfsec . --minimum-severity HIGH

  unit-tests:
    needs: static-analysis
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      - name: Run Unit Tests
        run: |
          for module in modules/*/; do
            if [ -d "${module}tests" ]; then
              echo "Testing ${module}..."
              (cd "$module" && terraform test)
            fi
          done

  integration-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    if: github.event.pull_request.draft == false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Run Integration Tests
        env:
          AWS_ROLE_ARN: ${{ secrets.TEST_ACCOUNT_ROLE }}
        run: |
          cd test
          go test -v -timeout 30m -tags=integration ./...

Results

MetricBeforeAfter
Infra incidents from module changes2.3/month0.2/month
Time to validate a module PR45 min (manual)12 min (automated)
Module reuse across teams34%78%
Confidence in terraform apply"hope it works"Verified by 4 test layers

Terraform Testing Results Over Time

Key Takeaways

  1. Start with static analysis. It's free, fast, and catches the majority of issues. Make terraform fmt, validate, and tflint non-negotiable.

  2. Use native Terraform tests for logic. terraform test with command = plan validates module behavior without provisioning anything—runs in seconds.

  3. Contract tests prevent breaking changes. When multiple teams consume your modules, contract tests are the safety net that allows independent evolution.

  4. Reserve integration tests for critical paths. They're slow and expensive. Run them on non-draft PRs and for modules that manage networking, security, or data stores.

  5. Fail fast, fail cheap. Structure your pipeline so the cheapest tests run first. A formatting error shouldn't trigger a 30-minute integration test suite.

Untested infrastructure code is a liability disguised as automation. The testing pyramid for Terraform gives you confidence that terraform apply will do what you expect—every time.

Comments

    No comments yet. Be the first to share your thoughts.