Terraform Testing ด้วย Terratest และ terraform test

เมื่อ infrastructure ถูกเขียนด้วย code การทดสอบโค้ด IaC ก็ควรเข้มงวดไม่ต่างจากการทดสอบ application code การใช้ plan และ validate เพียงอย่างเดียวไม่เพียงพอ เพราะไม่สามารถยืนยันได้ว่า resource ที่สร้างขึ้นทำงานตามที่ตั้งใจไว้จริงหรือไม่ การทดสอบแบบอัตโนมัติด้วย Terratest หรือ framework อื่น ๆ จะช่วยให้มั่นใจว่า module ของเรา deploy ได้จริง และมี behavior ที่ถูกต้อง

บทความนี้จะแนะนำแนวทางการทดสอบโค้ด IaC ตั้งแต่ concept พื้นฐาน, การใช้งาน Terratest ซึ่งเป็น library ยอดนิยมจาก Gruntwork, การใช้ terraform test built-in framework (ที่เพิ่มเข้ามาใน 1.6), และ framework ทางเลือกอื่น ๆ พร้อมตัวอย่างการเขียนเทสจริง

ทำไมต้องเขียนเทสสำหรับ IaC

การ validate และ plan บอกเราได้ว่าโค้ดไม่มี syntax error และ plan จะทำอะไร แต่ไม่สามารถบอกได้ว่าผลลัพธ์หลัง apply จะใช้งานได้จริงหรือไม่ การเทสที่ดีควรครอบคลุมเรื่องเหล่านี้:

  • Deploy สำเร็จ: module ที่เขียนสามารถ apply ได้โดยไม่มี error
  • Output ถูกต้อง: ค่า output ตรงกับที่คาดหวัง
  • Resource behavior: เช่น EC2 ที่สร้างต้อง SSH เข้าได้, load balancer ต้องตอบ HTTP 200
  • Edge case: จัดการกรณีพิเศษได้ดี เช่น empty list, null value, invalid input
  • Regression prevention: เมื่อแก้ไขแล้วไม่ทำให้ feature เดิมพัง

Terratest คืออะไร

Terratest เป็น Go library ที่ออกแบบมาสำหรับเขียนเทส IaC โดยใช้ภาษา Go เน้นการทดสอบแบบ end-to-end คือการ apply จริง, ตรวจสอบ, แล้ว destroy ทำให้สามารถทดสอบกับ cloud provider ของจริงได้ไม่ใช่แค่ mock

จุดเด่นของ Terratest:

  • ใช้ Go ซึ่งเป็นภาษาที่มี ecosystem ครบครัน
  • รองรับหลาย tool: Terraform, Packer, Docker, Kubernetes
  • มี helper function เยอะ สำหรับ AWS, GCP, Azure, SSH, HTTP check
  • Deploy → verify → cleanup โดยอัตโนมัติ
  • ใช้ร่วมกับ Go testing framework ได้เลย

ข้อจำกัด

  • ต้องเขียน Go ซึ่งทีมบางส่วนอาจไม่คุ้นเคย
  • เทสใช้เวลานาน (5–30 นาทีต่อเทส) เพราะต้อง apply จริง
  • มีค่าใช้จ่ายจาก cloud resource ที่ถูกสร้างระหว่างเทส

การติดตั้ง Terratest

ต้องมี Go 1.18+ ติดตั้งก่อน จากนั้นสร้าง Go module และเพิ่ม Terratest เป็น dependency:

# สร้างโฟลเดอร์ test
mkdir -p test
cd test

# Initialize Go module
go mod init github.com/myorg/terraform-module/test

# เพิ่ม Terratest dependency
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert

ตัวอย่างเทสแรก

สมมติมี module ง่าย ๆ ที่สร้าง S3 bucket และ output bucket name ไว้ที่ examples/simple เทสพื้นฐานจะมีลักษณะดังนี้:

// test/simple_s3_test.go
package test

import (
    "testing"

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

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

    opts := &terraform.Options{
        TerraformDir: "../examples/simple",
        Vars: map[string]interface{}{
            "bucket_name_prefix": "test-terratest",
        },
        NoColor: true,
    }

    // ลบ resource หลังเทสจบ
    defer terraform.Destroy(t, opts)

    // apply module
    terraform.InitAndApply(t, opts)

    // ตรวจสอบ output
    bucketName := terraform.Output(t, opts, "bucket_name")
    assert.Contains(t, bucketName, "test-terratest")
}

การรันเทส

# ต้องมี AWS credentials ใน environment
export AWS_ACCESS_KEY_ID=xxx
export AWS_SECRET_ACCESS_KEY=yyy
export AWS_DEFAULT_REGION=ap-southeast-1

# รันเทส
cd test
go test -v -timeout 30m

# รันเทสเดียว
go test -v -run TestSimpleS3Module -timeout 30m

Pattern ที่นิยมใช้

การตรวจสอบ Resource บน AWS

Terratest มี helper สำหรับ query resource จริงบน AWS ช่วยให้ตรวจสอบ property ของ resource ที่สร้างขึ้นได้:

