DEV Community

Cover image for Your Terraform Examples Are Broken (And You Don't Know It Yet)
Jacob
Jacob

Posted on

Your Terraform Examples Are Broken (And You Don't Know It Yet)

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:

  1. Basic usage - Just create a bucket with defaults
  2. With versioning - Add version tracking
  3. 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:

  1. No extra tools - Tests are HCL. No external frameworks, no separate languages. Just Terraform.
  2. Real resource testing - Tests actually create and validate resources in your test environment
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Run Locally

terraform test
Enter fullscreen mode Exit fullscreen mode

Step 5: Add to CI

- name: Validate Examples
  run: terraform test -verbose
Enter fullscreen mode Exit fullscreen mode

Or output as JUnit XML for CI integration:

terraform test -junit-xml=test-results.xml
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)