Skip to main content

Contract Testing

Contract testing = ตรวจว่า module interface (inputs/outputs) ตรงกับที่ caller คาดหวัง — ไม่เกี่ยวกับ behavior ภายใน

Contract Testing คืออะไร?

ใน software contract = "promise" ที่ component หนึ่งให้กับ caller:

  • Input ที่ accept
  • Output ที่ return
  • Side effects

ใน Terraform module:

  • Input variables ที่รับ (name, type, validation)
  • Output values ที่ return
  • Resources ที่สร้าง

ถ้า contract เปลี่ยน → caller จะ break

ทำไมต้อง Contract Test?

ตัวอย่าง:

  • Module v1 มี output bucket_name
  • Module v2 rename เป็น bucket_id
  • Caller ที่ใช้ module.x.bucket_name → break ❌

→ Contract test จับได้ก่อน release

Test ด้วย terraform test

tests/contract.tftest.hcl
variables {
name = "test"
}

mock_provider "aws" {}

# Test required inputs
run "required_inputs" {
command = plan

variables {
name = null
}

expect_failures = [var.name]
}

# Test output existence
run "outputs_exist" {
command = plan

assert {
condition = output.bucket_id != null
error_message = "Output 'bucket_id' must exist"
}

assert {
condition = output.bucket_arn != null
error_message = "Output 'bucket_arn' must exist"
}

assert {
condition = output.bucket_region != null
error_message = "Output 'bucket_region' must exist"
}
}

# Test output types
run "output_types" {
command = plan

assert {
condition = can(regex("^arn:aws:s3:::", output.bucket_arn))
error_message = "bucket_arn must be S3 ARN format"
}
}

Test กับ Mock Module

ทดสอบ contract โดย mock dependencies:

mock_provider "aws" {
override_resource {
target = aws_s3_bucket.this
values = {
id = "mocked-bucket"
arn = "arn:aws:s3:::mocked-bucket"
region = "us-east-1"
}
}
}

run "contract_via_mock" {
command = plan

assert {
condition = output.bucket_id == "mocked-bucket"
error_message = "Module should output bucket id"
}
}

Contract Examples

ตัวอย่าง 1: Required Variables

# Module: modules/vpc/variables.tf
variable "name" {
type = string
description = "VPC name"
}

variable "cidr_block" {
type = string
description = "CIDR block"
}
tests/contract.tftest.hcl
mock_provider "aws" {}

run "missing_name_fails" {
command = plan
variables {
name = null
cidr_block = "10.0.0.0/16"
}
expect_failures = [var.name]
}

run "missing_cidr_fails" {
command = plan
variables {
name = "test"
cidr_block = null
}
expect_failures = [var.cidr_block]
}

run "valid_inputs_succeed" {
command = plan
variables {
name = "test"
cidr_block = "10.0.0.0/16"
}
}

ตัวอย่าง 2: Output Schema

# Module: modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}

output "vpc_cidr" {
value = aws_vpc.main.cidr_block
}

output "subnet_ids" {
value = aws_subnet.public[*].id
}
run "outputs_schema" {
command = plan
variables {
name = "test"
cidr_block = "10.0.0.0/16"
}

assert {
condition = can(regex("^vpc-", output.vpc_id))
error_message = "vpc_id must be VPC ID format (vpc-*)"
}

assert {
condition = output.vpc_cidr == "10.0.0.0/16"
error_message = "vpc_cidr must echo input"
}

assert {
condition = length(output.subnet_ids) > 0
error_message = "subnet_ids must be non-empty list"
}

assert {
condition = alltrue([for id in output.subnet_ids : can(regex("^subnet-", id))])
error_message = "All subnet_ids must be subnet ID format"
}
}

ตัวอย่าง 3: Backwards Compatibility

ทดสอบว่า version ใหม่ไม่ break interface เดิม:

run "v1_contract_still_works" {
command = plan

variables {
name = "test"
cidr_block = "10.0.0.0/16"
# ไม่ส่ง variable ใหม่ที่เพิ่งเพิ่ม
}

assert {
condition = output.vpc_id != null
error_message = "v1 contract: vpc_id output broken"
}

assert {
condition = output.subnet_ids != null
error_message = "v1 contract: subnet_ids output broken"
}
}

Test Snapshot Pattern

ใช้ terraform_data เก็บ snapshot ของ outputs:

resource "terraform_data" "snapshot" {
input = {
inputs_hash = sha1(jsonencode({
name = var.name
cidr_block = var.cidr_block
}))
outputs_hash = sha1(jsonencode({
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.public[*].id
}))
}
}

Test against Real Examples

modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── examples/
│ └── basic/
│ └── main.tf # ← real example
└── tests/
└── contract.tftest.hcl
tests/contract.tftest.hcl
run "test_basic_example" {
command = plan

module {
source = "./examples/basic"
}

assert {
condition = length(module.vpc.subnet_ids) >= 2
error_message = "Basic example should have >=2 subnets"
}
}

Test Module Composability

run "module_composes_correctly" {
command = plan

module {
source = "./examples/with-rds" # uses both vpc + rds modules
}

assert {
condition = module.rds.subnet_ids == module.vpc.private_subnet_ids
error_message = "RDS should use VPC private subnets"
}
}

Contract Testing in CI

.github/workflows/contract-tests.yml
on:
pull_request:
paths: ["modules/**"]

jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3

- name: Find changed modules
id: changes
run: |
MODULES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} | \
grep '^modules/' | cut -d/ -f1-2 | sort -u)
echo "modules=$MODULES" >> $GITHUB_OUTPUT

- name: Run contract tests
run: |
for mod in ${{ steps.changes.outputs.modules }}; do
cd $mod
terraform test -filter=tests/contract.tftest.hcl
cd -
done

Best Practices

✅ DO:
- ทดสอบ required vs optional inputs
- ทดสอบทุก output (existence + format)
- ทดสอบ validation rules
- ใช้ mock_provider เพื่อ test เร็ว
- Run contract tests ใน CI ก่อน merge

❌ DON'T:
- ห้ามทดสอบ implementation detail (provider's job)
- ห้ามทดสอบ behavior ของ AWS API
- ห้ามรัน apply tests สำหรับ contract — ใช้ plan + mock พอ

เปรียบเทียบ

Test TypePurposeSpeedCost
ContractInterface compatibility⚡ FastFree
UnitInternal logic⚡ FastFree
IntegrationReal cloud behavior🐢 Slow$$
E2EFull deployment🐢🐢 Very slow$$$

สรุป

  • Contract testing = ตรวจ inputs/outputs interface
  • ใช้ terraform test + mock_provider
  • ทดสอบ: required inputs, output existence, output format, backwards compat
  • รันใน CI ก่อน merge — กัน breaking changes

ต่อไป → Integration Testing