TL;DR: Every time you update a Terraform example, you should also update its corresponding test. Stop doing it twice. Point your tests at your examples directly. Terraform 1.6+ makes this native. Your docs stay correct automatically.
The Problem (Level 200)
I spent three years maintaining a Terraform S3 module. It had examples: basic, with versioning, with lifecycle policies. Each example lived in examples/. I also had tests in tests/. Separate. Independent. A disaster waiting to happen.
Then someone refactored the module's variable names. Simple change: enable_versioning → versioning_enabled. The examples got updated. The tests... didn't get the memo until CI blew up. Actually, that's not true—CI passed but the examples failed for actual users trying to copy-paste.
This is the core problem: Documentation lives in one place, validation lives in another. They drift. Slowly at first, then all at once.
You end up with:
- Examples that worked in v1.2 but break in v2.0
- Tests that pass but only validate old variable shapes
- Users following your docs and hitting errors
- Support tickets asking "does your example actually work?"
The overhead isn't just the initial maintenance. It's the debugging, the version management, the users who give up before opening an issue.
What Changed (Level 200)
Terraform 1.6 introduced a native testing framework. Not some external wrapper—native HCL tests.
The insight hit me: your examples ARE specifications. Why write them twice?
Instead of:
examples/basic/main.tf (documentation)
tests/basic_test.tf (validation)
← Separate. Drift. Nightmare.
You can have:
examples/basic/main.tf (documentation)
tests/basic_example.tftest.hcl (validates that example)
← One source. Always in sync.
Your test doesn't validate some abstract spec. It validates the exact example users will copy.
Here's the difference:
Old way:
# examples/basic/main.tf
module "s3" {
source = "../../"
bucket_name = "my-bucket"
environment = "dev"
}
# tests/basic_test.tf (separate, independent)
module "s3" {
source = "../../"
bucket_name = "my-bucket"
environment = "dev"
# Hope this matches the example
}
New way:
# examples/basic/main.tf
module "s3" {
source = "../../"
bucket_name = "my-bucket"
environment = "dev"
}
# tests/basic_example.tftest.hcl (tests the example directly)
run "basic_works" {
command = apply
module {
source = "./examples/basic" # ← Points at the example
}
assert {
condition = module.s3.bucket_id != ""
error_message = "Bucket should be created"
}
}
When you change the example, the test validates it immediately. No drift. No surprise failures.
Advanced Patterns (Level 400)
Pattern 1: Progressive Example Validation
Users learn by progression. Basic → intermediate → advanced. Test each step independently and verify the progression actually works:
# tests/progression.tftest.hcl
run "stage_1_basic" {
command = apply
module {
source = "./examples/basic"
}
assert {
condition = module.s3.bucket_versioning == false
error_message = "Basic: versioning should be disabled"
}
}
run "stage_2_with_versioning" {
command = apply
module {
source = "./examples/with-versioning"
}
assert {
condition = module.s3.bucket_versioning == true
error_message = "Intermediate: versioning should be enabled"
}
}
run "stage_3_complete" {
command = apply
module {
source = "./examples/with-lifecycle-and-versioning"
}
assert {
condition = module.s3.bucket_versioning == true
error_message = "Advanced: versioning required"
}
assert {
condition = length(module.s3.lifecycle_rules) >= 1
error_message = "Advanced: lifecycle rules required"
}
}
Benefit: Each example is independently valid. Progression is guaranteed. Users have a safe learning path.
Pattern 2: Configuration Variations Without Bloated Examples
Your module supports multiple encryption options. Don't bloat your example with every variation. Keep examples clean, test variations separately:
# examples/basic/main.tf (stays simple)
module "s3" {
source = "../../"
bucket_name = var.bucket_name
encryption_algorithm = var.encryption_algorithm # User controls this
}
# tests/encryption_options.tftest.hcl (tests each variation)
run "with_s3_managed_encryption" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "test-bucket-s3-managed"
encryption_algorithm = "AES256"
}
assert {
condition = module.s3.encryption_type == "S3-managed"
error_message = "Should use S3-managed encryption"
}
}
run "with_kms_encryption" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "test-bucket-kms"
encryption_algorithm = "aws:kms"
}
assert {
condition = module.s3.encryption_type == "KMS"
error_message = "Should use KMS encryption"
}
}
Benefit: Examples stay readable. Test coverage is comprehensive. Variations are documented through tests.
Pattern 3: Validate Constraints Early
Your module should reject invalid inputs. Test exactly what should fail:
# tests/validation.tftest.hcl
run "reject_uppercase_bucket_name" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "INVALID-UPPERCASE" # S3 only allows lowercase
}
expect_failures = [
module.s3
]
}
run "reject_too_long_bucket_name" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "this-bucket-name-is-way-longer-than-63-characters-which-is-not-allowed"
}
expect_failures = [
module.s3
]
}
Benefit: Users get clear error messages. Failures are caught before deployment. Your module teaches through constraints.
Pattern 4: Integration Testing (Real Dependencies)
Show how your module works with others. Document the integration by testing it:
# examples/with-iam-access/main.tf
module "s3" {
source = "../../"
bucket_name = var.bucket_name
}
# Example shows how to grant IAM access to this bucket
data "aws_iam_policy_document" "bucket_access" {
statement {
sid = "ListBucket"
effect = "Allow"
actions = [
"s3:ListBucket",
]
resources = [module.s3.bucket_arn]
}
}
output "bucket_id" {
value = module.s3.bucket_id
}
output "access_policy_json" {
value = data.aws_iam_policy_document.bucket_access.json
}
# tests/integration_iam.tftest.hcl
run "with_iam_integration" {
command = apply
module {
source = "./examples/with-iam-access"
}
variables {
bucket_name = "integration-test-bucket"
}
assert {
condition = module.s3.bucket_id != ""
error_message = "Bucket should be created"
}
assert {
condition = can(jsondecode(module.s3.access_policy_json))
error_message = "IAM policy should be valid JSON"
}
assert {
condition = contains(module.s3.access_policy_json, module.s3.bucket_arn)
error_message = "Policy must grant access to the bucket"
}
}
Benefit: Integration patterns become discoverable. Users see how your module fits into larger architectures. The integration is tested, not just documented.
Pattern 5: Lock Down Your Output Contract
Users depend on your outputs. Test that they're predictable:
# tests/output_contracts.tftest.hcl
run "outputs_exist_and_are_valid" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "contract-test-bucket"
}
# Outputs must exist
assert {
condition = can(module.s3.bucket_id)
error_message = "bucket_id output required"
}
assert {
condition = can(module.s3.bucket_arn)
error_message = "bucket_arn output required"
}
# Outputs must have correct format
assert {
condition = can(regex("^arn:aws:s3:::", module.s3.bucket_arn))
error_message = "bucket_arn must be a valid S3 ARN"
}
# Relationships between outputs must hold
assert {
condition = contains(module.s3.bucket_arn, module.s3.bucket_id)
error_message = "ARN must include the bucket ID"
}
}
Benefit: Users can depend on your outputs without surprises. Breaking changes fail immediately. Your interface is contractual, not aspirational.
Getting Started
Step 1: Audit Your Examples
You probably have them already. List them:
- Basic usage
- Common variations (encryption, logging, versioning)
- Advanced scenarios
Step 2: Create Test Files
For each example, create a corresponding test:
examples/basic/main.tf → tests/basic_example.tftest.hcl
examples/with-versioning/main.tf → tests/versioning_example.tftest.hcl
examples/with-lifecycle/main.tf → tests/lifecycle_example.tftest.hcl
Step 3: Write One Assertion Per Test
Start simple. What should succeed?
assert {
condition = module.s3.bucket_id != ""
error_message = "Bucket should be created"
}
Step 4: Run Locally
terraform test
You'll see output like:
tests/basic_example.tftest.hcl ... pass
tests/versioning_example.tftest.hcl ... pass
tests/lifecycle_example.tftest.hcl ... pass
Step 5: Add to CI
- name: Validate Examples
run: terraform test -verbose
Or output as JUnit XML for CI integrations:
terraform test -junit-xml=test-results.xml
Step 6: Repeat
Add one test at a time. No rush. Each test prevents one class of future bugs.
Why This Matters
For users: Your examples work. When someone follows your docs, they succeed. No more "this worked in the readme but doesn't work for me" frustration.
For maintainers: One source of truth. When you refactor, tests fail immediately if examples break. No silent drift. You catch it in CI, not in user support tickets.
For organizations: Standardized pattern across all modules. Examples aren't optional documentation—they're testable specifications. Teams can onboard by reading examples. Governance becomes "here's the secure, tested way to use this."
The Takeaway
Stop maintaining your examples and tests separately. They're the same thing. Point your tests at your examples directly. Let Terraform validate them on every commit.
Your examples ARE your tests. Your tests ARE your documentation. This isn't a new concept—it's convergence.
Start today: Pick one example. Write one test that validates it. Run terraform test. Watch it work. That's the entire pattern.
Everything else is just repetition.
Further Reading
- Terraform Testing Language Documentation - Official guide to writing tests in Terraform
-
Terraform Test Command (CLI) - Reference for
terraform testcommand and options - Terraform Registry Module Standards - Best practices for publishing modules with examples
Top comments (0)