Skip to main content

Testing Modules

สรุป best practices การ test Terraform module — combine unit + contract + integration

Module Test Strategy

Test Pyramid for Modules

TestWhatSpeedFrequency
UnitPlan logic, mocks⚡ FastEvery commit
ContractInterface stability⚡ FastEvery commit
IntegrationReal cloud behavior🐢 SlowPre-release
E2EModule + app🐢🐢 Very slowManual / 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