Testing Modules
สรุป best practices การ test Terraform module — combine unit + contract + integration
Module Test Strategy
Test Pyramid for Modules
| Test | What | Speed | Frequency |
|---|---|---|---|
| Unit | Plan logic, mocks | ⚡ Fast | Every commit |
| Contract | Interface stability | ⚡ Fast | Every commit |
| Integration | Real cloud behavior | 🐢 Slow | Pre-release |
| E2E | Module + app | 🐢🐢 Very slow | Manual / nightly |
Module Repo Structure (Tested)
terraform-aws-web-app/
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── examples/
│ ├── basic/
│ │ ├── main.tf
│ │ └── README.md
│ └── advanced/
│ └── main.tf
└── tests/
├── unit/ # plan + mock
│ └── plan.tftest.hcl
├── contract/ # inputs/outputs
│ └── contract.tftest.hcl
├── integration/ # real apply
│ └── integration.tftest.hcl
└── go/ # Terratest (Go)
├── go.mod
└── module_test.go
Test 1: Unit (terraform test + mock)
tests/unit/plan.tftest.hcl
mock_provider "aws" {}
variables {
name = "test"
environment = "dev"
vpc_id = "vpc-mock"
subnet_ids = ["subnet-1", "subnet-2"]
}
run "plan_succeeds" {
command = plan
}
run "production_uses_larger_instance" {
command = plan
variables {
environment = "prod"
}
assert {
condition = aws_launch_template.web.instance_type == "t3.large"
error_message = "Production should use t3.large"
}
}
run "dev_uses_smaller_instance" {
command = plan
variables {
environment = "dev"
}
assert {
condition = aws_launch_template.web.instance_type == "t3.micro"
error_message = "Dev should use t3.micro"
}
}
run "common_tags_applied" {
command = plan
assert {
condition = aws_launch_template.web.tag_specifications[0].tags["ManagedBy"] == "terraform"
error_message = "ManagedBy tag missing"
}
}
Test 2: Contract (Interface)
tests/contract/contract.tftest.hcl
mock_provider "aws" {}
variables {
name = "test"
vpc_id = "vpc-mock"
subnet_ids = ["subnet-1", "subnet-2"]
}
# Required inputs
run "name_required" {
command = plan
variables {
name = null
}
expect_failures = [var.name]
}
run "vpc_id_required" {
command = plan
variables {
vpc_id = null
}
expect_failures = [var.vpc_id]
}
# Output existence
run "all_outputs_exist" {
command = plan
assert {
condition = output.alb_dns_name != null
error_message = "alb_dns_name output missing"
}
assert {
condition = output.alb_arn != null
error_message = "alb_arn output missing"
}
assert {
condition = output.security_group_id != null
error_message = "security_group_id output missing"
}
}
# Subnet count validation
run "minimum_subnets" {
command = plan
variables {
subnet_ids = ["subnet-1"] # only 1
}
expect_failures = [var.subnet_ids]
}
Test 3: Integration (Real Cloud)
tests/integration/integration.tftest.hcl
run "real_deployment" {
command = apply
variables {
name = "tf-test-${formatdate("YYYYMMDDhhmmss", timestamp())}"
}
assert {
condition = startswith(aws_alb.this.dns_name, var.name)
error_message = "ALB DNS should start with name prefix"
}
}
หรือใช้ Terratest:
tests/go/module_test.go
package test
import (
"fmt"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestModuleBasic(t *testing.T) {
t.Parallel()
uniqueId := random.UniqueId()
opts := &terraform.Options{
TerraformDir: "../../examples/basic",
Vars: map[string]interface{}{
"name": fmt.Sprintf("test-%s", uniqueId),
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
url := terraform.Output(t, opts, "url")
http_helper.HttpGetWithRetry(
t, url, nil, 200, "OK", 30, 10*time.Second,
)
}
Test Multiple Examples
func TestModuleExamples(t *testing.T) {
examples := []struct {
name string
dir string
}{
{"basic", "../../examples/basic"},
{"advanced", "../../examples/advanced"},
{"multi_region", "../../examples/multi-region"},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
t.Parallel()
opts := &terraform.Options{
TerraformDir: ex.dir,
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
})
}
}
Test Module Versions (Backwards Compat)
func TestBackwardsCompat(t *testing.T) {
// Deploy with v1 inputs (no new optional params)
v1Opts := &terraform.Options{
TerraformDir: "../../examples/v1-style",
}
defer terraform.Destroy(t, v1Opts)
terraform.InitAndApply(t, v1Opts)
// Verify v1 outputs still exist
vpcID := terraform.Output(t, v1Opts, "vpc_id")
assert.NotEmpty(t, vpcID)
}
CI/CD for Module Repo
.github/workflows/module-tests.yml
name: Module Tests
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform test -filter=tests/unit/
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform test -filter=tests/contract/
integration:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [unit, contract]
if: github.event_name == 'push' # only on main, not PRs
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/setup-go@v5
with:
go-version: 1.22
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.TEST_ROLE_ARN }}
aws-region: us-east-1
- working-directory: tests/go
run: go test -v -timeout 30m
Pre-commit Hooks
.pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.96.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
- id: terraform_docs
pre-commit install
# ทุก commit จะรัน fmt + validate + lint อัตโนมัติ
Generate Docs (terraform-docs)
brew install terraform-docs
# Generate README from variables.tf + outputs.tf
terraform-docs markdown table --output-file README.md .
Module Release Process
1. Develop on feature branch
2. Run unit + contract tests (every commit)
3. Open PR
4. Run integration tests (CI)
5. Code review
6. Merge to main
7. Tag release: git tag v1.2.3
8. Push tag: git push --tags
9. Auto-publish to registry (Terraform Cloud / Public)
Best Practices
✅ DO:
- Test every example folder
- Run unit + contract tests on every PR
- Run integration tests before release
- Use random naming เพื่อ parallel tests
- defer Destroy() เสมอ
- Document test setup ใน README
❌ DON'T:
- ห้าม run integration tests on every commit (ช้า + costly)
- ห้าม skip cleanup
- ห้าม share AWS account with prod
- ห้าม commit test artifacts (.terraform/, tfstate)
ตัวอย่าง: README Section "Testing"
## Testing
### Unit Tests
\`\`\`bash
terraform test -filter=tests/unit/
\`\`\`
### Integration Tests
Requires AWS credentials in `us-east-1`:
\`\`\`bash
cd tests/go
go test -v -timeout 30m
\`\`\`
### Pre-commit Hooks
\`\`\`bash
pre-commit install
\`\`\`
สรุป
- Module testing layers: unit (mock) → contract (interface) → integration (real)
- CI/CD: unit+contract on every PR, integration pre-release
- ใช้ terraform test + Terratest + examples folder
- Pre-commit + terraform-docs + CI = mature workflow
- Test naming: random/unique + always cleanup
ต่อไป → Section 17: Scaling Terraform