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
| Tool | Pros | Cons |
|---|---|---|
| terraform test | Built-in, no extra deps | Limited assertions |
| Terratest | Powerful Go library, lots of helpers | ต้องเขียน Go |
| Kitchen-Terraform | Ruby/InSpec | Less 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