lesson ── terraform-production ── ~16 мин ── 5 шагов
Terraform 1.6 ships a built-in test runner. *.tftest.hcl files describe
scenarios through run blocks and assert checks. In this lesson you build
a bucket module, write tests for it in command = plan mode
(fast, no apply; the provider still comes up and reaches out to the
endpoint, which is safe on LocalStack) and in command = apply.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
cd /home/student/tf-tests/modules/bucket
cat > main.tf <<'EOF'
resource "aws_s3_bucket" "this" {bucket = var.name
tags = merge(
{ ManagedBy = "terraform" },var.tags,
)
}
EOF
cat > variables.tf <<'EOF'
variable "name" {type = string
validation {condition = length(var.name) >= 3 && length(var.name) <= 63
error_message = "bucket name must be 3-63 chars"
}
}
variable "tags" {type = map(string)
default = {}}
EOF
cat > outputs.tf <<'EOF'
output "id" {value = aws_s3_bucket.this.id
}
EOF
The module is ready. It has one input name (with validation), optional
tags, and an id output.
✓ The bucket module is written. Now we'll cover it with tests.
Tests sit next to the module. The convention is a tests/ directory:
mkdir -p /home/student/tf-tests/modules/bucket/tests
cat > /home/student/tf-tests/modules/bucket/tests/naming.tftest.hcl <<'EOF'
variables {name = "my-test-bucket"
}
run "bucket_name_propagates" {command = plan
assert {condition = aws_s3_bucket.this.bucket == "my-test-bucket"
error_message = "var.name does not reach the resource"
}
}
run "managedby_tag_set" {command = plan
assert {condition = aws_s3_bucket.this.tags["ManagedBy"] == "terraform"
error_message = "ManagedBy tag missing"
}
}
run "custom_tags_merged" {command = plan
variables { tags = { Owner = "ci" }}
assert {condition = aws_s3_bucket.this.tags["Owner"] == "ci"
error_message = "Custom tag not merged"
}
}
EOF
Run it:
cd /home/student/tf-tests/modules/bucket
terraform init -backend=false
terraform test
You should see "3 passed, 0 failed". All three tests run in plan mode, so no cloud is needed.
✓ Three tests passed. Plan-only, fast.
Validation has to fire. Here is a test for it:
cat > /home/student/tf-tests/modules/bucket/tests/validation.tftest.hcl <<'EOF'
run "rejects_too_short_name" {command = plan
variables {name = "ab"
}
expect_failures = [
var.name,
]
}
run "rejects_too_long_name" {command = plan
variables {name = "this-name-is-way-too-long-and-should-fail-because-its-over-63-characters-easy"
}
expect_failures = [
var.name,
]
}
EOF
terraform test
You should see "5 passed". These two tests pass because validation really did fail, which is exactly what we expected.
✓ Validation is checked. The tests catch the inverse cases.
Plan-only sees only the static side. To check that the resource is really created (for example, that the id matches the name), you need apply. The provider in the test must point at LocalStack.
cat > /home/student/tf-tests/modules/bucket/tests/apply.tftest.hcl <<'EOF'
provider "aws" {region = "us-east-1"
access_key = "test"
secret_key = "test"
s3_use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {s3 = "http://localstack:4566"
iam = "http://localstack:4566"
sts = "http://localstack:4566"
}
}
run "real_apply_creates_bucket" {command = apply
variables {name = "test-apply-bucket"
}
assert {condition = aws_s3_bucket.this.id == "test-apply-bucket"
error_message = "id should equal name for s3 bucket"
}
assert {condition = length(output.id) > 0
error_message = "output.id is empty"
}
}
EOF
terraform test
You should see "6 passed". The apply test really created a bucket in LocalStack and removed it right after, the runner does a destroy at the end.
✓ The apply test passed. The bucket was created and torn down within a single test.
OpenTofu keeps the CLI and state compatible with Terraform for the
commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back
up the state and do a run on a feature branch, the differences
cluster in the newer features (variables in backend,
state encryption, OCI registry-backed modules). See
tf-opentofu-parity for the full matrix.
While developing a test there is no point running all the others. Filter:
terraform test -filter=tests/validation.tftest.hcl
Runs only the validation tests and skips the rest. A time saver during long apply scenarios.
Several filters:
terraform test \
-filter=tests/naming.tftest.hcl \
-filter=tests/validation.tftest.hcl
Just these two files, the apply test will not run.
✓ Filter works. In a CI pipeline this gives you fast PR checks separately from the long integration tests.
A test is a commitment. Each one has to be maintained. Antipatterns:
output "id" { value = ... }
there is nothing to test in asserting the output equals the ARN; if that broke,
plan would not run at all.What to test:
merge, format, conditional).expect_failures).no diff after refactor, golden plan).See iac-testing-theory.
.tftest.hcl files live next to the module. run is a scenario, assert
is a check. command = plan for fast unit tests, command = apply
for integration tests. expect_failures for inverse checks
(that validation fired).
команды
terraform testrun all *.tftest.hcl.terraform test -filter=tests/foo.tftest.hclone file only, handy while developing a test.terraform test -verboseshows the plan output of each run, for debugging.концепции