TL;DR: Stop maintaining separate examples and tests. Test your examples directly. One source of truth. Your docs stay correct automatically.
The Problem
You publish an example in your module docs. Users follow it. Six months later, you refactor your module. The example breaks. But you don't know until someone opens an issue.
This happens because: documentation lives in examples/, tests live in tests/, and they drift apart.
With Terraform's native testing (1.6+), there's a better way. Stop writing your examples separately from your tests. Make every example self-validating.
This post shows how to do it—and why it's a game-changer for module maintainers.
Part 1: Foundation (Level 200)
The Traditional Problem
You've built an S3 module with three examples:
- Basic usage - Just create a bucket with defaults
- With versioning - Add version tracking
- With lifecycle policies - Add archival and cleanup
Then you:
- Write examples in
examples/ - Write tests in
tests/ - Hope they stay in sync
They don't. Ever. You refactor the module's variable names. The examples get updated. The tests... don't. Your CI passes. Your examples fail when users try them. Good luck debugging why your own documentation doesn't work.
What Changed: Terraform Testing Framework
Terraform 1.6+ introduced a native testing framework that lets you write tests directly in HCL. This is significant because:
- No extra tools - Tests are HCL. No external frameworks, no separate languages. Just Terraform.
- Real resource testing - Tests actually create and validate resources in your test environment
-
CI/CD ready - Run with
terraform test, output as JUnit XML with-junit-xml=<file>, and integrate into any CI platform natively.
The Insight: Stop Duplicating
Your examples ARE tests. Why maintain them separately?
Before (the painful way):
examples/basic/main.tf
examples/with-versioning/main.tf
tests/basic_test.tf
tests/versioning_test.tf
They drift. You maintain two separate code bases. Neither stays in sync.
After (one source of truth):
examples/basic/main.tf
examples/with-versioning/main.tf
tests/basic_example.tftest.hcl ← tests the example directly
tests/versioning_example.tftest.hcl ← tests the example directly
Same example. Tested every time. Always works.
Here's How It Works
Your example (the user-facing documentation):
# examples/basic/main.tf
module "s3_bucket" {
source = "../../"
bucket_name = "my-example-bucket"
environment = "dev"
}
Your test (validates that same example):
# tests/basic_example.tftest.hcl
run "basic_example" {
command = apply
module {
source = "./examples/basic" # Test the example directly
}
assert {
condition = module.s3_bucket.bucket_id != ""
error_message = "Bucket ID should not be empty"
}
assert {
condition = startswith(module.s3_bucket.bucket_id, "my-example-bucket")
error_message = "Bucket should be created with correct name"
}
}
That's it. No duplication. The test validates your actual example code. When your module changes, the test immediately fails if the example breaks. You catch it before users do.
Two Testing Approaches:
-
Integration Testing (default with
command = apply): Creates actual infrastructure, validates real resources work as expected -
Unit Testing (with
command = plan): Only validates the plan output without provisioning resources—faster, cheaper, no cleanup needed
Part 2: Advanced Patterns (Level 400)
Once you've got the basic idea, here's what powerful teams do with it.
Pattern 1: Multi-Stage Example Testing
Users learn best by progression: basic → intermediate → advanced. Test each stage independently:
# tests/progression.tftest.hcl
run "stage_1_basic" {
command = apply
module {
source = "./examples/basic"
}
assert {
condition = module.s3_bucket.versioning_enabled == false
error_message = "Basic example should not have versioning"
}
}
run "stage_2_with_versioning" {
command = apply
module {
source = "./examples/with-versioning"
}
assert {
condition = module.s3_bucket.versioning_enabled == true
error_message = "Versioning example should enable versioning"
}
}
run "stage_3_complete" {
command = apply
module {
source = "./examples/lifecycle-tiered-lifecycle"
}
# Validate the complete configuration works end-to-end
assert {
condition = module.s3_bucket.versioning_enabled == true
error_message = "Complete example should have versioning"
}
assert {
condition = module.s3_bucket.lifecycle_rules != null
error_message = "Complete example should have lifecycle rules"
}
assert {
condition = length(module.s3_bucket.lifecycle_rules) >= 1
error_message = "Complete example should define at least one lifecycle rule"
}
}
Result: each example is independently validated, progression is guaranteed, users have a safe learning path.
Pattern 2: Testing Configuration Variations
Your module supports multiple encryption options. Instead of one bloated example, keep examples clean and test variations separately:
# tests/encryption_variations.tftest.hcl
variables {
enable_default_encryption = true
enable_kms_encryption = false
}
run "with_default_encryption" {
command = apply
module {
source = "./examples/basic"
}
variables {
enable_default_encryption = true
enable_kms_encryption = false
}
assert {
condition = module.s3_bucket.encryption_algorithm == "AES256"
error_message = "Should use default S3 encryption"
}
}
run "with_kms_encryption" {
command = apply
module {
source = "./examples/basic"
}
variables {
enable_default_encryption = false
enable_kms_encryption = true
}
assert {
condition = module.s3_bucket.encryption_algorithm == "aws:kms"
error_message = "Should use KMS encryption"
}
}
Clean examples. Comprehensive test coverage. No duplication.
Pattern 3: Catch Failures Fast
Your module should reject invalid inputs. Test exactly what should fail:
# tests/validation_constraints.tftest.hcl
run "invalid_bucket_name_rejected" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "INVALID-UPPERCASE" # S3 buckets must be lowercase
}
expect_failures = [
module.s3_bucket
]
}
run "reserved_name_rejected" {
command = apply
module {
source = "./examples/basic"
}
variables {
bucket_name = "aws-reserved-bucket" # Reserved AWS name
}
expect_failures = [
module.s3_bucket
]
}
Pattern 4: Integration Testing
Show users how your module works with other modules (like IAM policies):
# examples/with-iam-policy/main.tf
module "s3_bucket" {
source = "../../"
bucket_name = var.bucket_name
environment = var.environment
}
# The example also shows how to use this bucket with IAM
module "s3_access_policy" {
source = "./iam-policy"
bucket_arn = module.s3_bucket.arn
}
output "bucket_id" {
value = module.s3_bucket.bucket_id
}
output "access_policy_arn" {
value = module.s3_access_policy.policy_arn
}
Then your test validates the complete picture:
# tests/iam_integration.tftest.hcl
run "with_iam_integration" {
command = apply
module {
source = "./examples/with-iam-policy"
}
variables {
bucket_name = "integration-test-bucket"
environment = "test"
}
assert {
condition = module.s3_bucket.bucket_id != ""
error_message = "Bucket should be created"
}
assert {
condition = module.s3_access_policy.policy_arn != ""
error_message = "IAM policy should be created"
}
# Verify the policy references the correct bucket
assert {
condition = contains(module.s3_access_policy.policy_document, module.s3_bucket.arn)
error_message = "IAM policy must reference the bucket ARN"
}
}
Pattern 5: Test-First Examples
Write the test first (your spec). The example implements exactly what the test demands:
# tests/spec.tftest.hcl - This IS the specification
run "example_secure_bucket" {
command = apply
module {
source = "./examples/secure"
}
# These assertions ARE the specification
assert {
condition = module.s3_bucket.versioning_enabled == true
error_message = "SPEC: Versioning must be enabled for secure bucket"
}
assert {
condition = module.s3_bucket.public_access_blocked == true
error_message = "SPEC: Public access must be blocked for secure bucket"
}
assert {
condition = module.s3_bucket.logging_enabled == true
error_message = "SPEC: Access logging must be enabled for secure bucket"
}
}
Test drives the example. Zero ambiguity.
Pattern 6: Lock Down Your Output Contracts
Users depend on your outputs. Make sure they're predictable:
# tests/output_contracts.tftest.hcl
run "output_structure" {
command = apply
module {
source = "./examples/basic"
}
# Validate output types
assert {
condition = can(module.s3_bucket.bucket_id)
error_message = "bucket_id output must exist"
}
assert {
condition = can(module.s3_bucket.bucket_arn)
error_message = "bucket_arn output must exist"
}
assert {
condition = can(module.s3_bucket.bucket_region)
error_message = "bucket_region output must exist"
}
# Validate output formats
assert {
condition = can(regex("^arn:aws:s3:::", module.s3_bucket.bucket_arn))
error_message = "bucket_arn output must be a valid S3 ARN"
}
# Validate consistency between outputs
assert {
condition = can(regex(module.s3_bucket.bucket_region, module.s3_bucket.bucket_arn))
error_message = "bucket_arn must include the bucket region"
}
}
Now users can safely depend on your outputs without surprises.
Pattern 7: Catch Backwards Incompatibility Before Release
Test that your examples work across versions:
# tests/version_compatibility.tftest.hcl
run "version_1_basic" {
command = apply
module {
source = "./examples/basic"
}
# This documents what v1 of the module supported
variables {
bucket_name = "test-bucket"
environment = "dev"
}
assert {
condition = module.s3_bucket.bucket_id != ""
error_message = "v1 API: Basic example should work"
}
}
run "version_2_extended_options" {
command = apply
module {
source = "./examples/with-extended-options"
}
# v2 added more configuration options
variables {
bucket_name = "test-bucket-v2"
environment = "dev"
enable_access_logging = true
enable_server_side_encryption = true
}
assert {
condition = module.s3_bucket.logging_enabled == true
error_message = "v2 API: Extended options should work"
}
}
run "backwards_compatibility" {
command = apply
module {
source = "./examples/basic"
}
# Ensure v1 examples still work with v2
variables {
bucket_name = "test-bucket"
environment = "dev"
}
assert {
condition = module.s3_bucket.bucket_id != ""
error_message = "v2: Must maintain backwards compatibility with v1 examples"
}
}
Getting Started (It's Simple)
Step 1: List Your Examples
You probably already have them:
- Basic usage
- Common variations (encryption, logging, versioning)
- Advanced scenarios
Step 2: Create Test Files
For each example, create a test that validates it:
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 Assertions
For each test, define what success looks like:
assert {
condition = module.s3_bucket.bucket_id != ""
error_message = "Bucket should be created"
}
Step 4: Run Locally
terraform test
Step 5: Add to CI
- name: Validate Examples
run: terraform test -verbose
Or output as JUnit XML for CI integration:
terraform test -junit-xml=test-results.xml
Your CI platform (GitHub Actions, GitLab CI, Jenkins, etc.) can parse the JUnit XML and display results natively.
Other useful flags:
-
-verbose- Shows plan/state details for debugging -
-json- Machine-readable JSON output -
-parallelism=<n>- Run multiple tests in parallel (default: 10) -
-filter=testfile- Run specific test files only
Done. Every example is tested on every commit, and results integrate seamlessly with your CI/CD platform.
Why This Matters
For users: Examples that actually work. No more "this is the docs version, not the current version" frustration.
For maintainers: One source of truth. No sync headaches. Confidence that refactoring won't break users. Your tests ARE your documentation.
For organizations: Standardized pattern across all modules. Onboarding happens through examples. Governance becomes "here's the secure way to use this." Quality is measurable.
The Takeaway
Stop maintaining two versions of the truth. Your examples can be your tests.
Every example in your registry should be tested. Every test should demonstrate real usage. This isn't a new concept—it's convergence. Documentation and validation become the same thing.
Start today: Pick one example. Write one test that validates it. Run terraform test. Watch it work. Then do the next one. That's it.
The best part? You're not choosing between good examples OR good tests anymore. Every example becomes a test, and every test becomes documentation.
Further Reading
- Terraform Testing Language Documentation
- Terraform Test Command (CLI) - includes JUnit XML output
- Terraform Registry Module Best Practices
Top comments (0)