IaC テストの問題点

インフラストラクチャのテストは、アプリケーション コードのテストとは根本的に異なります。 Terraform モジュールの単体テストでは、実際のリソースの作成が必要になる場合があります AWS では、お金と時間 (ミリ秒ではなく分単位) がかかります。フィードバックループが長いので、 テストは適切に処理されないと破壊的になる可能性があり、依存関係も クラウド環境での統合テストは避けられません。

これらの課題にもかかわらず、IaC テストは不可欠です: Terraform モジュール 検証されていないのは時限爆弾だ。この記事では、 ピラミッド IaC のテストの数 3 つのレベル: 静的テスト (高速、安価)、テスト terraform テスト (中)、Terratest との統合テスト (低速、詳細)。

IaC のテスト ピラミッド

  • レベル 1 - 静的 (秒): fmt、検証、tflint、tfsec/checkov。 AWS のコストはゼロです。
  • レベル 2 - ユニット/モック (分): mock_provider を使用した terraform テスト。アセットは作成されませんでした。
  • レベル 3 - 統合 (5 ~ 30 分): Terratest: 実際の資産、エンドツーエンドの検証。

Terraform テスト: ネイティブ フレームワーク (1.6 から GA)

Terraform 1.6 以降、フレームワーク terraform test それは一般提供です。 テストファイルには拡張子が付いています .tftest.hcl そしてテストできるようにします モジュール インターフェイス (入力/出力) には 2 つのモードがあります。 コマンド適用 (実際のリソースを作成する) 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.Parallel(): 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
ユニット(模擬) terraform テスト - モック あらゆるPR 1~5分 $0
統合 テラテスト 広報→メイン 10~30分 $0.10-2.00
契約 terraform テスト - モック 各PRモジュール 2~5分 $0

結論と次のステップ

3 層の IaC テスト戦略 (静的、ユニット/モック、統合) フィードバック速度と検証の深さのバランスを取る。新しい フレームワーク terraform test ネイティブなので基本的なテストにアクセスできる 外部依存関係がありませんが、Terratest は依然として最適なツールです。 インフラストラクチャの実際の動作を検証します。

シリーズの次の記事

  • 第6条: IaC セキュリティ — Checkov、Trivy、OPA コードとしてのポリシー: 事前適用ゲートでの自動セキュリティ スキャンと組織ポリシー。
  • 第7条: Terraform マルチクラウド — AWS + Azure + GCP を使用 共有モジュール: 抽象化レイヤーと複数のプロバイダー管理。