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 Type | Purpose | Speed | Cost |
|---|---|---|---|
| Contract | Interface compatibility | ⚡ Fast | Free |
| Unit | Internal logic | ⚡ Fast | Free |
| Integration | Real cloud behavior | 🐢 Slow | $$ |
| E2E | Full 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