IaC 테스트의 문제

인프라 테스트는 애플리케이션 코드 테스트와 근본적으로 다릅니다. Terraform 모듈의 단위 테스트에는 실제 리소스 생성이 필요할 수 있습니다. 비용과 시간(밀리초가 아닌 분)이 소요되는 AWS. 피드백 루프가 길다. 테스트는 적절하게 처리되지 않으면 잠재적으로 파괴적이며 종속성은 클라우드 환경에서는 통합 테스트가 불가피합니다.

이러한 과제에도 불구하고 IaC 테스트는 필수입니다. Terraform 모듈 테스트되지 않은 것은 시한 폭탄입니다. 이 기사는 피라미드 IaC 테스트 세 가지 수준: 정적 테스트(빠르고 저렴함), Terraform 테스트(중간) 및 Terratest와의 통합 테스트(느리고 심층적)

IaC용 테스트 피라미드

  • 수준 1 - 정적(초): fmt, 유효성 검사, tflint, tfsec/checkov. AWS 비용이 없습니다.
  • 레벨 2 - 유닛/모의(분): mock_provider를 사용한 Terraform 테스트. 자산이 생성되지 않았습니다.
  • 레벨 3 - 통합(5~30분): Terratest: 실제 자산, 엔드투엔드 검증.

Terraform 테스트: 기본 프레임워크(1.6부터 정식 버전)

Terraform 1.6부터 프레임워크는 terraform test 일반 가용성입니다. 테스트 파일에는 확장자가 있습니다. .tftest.hcl 테스트할 수 있게 해주세요 두 가지 모드의 모듈 인터페이스(입력/출력): 명령 적용 (실제 자원 생성) e 명령 계획 (계획만 가능, 비용 없음)

# 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

모의 공급자: 클라우드 자격 증명 없이 테스트

Terraform 1.7부터 모의 공급자 시뮬레이션할 수 있게 해주세요 실제 자격 증명이 없는 AWS/Azure/GCP 공급자. 계산된 값(예: ARN, ID)는 모의에서 자동으로 생성되거나 구성됩니다.

# 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: Go와의 통합 테스트

테라테스트 (github.com/gruntwork-io/terratest)는 Go 라이브러리입니다. 실제 클라우드 리소스를 생성하고 해당 동작을 확인하는 통합 테스트를 작성합니다. 완료되면 파괴하세요. 복잡한 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)")
}

계약 테스트: 모듈 인터페이스 검증

Il 계약 테스트 Terraform 모듈의 경우 계약 모듈(입력 및 출력)이 소비자의 기대를 충족합니다. 다음과 같은 경우에 유용합니다. 변경 사항으로 인해 소비자가 중단되지 않도록 하는 공유 모듈 저장소.

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

CI와의 통합: 테스트를 위한 GitHub 작업

# .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

통합 테스트 시간 최적화

테스트 비용과 시간을 줄이기 위한 전략

  • t.병렬(): Terratest 테스트를 병렬로 실행하여 총 시간. 각각 5분씩 10번의 테스트를 수행하므로 동시에 5분이면 충분합니다. 50 대신.
  • 경제 지역 사용: us-east-1은 AWS가 가장 많은 리전입니다. 경제적이며 가장 많은 서비스를 이용할 수 있습니다.
  • 적극적인 정리: 미국 defer terraform.Destroy() 항상 옵션을 생성한 후 첫 번째 작업으로 수행하여 당황하더라도 파괴됩니다.
  • 수정된 모듈만 테스트하세요. 미국 paths GitHub 액션에서 PR에서 실제로 변경된 모듈에 대해서만 테스트를 실행합니다.
  • 명령 = 계획 선호: 대부분의 테스트에서 논리, command = plan Terraform 테스트에서는 충분하며 리소스를 생성하지 않습니다.

팀을 위한 포괄적인 테스트 전략

수준 기구 어디로 변하는가 지속 AWS 비용
공전 fmt, 유효성 검사, tflint 모든 PR, 지역 10~30대 $0
보안 체코프, 트리비 모든 PR 30~60대 $0
단위 (계획) Terraform 테스트 계획 모든 PR 1~3분 $0
유닛(모의) 테라폼 테스트 -mock 모든 PR 1~5분 $0
완성 테라테스트 홍보 → 메인 10~30분 $0.10-2.00
계약 테라폼 테스트 -mock 각 홍보 모듈 2~5분 $0

결론 및 다음 단계

3계층 IaC 테스트 전략(정적, 단위/모의, 통합) 피드백 속도와 검증 깊이의 균형을 유지하세요. 새로운 프레임워크 terraform test 네이티브를 사용하면 기본 테스트에 액세스할 수 있습니다. 외부 종속성 없이 Terratest는 여전히 선택 도구로 남아 있습니다. 인프라의 실제 동작을 확인합니다.

시리즈의 다음 기사

  • 제6조: IaC 보안 — Checkov, Trivy 및 OPA 코드형 정책: 사전 적용 게이트의 자동 보안 검사 및 조직 정책.
  • 제7조: Terraform 다중 클라우드 — AWS + Azure + GCP 공유 모듈: 추상화 계층 및 다중 공급자 관리.