import (
    "github.com/gruntwork-io/terratest/modules/aws"
)

func TestEC2Module(t *testing.T) {
    opts := &terraform.Options{TerraformDir: "../examples/ec2"}
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    instanceID := terraform.Output(t, opts, "instance_id")
    region := "ap-southeast-1"

    // ตรวจว่าสถานะ running
    aws.AssertInstanceStateRunning(t, instanceID, region)

    // ตรวจ tags
    tags := aws.GetTagsForEc2Instance(t, region, instanceID)
    assert.Equal(t, "production", tags["Environment"])
}

HTTP Endpoint Check

เมื่อสร้าง web server หรือ load balancer ควรตรวจว่าตอบสนองได้จริง:

import (
    "github.com/gruntwork-io/terratest/modules/http-helper"
    "time"
)

func TestLoadBalancer(t *testing.T) {
    opts := &terraform.Options{TerraformDir: "../examples/lb"}
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    lbURL := terraform.Output(t, opts, "load_balancer_url")

    // รอให้ LB พร้อมแล้วค่อย check
    http_helper.HttpGetWithRetry(
        t, lbURL, nil,
        200,         // expected status
        "healthy",   // expected body content
        30,          // retries
        10*time.Second,
    )
}

SSH และการรัน Command บนเครื่อง

import (
    "github.com/gruntwork-io/terratest/modules/ssh"
)

func TestEC2SSH(t *testing.T) {
    opts := &terraform.Options{TerraformDir: "../examples/ec2"}
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)

    publicIP := terraform.Output(t, opts, "public_ip")
    keyPair := &ssh.KeyPair{
        PublicKey:  terraform.Output(t, opts, "public_key"),
        PrivateKey: terraform.Output(t, opts, "private_key"),
    }

    host := ssh.Host{
        Hostname:    publicIP,
        SshUserName: "ubuntu",
        SshKeyPair:  keyPair,
    }

    output := ssh.CheckSshCommand(t, host, "hostname")
    assert.NotEmpty(t, output)
}

Random Name เพื่อหลีกเลี่ยง Collision

เมื่อรันเทสขนานกันหลายตัว ต้องแน่ใจว่าชื่อ resource ไม่ชนกัน Terratest มี helper random.UniqueId():

import (
    "github.com/gruntwork-io/terratest/modules/random"
    "strings"
)

uniqueID := strings.ToLower(random.UniqueId())
bucketName := fmt.Sprintf("test-bucket-%s", uniqueID)

Test Stage: แยกขั้นตอน Deploy/Verify

สำหรับเทสที่ใช้เวลานาน Terratest มี test_structure ช่วยให้แยกขั้นตอน deploy, verify และ cleanup ได้ เพื่อตอน debug สามารถ skip stage ที่ไม่ต้องการรันซ้ำได้:

import (
    "github.com/gruntwork-io/terratest/modules/test-structure"
)

func TestStaged(t *testing.T) {
    workDir := "../examples/stage"

    // Stage: Destroy (run เสมอท้ายสุด)
    defer test_structure.RunTestStage(t, "cleanup", func() {
        opts := test_structure.LoadTerraformOptions(t, workDir)
        terraform.Destroy(t, opts)
    })

    // Stage: Deploy
    test_structure.RunTestStage(t, "deploy", func() {
        opts := &terraform.Options{TerraformDir: workDir}
        test_structure.SaveTerraformOptions(t, workDir, opts)
        terraform.InitAndApply(t, opts)
    })

    // Stage: Validate
    test_structure.RunTestStage(t, "validate", func() {
        opts := test_structure.LoadTerraformOptions(t, workDir)
        bucket := terraform.Output(t, opts, "bucket_name")
        assert.NotEmpty(t, bucket)
    })
}

สามารถ skip stage ด้วย environment variable:

# รันเฉพาะ deploy กับ validate (ยังไม่ cleanup)
SKIP_cleanup=true go test -v

# รันเฉพาะ validate (ใช้ state ที่ deploy ไว้แล้ว)
SKIP_deploy=true SKIP_cleanup=true go test -v

terraform test: Built-in Framework

ตั้งแต่เวอร์ชัน 1.6 เป็นต้นมา HashiCorp เพิ่ม terraform test command ที่ให้เขียนเทสเป็น HCL ได้โดยตรง ไม่ต้องใช้ Go เหมาะสำหรับทีมที่ไม่ต้องการเรียนภาษาใหม่

โครงสร้างไฟล์

module/
├── main.tf
├── variables.tf
├── outputs.tf
└── tests/
    ├── simple.tftest.hcl
    └── invalid_input.tftest.hcl

ตัวอย่าง Test File

# tests/simple.tftest.hcl
variables {
  bucket_name = "test-bucket-simple"
}

run "check_bucket_output" {
  command = apply

  assert {
    condition     = output.bucket_name == "test-bucket-simple"
    error_message = "Bucket name output ไม่ถูกต้อง"
  }

  assert {
    condition     = output.bucket_arn != ""
    error_message = "ต้องมี ARN ของ bucket"
  }
}

