The Problem of IaC Testing

Testing infrastructure is fundamentally different from testing application code. A unit test of a Terraform module may require actual creation of resources AWS, which cost money and time (minutes, not milliseconds). The feedback loop is long, tests are potentially destructive if not handled properly, and dependencies on the cloud environment are inevitable for integration testing.

Despite these challenges, IaC testing is indispensable: a Terraform module untested it's a time bomb. This article presents a pyramid of tests for IaC with three levels: static tests (fast, cheap), tests with terraform tests (medium), and integration tests with Terratest (slow, in-depth).

Test Pyramid for IaC

  • Level 1 - Static (seconds): fmt, validate, tflint, tfsec/checkov. Zero AWS costs.
  • Level 2 - Unit/Mock (minutes): terraform test with mock_provider. Zero assets created.
  • Level 3 - Integration (5-30 minutes): Terratest: real assets, end-to-end verification.

Terraform Test: The Native Framework (GA from 1.6)

Since Terraform 1.6, the framework terraform test it is General Availability. Test files have extension .tftest.hcl and allow you to test the module interface (input/output) with two modes: command apply (create real resources) e command plan (planning only, zero costs).

# modules/s3-static-website/main.tf - Il modulo da testare

variable "bucket_name" {
  type        = string
  description = "Nome univoco del bucket S3"
}

variable "environment" {
  type        = string
  description = "Ambiente: dev, staging, production"
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "environment deve essere dev, staging o production"
  }
}

variable "enable_versioning" {
  type    = bool
  default = false
}

resource "aws_s3_bucket" "website" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "website" {
  count  = var.enable_versioning ? 1 : 0
  bucket = aws_s3_bucket.website.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_website_configuration" "website" {
  bucket = aws_s3_bucket.website.id

  index_document { suffix = "index.html" }
  error_document { key = "error.html" }
}

output "bucket_id" {
  value = aws_s3_bucket.website.id
}

output "website_endpoint" {
  value = aws_s3_bucket_website_configuration.website.website_endpoint
}
# modules/s3-static-website/tests/unit.tftest.hcl
# Test con command = "plan" - zero risorse create, zero costi

variables {
  bucket_name = "test-website-bucket-12345"
  environment = "dev"
}

# Test 1: configurazione base
run "basic_plan" {
  command = plan

  assert {
    condition     = aws_s3_bucket.website.bucket == "test-website-bucket-12345"
    error_message = "Il nome del bucket non corrisponde alla variabile bucket_name"
  }

  assert {
    condition     = aws_s3_bucket.website.tags["Environment"] == "dev"
    error_message = "Il tag Environment non corrisponde alla variabile environment"
  }

  assert {
    condition     = length(aws_s3_bucket_versioning.website) == 0
    error_message = "Il versioning non dovrebbe essere abilitato per default"
  }
}

# Test 2: con versioning abilitato
run "with_versioning" {
  command = plan

  variables {
    enable_versioning = true
  }

  assert {
    condition     = length(aws_s3_bucket_versioning.website) == 1
    error_message = "Il versioning dovrebbe essere abilitato"
  }
}

# Test 3: validazione input - deve fallire con ambiente invalido
run "invalid_environment_fails" {
  command = plan

  variables {
    environment = "qa"  # Non valido: deve fallire
  }

  expect_failures = [var.environment]
}
# Esegui i test
# In locale:
terraform test

# Output atteso:
# Success! All tests passed.
# 3 passed, 0 failed

Mock Providers: Testing Without Cloud Credentials

From Terraform 1.7, i mock providers allow you to simulate the AWS/Azure/GCP provider without real credentials. Computed values (such as ARN, ID) are automatically generated or configured in the mock.

# modules/s3-static-website/tests/mock.tftest.hcl
# Test con mock provider: nessuna credenziale AWS necessaria

mock_provider "aws" {
  # Il provider AWS è simulato: nessuna chiamata API reale
  mock_resource "aws_s3_bucket" {
    defaults = {
      id                          = "test-website-bucket-12345"
      arn                         = "arn:aws:s3:::test-website-bucket-12345"
      bucket_domain_name          = "test-website-bucket-12345.s3.amazonaws.com"
      bucket_regional_domain_name = "test-website-bucket-12345.s3.eu-west-1.amazonaws.com"
      region                      = "eu-west-1"
    }
  }

  mock_resource "aws_s3_bucket_website_configuration" {
    defaults = {
      website_endpoint = "test-website-bucket-12345.s3-website-eu-west-1.amazonaws.com"
    }
  }
}

variables {
  bucket_name = "test-website-bucket-12345"
  environment = "dev"
}

run "mock_apply_succeeds" {
  # command = apply con mock provider: crea risorse simulate
  command = apply

  assert {
    condition     = output.bucket_id == "test-website-bucket-12345"
    error_message = "output.bucket_id deve corrispondere al mock"
  }

  assert {
    condition     = can(regex("s3-website", output.website_endpoint))
    error_message = "website_endpoint deve contenere 's3-website'"
  }
}

