Skip to main content

Integration Testing

ทดสอบ Terraform ด้วยการ apply ใน real cloud แล้วตรวจ behavior — ใช้ Terratest หรือ terraform test

ทำไม Integration Test?

Plan + mock = ดี แต่ไม่จับ:

  • IAM permission errors
  • Region availability
  • API rate limits
  • Race conditions
  • Real provider validation

→ Integration test ทำใน real cloud (เสียเงินนิดหน่อย)

Tool: Terratest

Terratest = Go library โดย Gruntwork — เป็นที่นิยมที่สุด

Install

mkdir test
cd test
go mod init terratest

go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/stretchr/testify/assert

Basic Terratest

test/vpc_test.go
package test

import (
"testing"

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

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

// Setup
opts := &terraform.Options{
TerraformDir: "../examples/basic",
Vars: map[string]interface{}{
"name": "test-vpc",
"cidr_block": "10.99.0.0/16",
},
}

// Cleanup at end
defer terraform.Destroy(t, opts)

// Apply
terraform.InitAndApply(t, opts)

// Assertions
vpcID := terraform.Output(t, opts, "vpc_id")
assert.Contains(t, vpcID, "vpc-")

cidr := terraform.Output(t, opts, "vpc_cidr")
assert.Equal(t, "10.99.0.0/16", cidr)
}

รัน:

cd test
go test -v -timeout 30m

Test Real AWS Resources

package test

import (
"fmt"
"testing"

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

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

awsRegion := "us-east-1"
bucketName := fmt.Sprintf("terratest-%s", random.UniqueId())

opts := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"bucket_name": bucketName,
"region": awsRegion,
},
}

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

// Use AWS SDK to verify
aws.AssertS3BucketExists(t, awsRegion, bucketName)
aws.AssertS3BucketVersioningExists(t, awsRegion, bucketName)
aws.AssertS3BucketPolicyExists(t, awsRegion, bucketName)
}

Test EC2 Instance

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

awsRegion := "us-east-1"
keyPair := aws.CreateAndImportEC2KeyPair(t, awsRegion, "terratest-key")
defer aws.DeleteEC2KeyPair(t, keyPair)

opts := &terraform.Options{
TerraformDir: "../examples/basic",
Vars: map[string]interface{}{
"key_name": keyPair.Name,
},
}

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

publicIP := terraform.Output(t, opts, "public_ip")

// SSH into instance
ssh.CheckSshConnection(t, ssh.Host{
Hostname: publicIP,
SshUserName: "ec2-user",
SshKeyPair: keyPair.KeyPair,
})
}

Test HTTP Endpoint

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

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

opts := &terraform.Options{
TerraformDir: "../examples/web-app",
}

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

url := terraform.Output(t, opts, "url")

// Wait for endpoint to be reachable
http_helper.HttpGetWithRetry(
t, url, nil, 200, "Hello", 30, 5*time.Second,
)
}

Test Strategies

Strategy 1: Run Once, Multiple Tests

func TestStages(t *testing.T) {
// Setup once
opts := &terraform.Options{TerraformDir: "../"}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)

t.Run("test_outputs", func(t *testing.T) {
vpcID := terraform.Output(t, opts, "vpc_id")
assert.Contains(t, vpcID, "vpc-")
})

t.Run("test_resources", func(t *testing.T) {
aws.AssertVpcExists(t, "us-east-1", terraform.Output(t, opts, "vpc_id"))
})
}

Strategy 2: Test Stages (Reusable)

func TestWithStages(t *testing.T) {
opts := &terraform.Options{TerraformDir: "../"}

// Stage: deploy
test_structure.RunTestStage(t, "deploy", func() {
terraform.InitAndApply(t, opts)
})

// Stage: validate
test_structure.RunTestStage(t, "validate", func() {
aws.AssertS3BucketExists(t, "us-east-1", terraform.Output(t, opts, "bucket"))
})

// Stage: cleanup
defer test_structure.RunTestStage(t, "cleanup", func() {
terraform.Destroy(t, opts)
})
}

รัน:

SKIP_cleanup=true go test -v   # skip destroy เพื่อ debug

Strategy 3: Table-Driven Tests