run "validate_naming_rule" {
  command = plan

  variables {
    bucket_name = "BAD-NAME-UPPER"
  }

  expect_failures = [
    var.bucket_name,
  ]
}

การรัน

# รันทุกไฟล์ใน tests/
terraform test

# รันเฉพาะไฟล์เดียว
terraform test -filter=tests/simple.tftest.hcl

# Verbose
terraform test -verbose

Mock Provider

Built-in framework รองรับ mock provider ทำให้เทสทำงานเร็วโดยไม่ต้องสร้าง resource จริง:

mock_provider "aws" {
  mock_resource "aws_s3_bucket" {
    defaults = {
      arn = "arn:aws:s3:::mock-bucket"
    }
  }
}

run "test_with_mock" {
  command = plan
  assert {
    condition = aws_s3_bucket.this.arn != ""
    error_message = "Mocked ARN should not be empty"
  }
}

Framework ทางเลือก

  • Kitchen-Terraform: ใช้ Test Kitchen (Ruby) + InSpec เหมาะกับทีมที่คุ้นเคย Chef/Ruby
  • Checkov / TFLint: Static analysis (ไม่ apply จริง) เน้น security + best practice ครอบคลุมเร็วแต่ไม่ลึก
  • OPA / Conftest: Policy testing — ตรวจว่า plan ตรง policy
  • Pytest + python-terraform: เขียน Python แทน Go
  • go-terraform-test / terraform-plan-tester: library เล็ก ๆ สำหรับเทส plan

การรันเทสใน CI

เทส IaC ควรรันในช่วงที่เหมาะสม ไม่ใช่ทุก PR เพราะใช้เวลาและเงิน แนวทางที่ดีคือ:

  • Static analysis: รันทุก PR (fmt, validate, TFLint, Checkov) — เร็วมาก
  • Unit test (mock): รันทุก PR — เร็วพอสมควร
  • Integration test (apply จริง): รัน nightly หรือเฉพาะเมื่อ merge สู่ main
  • Stage/Canary deploy: deploy ไปยัง test environment หลัง merge main แล้วรันเทสอีกรอบ

ตัวอย่าง GitHub Actions

name: Terratest

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  terratest:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.21'

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.TEST_AWS_ROLE }}
          aws-region: ap-southeast-1

      - name: Run Terratest
        run: |
          cd test
          go mod download
          go test -v -timeout 45m ./...

Best Practices

  • ใช้ account แยก: test account ควรแยกจาก production เพื่อไม่กระทบ resource จริง
  • Cleanup เสมอ: ใช้ defer terraform.Destroy เพื่อกัน resource ค้าง
  • Random suffix: ใช้ random.UniqueId() กัน naming collision
  • Set timeout: บังคับ timeout ทุก test ไม่ให้ค้าง
  • Region ที่ถูก: บาง resource มี limit ต่างกัน เลือก region ที่รองรับทุกอย่างที่เทส
  • ไม่ใช้ credentials จริง: ใช้ OIDC, role assumption หรือ test credentials เท่านั้น
  • Parallelize ระวัง: ใช้ t.Parallel() เมื่อเทสไม่มี resource ชนกัน
  • ทดสอบทุก example: ทุกตัวอย่างใน examples/ ควรมี test ประกบ
  • Monitor cost: ตั้ง budget alert กัน cloud bill บาน

สรุป

การเขียนเทสสำหรับโค้ด IaC ทำให้มั่นใจว่า module deploy ได้จริง และ behavior ถูกต้องตามที่คาดหวัง Terratest เป็นตัวเลือกหลักสำหรับ integration test ด้วย Go ที่มี helper หลากหลายครอบคลุมทั้ง cloud provider และ protocol เช่น HTTP, SSH, Kubernetes ส่วน built-in framework เหมาะกับทีมที่ไม่ต้องการเรียนภาษาใหม่ ต้องการเทสแบบ unit test เบา ๆ และอยู่ใกล้กับโค้ด IaC โดยไม่ต้องสลับไปมาระหว่างสองภาษา

การผสม static analysis (TFLint, Checkov), unit test (built-in framework + mock), และ integration test (Terratest) จะได้ coverage ที่ครอบคลุมที่สุด ทำให้สามารถปล่อย module ให้ทีมใช้งานได้อย่างมั่นใจ และลด incident ที่เกิดจาก infrastructure change ได้อย่างมีประสิทธิภาพ นอกจากนี้ควรเริ่มจากเทสง่าย ๆ ก่อน เช่น ตรวจว่า output ไม่ว่าง หรือ resource จำนวนเท่าที่คาดไว้ ก่อนขยายไปยังเทสที่ตรวจ behavior ของ infrastructure ทั้งหมด เพื่อให้ feedback loop เร็วและ maintain code ได้สะดวก ทีมที่เริ่ม testing infrastructure มักได้ประโยชน์ภายใน sprint แรกจากการจับ bug ที่ไม่เคยเห็นจาก plan เพียงอย่างเดียว