Terratest: Integration Test with Go

Terratest (github.com/gruntwork-io/terratest) is a Go library to write integration tests that create real cloud resources and verify their behavior and destroy them when finished. It is the gold standard for testing complex Terraform modules.

# Struttura per Terratest
modules/
  s3-static-website/
    main.tf
    variables.tf
    outputs.tf
    tests/
      unit.tftest.hcl          # terraform test
      integration_test.go      # Terratest
      go.mod
      go.sum
// modules/s3-static-website/tests/integration_test.go
package test

import (
  "fmt"
  "testing"
  "time"

  "github.com/aws/aws-sdk-go/aws"
  "github.com/aws/aws-sdk-go/aws/session"
  "github.com/aws/aws-sdk-go/service/s3"
  "github.com/gruntwork-io/terratest/modules/random"
  "github.com/gruntwork-io/terratest/modules/terraform"
  "github.com/stretchr/testify/assert"
  "github.com/stretchr/testify/require"
)

// TestS3WebsiteModuleBasic crea il modulo, verifica il comportamento, poi distrugge
func TestS3WebsiteModuleBasic(t *testing.T) {
  t.Parallel() // I test possono girare in parallelo

  // ID univoco per evitare conflitti di naming tra test run
  uniqueId := random.UniqueId()
  bucketName := fmt.Sprintf("terratest-website-%s", uniqueId)

  terraformOptions := &terraform.Options{
    TerraformDir: "../",

    // Variabili passate al modulo
    Vars: map[string]interface{}{
      "bucket_name":       bucketName,
      "environment":       "dev",
      "enable_versioning": false,
    },

    // Mostra tutti i log Terraform durante i test
    // NoColor: true, // Disabilita colori per log CI puliti
  }

  // defer garantisce la distruzione delle risorse anche se il test fallisce
  defer terraform.Destroy(t, terraformOptions)

  // Init e Apply
  terraform.InitAndApply(t, terraformOptions)

  // Leggi gli output
  bucketId := terraform.Output(t, terraformOptions, "bucket_id")
  websiteEndpoint := terraform.Output(t, terraformOptions, "website_endpoint")

  // Assertions sugli output
  assert.Equal(t, bucketName, bucketId, "bucket_id deve essere uguale al bucket_name")
  assert.Contains(t, websiteEndpoint, "s3-website", "website_endpoint deve essere una URL website S3")

  // Verifica diretta con AWS SDK che il bucket esista e sia configurato correttamente
  sess, err := session.NewSession(&aws.Config{Region: aws.String("eu-west-1")})
  require.NoError(t, err)

  s3Client := s3.New(sess)

  // Verifica che il bucket esista
  _, err = s3Client.HeadBucket(&s3.HeadBucketInput{
    Bucket: aws.String(bucketName),
  })
  assert.NoError(t, err, "Il bucket deve esistere in AWS")

  // Verifica che il website hosting sia abilitato
  websiteOutput, err := s3Client.GetBucketWebsite(&s3.GetBucketWebsiteInput{
    Bucket: aws.String(bucketName),
  })
  require.NoError(t, err)
  assert.Equal(t, "index.html", *websiteOutput.IndexDocument.Suffix)
  assert.Equal(t, "error.html", *websiteOutput.ErrorDocument.Key)
}

// TestS3WebsiteModuleVersioning testa il versioning
func TestS3WebsiteModuleVersioning(t *testing.T) {
  t.Parallel()

  uniqueId := random.UniqueId()
  bucketName := fmt.Sprintf("terratest-versioned-%s", uniqueId)

  terraformOptions := &terraform.Options{
    TerraformDir: "../",
    Vars: map[string]interface{}{
      "bucket_name":       bucketName,
      "environment":       "dev",
      "enable_versioning": true,
    },
  }

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

  // Verifica versioning con AWS SDK
  sess, _ := session.NewSession(&aws.Config{Region: aws.String("eu-west-1")})
  s3Client := s3.New(sess)

  versioningOutput, err := s3Client.GetBucketVersioning(&s3.GetBucketVersioningInput{
    Bucket: aws.String(bucketName),
  })

  require.NoError(t, err)
  assert.Equal(t, "Enabled", *versioningOutput.Status)
}

// TestS3WebsiteIdempotent verifica che apply due volte non cambi nulla
func TestS3WebsiteIdempotent(t *testing.T) {
  t.Parallel()

  uniqueId := random.UniqueId()
  bucketName := fmt.Sprintf("terratest-idempotent-%s", uniqueId)

  terraformOptions := &terraform.Options{
    TerraformDir: "../",
    Vars: map[string]interface{}{
      "bucket_name": bucketName,
      "environment": "dev",
    },
  }

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

  // Secondo apply: non deve cambiare nulla (0 adds, 0 changes, 0 destroys)
  exitCode := terraform.PlanExitCode(t, terraformOptions)
  assert.Equal(t, 0, exitCode, "Secondo plan deve avere exit code 0 (no changes)")
}

