Problem testowania IaC

Infrastruktura testowa zasadniczo różni się od testowania kodu aplikacji. Test jednostkowy modułu Terraform może wymagać faktycznego utworzenia zasobów AWS, które kosztują pieniądze i czas (minuty, a nie milisekundy). Pętla sprzężenia zwrotnego jest długa, testy są potencjalnie destrukcyjne, jeśli nie są właściwie obsługiwane, oraz zależności w środowisku chmury są nieuniknione w przypadku testów integracyjnych.

Pomimo tych wyzwań niezastąpione są testy IaC: moduł Terraform niesprawdzone, to bomba zegarowa. W artykule przedstawiono A piramida testów dla IaC z trzema poziomami: testy statyczne (szybkie, tanie), testy z testy terraformowe (średnie) i testy integracyjne z Terratestem (powolne, dogłębne).

Piramida testowa dla IaC

  • Poziom 1 – Statyczny (sekundy): fmt, sprawdź, tflint, tfsec/checkov. Zerowe koszty AWS.
  • Poziom 2 – Jednostka/Makieta (minuty): test terraformowy z mock_provider. Utworzono zero zasobów.
  • Poziom 3 – Integracja (5-30 minut): Terratest: realne aktywa, kompleksowa weryfikacja.

Test Terraform: Natywna platforma (GA od 1.6)

Od wersji Terraform 1.6 framework terraform test jest to ogólna dostępność. Pliki testowe mają rozszerzenie .tftest.hcl i pozwolić Ci przetestować interfejs modułu (wejście/wyjście) z dwoma trybami: polecenie zastosuj (stwórz prawdziwe zasoby) e plan dowodzenia (tylko planowanie, koszty zerowe).

# 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

Próbni dostawcy: testowanie bez poświadczeń w chmurze

Od Terraforma 1.7, tj fałszywych dostawców pozwalają na symulację dostawca AWS/Azure/GCP bez prawdziwych poświadczeń. Obliczone wartości (takie jak ARN, ID) są automatycznie generowane lub konfigurowane w makiecie.

# 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: Test integracji z Go

Terratest (github.com/gruntwork-io/terratest) to biblioteka Go napisać testy integracyjne, które stworzą realne zasoby chmurowe i weryfikują ich zachowanie i zniszcz je, gdy skończysz. Jest to złoty standard w testowaniu złożonych modułów Terraform.

# 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)")
}

Testowanie kontraktowe: weryfikacja interfejsów modułów

Il testowanie kontraktu dla modułów Terraform sprawdza, czy plik umowa modułu (wejście i wyjście) spełnia oczekiwania konsumentów. Przydaje się w Udostępnione repozytoria modułów, aby mieć pewność, że zmiany nie psują konsumentów.

# 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"
  }
}

Integracja z 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

Zoptymalizuj czas testów integracyjnych

Strategie mające na celu zmniejszenie kosztów i czasu testowania

  • t.Równolegle(): Aby zmniejszyć, uruchom równolegle testy Terratest całkowity czas. Przy 10 testach po 5 minut każdy wystarczy 5 minut równolegle zamiast 50.
  • Skorzystaj z regionów gospodarczych: us-east-1 jest najbardziej regionem AWS ekonomiczne i oferujące największą liczbę dostępnych usług.
  • Agresywne czyszczenie: USA defer terraform.Destroy() zawsze jako pierwsza operacja po utworzeniu opcji, aby się upewnić zniszczenia nawet w przypadku paniki.
  • Testuj tylko zmodyfikowane moduły: USA paths w akcjach GitHuba aby uruchomić testy tylko dla modułów faktycznie zmienionych w PR.
  • Preferuj polecenie = plan: W przypadku większości testów logika, command = plan w teście terraformowym jest to wystarczające i nie tworzy zasobów.

Kompleksowa strategia testów dla zespołów

Poziom Instrument Gdzie się obraca Czas trwania Koszt AWS-a
Statyczny fmt, zatwierdź, tflint Każdy PR, lokalny 10-30 s $0
Bezpieczeństwo Checkov, Trivy Każdy PR 30-60 lat $0
Jednostka (plan) plan testu terraformowego Każdy PR 1-3 minuty $0
Jednostka (próba) test terraformowy - próbny Każdy PR 1-5 minut $0
Integracja Terratest PR → główny 10-30 minut 0,10-2,00 USD
Umowa test terraformowy - próbny Każdy moduł PR 2-5 minut $0

Wnioski i dalsze kroki

Trójpoziomowa strategia testowania IaC — statyczna, jednostkowa/próbna i integracyjna — zrównoważyć prędkość sprzężenia zwrotnego z głębokością weryfikacji. Nowy ramy terraform test natywny sprawia, że podstawowe testy stają się dostępne bez zewnętrznych zależności, podczas gdy Terratest pozostaje narzędziem z wyboru zweryfikować rzeczywiste zachowanie infrastruktury.

Następne artykuły z serii

  • Artykuł 6: Bezpieczeństwo IaC — Checkov, Trivy i OPA Policy-as-Code: automatyczne skanowanie bezpieczeństwa i zasady organizacyjne w bramce przed złożeniem wniosku.
  • Artykuł 7: Terraform Multi-Cloud — AWS + Azure + GCP z Moduły współdzielone: warstwa abstrakcji i zarządzanie wieloma dostawcami.