func TestVariations(t *testing.T) {
cases := []struct {
name string
instanceType string
expectError bool
}{
{"valid_micro", "t3.micro", false},
{"valid_large", "t3.large", false},
{"invalid_legacy", "t1.nano", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{"instance_type": tc.instanceType},
}

_, err := terraform.InitAndPlanE(t, opts)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

Random Naming (Avoid Conflicts)

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

bucketName := fmt.Sprintf("test-%s", random.UniqueId())
// → "test-aBc123" — unique each run

Cleanup with defer

func TestSomething(t *testing.T) {
opts := &terraform.Options{TerraformDir: "../"}

defer terraform.Destroy(t, opts) // ← always cleanup
terraform.InitAndApply(t, opts)

// Test logic — even if assertion fails, defer runs
assert.Equal(t, ...)
}

CI/CD Integration

.github/workflows/integration.yml
on:
pull_request:
paths: ["**.tf", "test/**"]

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

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

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

- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TEST_ROLE_ARN }}
aws-region: us-east-1

- name: Run Integration Tests
working-directory: ./test
run: go test -v -timeout 30m

Best Practices

✅ DO:
- t.Parallel() เพื่อ run tests ขนานกัน
- defer terraform.Destroy() เสมอ
- ใช้ random naming หลีกเลี่ยง conflict
- Test ใน dedicated AWS account (ไม่ใช่ prod!)
- Set timeout (30m+ for complex modules)
- Test cleanup logic เอง (test destroy doesn't error)

❌ DON'T:
- ห้ามรัน integration tests ใน prod account
- ห้าม skip defer destroy (resource leak!)
- ห้าม share state file ระหว่าง tests
- ห้ามใช้ static names (conflict)

ทางเลือก: Kitchen-Terraform

kitchen-terraform = Ruby alternative

.kitchen.yml
driver:
name: terraform

provisioner:
name: terraform

verifier:
name: terraform

platforms:
- name: aws

suites:
- name: default

ทางเลือก: terraform test (built-in)

tests/integration.tftest.hcl
run "real_deployment" {
command = apply

variables {
name = "integration-test"
}

assert {
condition = aws_s3_bucket.main.region == "us-east-1"
error_message = "Bucket should be in us-east-1"
}
}

→ รัน apply จริงใน AWS — Terraform จัดการ destroy เอง

เปรียบเทียบ Tools

ToolProsCons
terraform testBuilt-in, no extra depsLimited assertions
TerratestPowerful Go library, lots of helpersต้องเขียน Go
Kitchen-TerraformRuby/InSpecLess popular now

แนะนำ: terraform test สำหรับ basic, Terratest สำหรับ complex

ตัวอย่าง Mature Test Suite

package test

import (
"fmt"
"testing"
"time"

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

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

region := "us-east-1"
uniqueId := random.UniqueId()

opts := &terraform.Options{
TerraformDir: "../examples/basic",
Vars: map[string]interface{}{
"name": fmt.Sprintf("test-%s", uniqueId),
"region": region,
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": region,
},
NoColor: true,
}

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

// Test 1: ALB exists and is active
albArn := terraform.Output(t, opts, "alb_arn")
alb := aws.GetAlbByArn(t, region, albArn)
assert.Equal(t, "active", aws.ToString(alb.State.Code))

// Test 2: Target group has registered targets
tgArn := terraform.Output(t, opts, "target_group_arn")
// wait for instances to register
time.Sleep(60 * time.Second)
health := aws.GetTargetGroupHealth(t, region, tgArn)
assert.NotEmpty(t, health.TargetHealthDescriptions)

// Test 3: HTTP endpoint responds
url := terraform.Output(t, opts, "url")
http_helper.HttpGetWithRetry(t, url, nil, 200, "Hello", 30, 5*time.Second)

// Test 4: Tags applied
asgName := terraform.Output(t, opts, "asg_name")
asg := aws.GetAsgByName(t, region, asgName)
hasEnvTag := false
for _, tag := range asg.Tags {
if aws.ToString(tag.Key) == "Environment" {
hasEnvTag = true
break
}
}
assert.True(t, hasEnvTag, "ASG must have Environment tag")
}

สรุป

  • Integration testing = test ด้วย apply ใน real cloud
  • ใช้ Terratest (Go) หรือ terraform test (built-in)
  • defer terraform.Destroy() เสมอ — กัน resource leak
  • Test parallel + random naming
  • Run ใน dedicated test account — ไม่ใช่ prod

ต่อไป → End-to-End Testing