Contract Testing: Verification of Module Interfaces

Il contract testing for Terraform modules verifies that the contract of the module (input and output) meets consumer expectations. It is useful in Shared module repositories to ensure changes don't break consumers.

# modules/s3-static-website/tests/contract.tftest.hcl
# Contract test: verifica che l'interfaccia del modulo rispetti il contratto

mock_provider "aws" {}

# Contract: output.bucket_id deve sempre essere una stringa non vuota
run "contract_bucket_id_not_empty" {
  command = apply

  variables {
    bucket_name = "contract-test-bucket"
    environment = "dev"
  }

  assert {
    condition     = length(output.bucket_id) > 0
    error_message = "CONTRATTO VIOLATO: output.bucket_id non deve essere vuoto"
  }
}

# Contract: output.website_endpoint deve essere un URL valido
run "contract_website_endpoint_format" {
  command = apply

  variables {
    bucket_name = "contract-test-bucket"
    environment = "dev"
  }

  assert {
    condition     = can(regex("^[a-z0-9-]+\\.s3-website[-.]", output.website_endpoint))
    error_message = "CONTRATTO VIOLATO: website_endpoint deve essere nel formato S3 website URL"
  }
}

# Contract: il modulo NON deve mai creare risorse con tag Environment mancante
run "contract_environment_tag_required" {
  command = plan

  variables {
    bucket_name = "contract-test-bucket"
    environment = "staging"
  }

  assert {
    condition     = aws_s3_bucket.website.tags["Environment"] != null
    error_message = "CONTRATTO VIOLATO: il tag Environment deve sempre essere presente"
  }
}

Integration with CI: GitHub Actions for Testing

# .github/workflows/module-test.yml
name: Test Terraform Modules

on:
  pull_request:
    paths:
      - 'modules/**'

permissions:
  id-token: write
  contents: read

jobs:
  # Livello 1: Test statici (0 costi, secondi)
  static:
    name: Static Analysis
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.5'
      - run: find modules -name "*.tf" -exec terraform fmt -check {} \;
      - run: |
          for dir in modules/*/; do
            cd "$dir"
            terraform init -backend=false
            terraform validate
            cd -
          done

  # Livello 2: terraform test con mock providers (0 costi AWS, minuti)
  unit-test:
    name: Unit Tests (terraform test)
    runs-on: ubuntu-latest
    needs: static
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.5'
      - name: Run terraform test for each module
        run: |
          for dir in modules/*/; do
            if ls "$dir"tests/*.tftest.hcl 2>/dev/null; then
              echo "Testing module: $dir"
              cd "$dir"
              terraform init -backend=false
              terraform test -filter=mock.tftest.hcl -filter=unit.tftest.hcl
              cd -
            fi
          done

  # Livello 3: Terratest (crea risorse AWS reali, 5-30 minuti)
  integration-test:
    name: Integration Tests (Terratest)
    runs-on: ubuntu-latest
    needs: unit-test
    # Esegui solo su PR verso main, non per ogni push
    if: github.base_ref == 'main'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/TerratestRole
          aws-region: eu-west-1

      - name: Run Terratest
        run: |
          cd modules/s3-static-website/tests
          go test -v -timeout 30m -run "TestS3Website"
        env:
          TF_VAR_environment: dev

Optimize Integration Test Times

Strategies to Reduce Testing Costs and Times

  • t.Parallel(): Run Terratest tests in parallel to reduce the total time. With 10 tests of 5 minutes each, 5 minutes are enough in parallel instead of 50.
  • Use economic regions: us-east-1 is the most AWS Region economical and with the greatest number of services available.
  • Aggressive Cleanup: USA defer terraform.Destroy() always as the first operation after creating the options, to ensure destruction even in the event of panic.
  • Test only modified modules: USA paths in GitHub Actions to run tests only for the modules actually changed in the PR.
  • Prefer command = plan: For most tests logic, command = plan in terraform test it is sufficient and it does not create resources.

Comprehensive Test Strategy for Teams

Level Instrument Where it turns Duration AWS cost
Static fmt, validate, tflint Every PR, local 10-30s $0
Security Checkov, Trivy Every PR 30-60s $0
Unit (plan) terraform test -plan Every PR 1-3 min $0
Unit (mock) terraform test -mock Every PR 1-5 min $0
Integration Terratest PR → main 10-30 min $0.10-2.00
Contract terraform test -mock Each PR modules 2-5 min $0

Conclusions and Next Steps

A three-tier IaC testing strategy — static, unit/mock, and integration — balance feedback speed with verification depth. The new framework terraform test native makes basic testing accessible without external dependencies, while Terratest remains the tool of choice for verify the real behavior of the infrastructure.

Next Articles in the Series

  • Article 6: IaC Security — Checkov, Trivy and OPA Policy-as-Code: automatic security scanning and organizational policies in the pre-apply gate.
  • Article 7: Terraform Multi-Cloud — AWS + Azure + GCP with Shared Modules: abstraction layer and multiple provider management.