DEV Community

Cover image for I Stopped Maintaining Terraform Examples and Tests Separately. Here's Why.
Jacob
Jacob

Posted on

I Stopped Maintaining Terraform Examples and Tests Separately. Here's Why.

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

You can have:

examples/basic/main.tf           (documentation)
tests/basic_example.tftest.hcl   (validates that example)
← One source. Always in sync.
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

Step 3: Write One Assertion Per Test

Start simple. What should succeed?

assert {
  condition     = module.s3.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

You'll see output like:

tests/basic_example.tftest.hcl ... pass
tests/versioning_example.tftest.hcl ... pass
tests/lifecycle_example.tftest.hcl ... pass
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 integrations:

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

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

Top comments (0)