This page is generated from skills/terraform-skill/references/testing-frameworks.md. Edit the source, not this page.
This skill is maintained by Anton Babenko (terraform-best-practices.com, Compliance.tf) under the Apache-2.0 license. Upstream: https://github.com/antonbabenko/terraform-skill
Testing Frameworks - Detailed Guide
Part of: terraform-skill Purpose: Detailed guides for Terraform/OpenTofu testing frameworks
This document provides in-depth guidance on testing frameworks for Infrastructure as Code. For the decision matrix and high-level overview, see the main skill file.
Table of Contents
Static Analysis
Always do this first. Zero cost, catches 40%+ of issues before deployment.
Pre-commit Hooks
# In .pre-commit-config.yaml
- repo: https://github.com/antonbabenko/pre-commit-terraform
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
What Each Tool Checks
terraform fmt- Code formatting consistencyterraform validate- Syntax and internal consistencyTFLint- Best practices, provider-specific rulestrivy/checkov- Security vulnerabilities
When to Use
Every commit, always. Zero cost, catches 40%+ of issues.
Plan Testing
What terraform plan Validates
- Verify expected resources will be created/modified/destroyed
- Catch provider authentication issues
- Validate variable combinations
- Review before applying
In CI/CD
terraform init
terraform plan -out=tfplan
# Optionally: Convert plan to JSON and validate with tools
terraform show -json tfplan | jq '.'
Limitations
- Doesn't deploy real infrastructure
- Can't catch runtime issues (IAM permissions, network connectivity)
- Won't find resource-specific bugs
Native Terraform Tests
Available: Terraform 1.6+, OpenTofu 1.6+
When to Use
- Team primarily works in HCL (no Go/Ruby experience needed)
- Testing logical operations and module behavior
- Want to avoid external testing dependencies
Basic Structure
Test discovery:
terraform testfinds*.tftest.hclfiles undertests/relative to the module root. Use-filter=<path>to scope to a specific file.
# tests/s3_bucket.tftest.hcl
run "create_bucket" {
command = apply
assert {
condition = aws_s3_bucket.main.bucket != ""
error_message = "S3 bucket name must be set"
}
}
run "verify_encryption" {
command = apply # `rule` is a set; use `one(...)` to extract the singleton
assert {
condition = one(aws_s3_bucket_server_side_encryption_configuration.main.rule).apply_server_side_encryption_by_default[0].sse_algorithm == "AES256"
error_message = "Bucket must use AES256 encryption"
}
}
Pattern applies identically on Azure/GCP; only provider/resource names change. See Module Patterns: Cross-cloud resource map.
Critical: Validate Resource Schemas First
Always use Terraform MCP to validate resource schemas before writing tests:
# Example workflow in Claude Code:
# 1. Search for provider documentation
mcp__terraform__search_providers({
provider_name: "aws",
provider_namespace: "hashicorp",
service_slug: "s3_bucket_server_side_encryption_configuration",
provider_document_type: "resources"
})
# 2. Get detailed schema
mcp__terraform__get_provider_details({
provider_doc_id: "12345" # from search results
})
Block-type distinctions the LLM must verify against the real schema:
- set — unordered, cannot index with
[0] - list — ordered, indexable
- computed attribute — only known after apply
Common Schema Patterns:
| AWS Resource | Block Type | Indexing |
|---|---|---|
rule in aws_s3_bucket_server_side_encryption_configuration | set | ❌ Cannot use [0] |
transition in aws_s3_bucket_lifecycle_configuration | set | ❌ Cannot use [0] |
noncurrent_version_expiration in lifecycle | nested block (MaxItems=1) — list-of-1 | ✅ Can use [0] |
Working with Set-Type Blocks
Problem: Cannot index sets with [0]
# ❌ WRONG: This will fail
condition = aws_s3_bucket_server_side_encryption_configuration.this.rule[0].bucket_key_enabled == true
# Error: Cannot index a set value
Solution 1: Use command = apply to materialize the set
run "test_encryption" {
command = apply # Creates real/mocked resources
assert {
# Now the set is materialized and can be checked
condition = length([for rule in aws_s3_bucket_server_side_encryption_configuration.this.rule :
rule.bucket_key_enabled if rule.bucket_key_enabled == true]) > 0
error_message = "Bucket key should be enabled"
}
}
Solution 2: Check at resource level (avoid accessing nested blocks)
run "test_encryption_exists" {
command = plan
assert {
# Check that the resource exists without accessing set members
condition = aws_s3_bucket_server_side_encryption_configuration.this != null
error_message = "Encryption configuration should be created"
}
}
Solution 3: Use for expressions (works in apply mode)
run "test_encryption_algorithm" {
command = apply
assert {
condition = alltrue([
for rule in aws_s3_bucket_server_side_encryption_configuration.this.rule :
alltrue([
for config in rule.apply_server_side_encryption_by_default :
config.sse_algorithm == "AES256"
])
])
error_message = "Encryption should use AES256"
}
}
command = plan vs command = apply
| Goal | Mode | Why |
|---|---|---|
Input-derived attribute (bucket name from var.bucket) | plan | value known before refresh |
| Variable default / validation | plan | fast, no resource creation |
| Computed attribute (ARN, generated name, cloud ID) | apply | only known after provider round-trip |
| Set-type nested block | apply | materializes the set so for expressions resolve |
| Real behavior / mocked provider responses | apply | runs the actual create path |
# ✅ plan — input-derived
run "test_input" {
command = plan
variables { bucket = "test-bucket" }
assert {
condition = aws_s3_bucket.this.bucket == "test-bucket"
error_message = "Bucket name should match input"
}
}
# ✅ apply — computed
run "test_prefix" {
command = apply
variables { bucket_prefix = "test-" }
assert {
condition = startswith(aws_s3_bucket.this.bucket, "test-")
error_message = "Bucket name should start with prefix"
}
}
❌ Asserting a computed value in plan mode → Condition expression could not be evaluated at this time. Fix: switch the run block to command = apply, or assert a different attribute that is known at plan.
With Mocking (1.7+)
mock_provider "aws" {
mock_resource "aws_instance" {
defaults = {
id = "i-mock123"
arn = "arn:aws:ec2:us-east-1:123456789:instance/i-mock123"
}
}
}
Pros
- Native HCL syntax (familiar to Terraform users)
- No external dependencies
- Fast execution with mocks
- Good for unit testing module logic
Cons
- Newer feature (less mature than Terratest)
- Limited ecosystem/examples
- Mocking doesn't catch real-world AWS behavior
Complete Test Examples (Following Best Practices)
Example 1: S3 Bucket Tests
# tests/unit/s3_bucket.tftest.hcl
mock_provider "aws" {} # Zero cost with mocks
# Test 1: Input validation (fast, plan mode)
run "validate_bucket_name" {
command = plan
variables {
bucket = "my-test-bucket"
}
assert {
condition = aws_s3_bucket.this.bucket == "my-test-bucket"
error_message = "Bucket name should match input"
}
}
# Test 2: Encryption defaults (apply mode for set access)
run "verify_default_encryption" {
command = apply
variables {
bucket = "encrypted-bucket"
}
assert {
# Using for expression to check set-type block
condition = alltrue([
for rule in aws_s3_bucket_server_side_encryption_configuration.this.rule :
alltrue([
for config in rule.apply_server_side_encryption_by_default :
config.sse_algorithm == "AES256"
])
])
error_message = "Default encryption should be AES256"
}
assert {
# Check bucket key at rule level
condition = alltrue([
for rule in aws_s3_bucket_server_side_encryption_configuration.this.rule :
rule.bucket_key_enabled == true
])
error_message = "Bucket key should be enabled"
}
}
# Test 3: Computed values (apply mode required)
run "verify_generated_name" {
command = apply
variables {
bucket_prefix = "test-"
}
assert {
condition = startswith(aws_s3_bucket.this.bucket, "test-")
error_message = "Generated bucket name should have prefix"
}
assert {
condition = length(aws_s3_bucket.this.bucket) > 5
error_message = "Bucket name should be generated"
}
}
Example 2: Lifecycle Rules
# tests/unit/lifecycle.tftest.hcl
mock_provider "aws" {}
run "verify_lifecycle_transitions" {
command = apply # Required for set-type transition blocks
variables {
bucket = "lifecycle-bucket"
lifecycle_rules = [{
id = "archive"
enabled = true
transition = [
{ days = 90, storage_class = "GLACIER" },
{ days = 180, storage_class = "DEEP_ARCHIVE" }
]
}]
}
assert {
# Check that both transitions exist using for expression
condition = length([
for rule in aws_s3_bucket_lifecycle_configuration.this.rule :
rule.id if rule.id == "archive"
]) == 1
error_message = "Lifecycle rule should exist"
}
assert {
# Verify transition count using length
condition = alltrue([
for rule in aws_s3_bucket_lifecycle_configuration.this.rule :
length(rule.transition) == 2
])
error_message = "Should have 2 transitions"
}
}
Terratest (Go-based)
Recommended for: Teams with Go experience, robust integration testing
When to Use
- Team has Go experience
- Need robust integration testing
- Testing multiple providers/complex infrastructure
- Want battle-tested framework with large community
Basic Structure
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestS3Module(t *testing.T) {
t.Parallel() // ALWAYS include for parallel execution
terraformOptions := &terraform.Options{
TerraformDir: "../examples/complete",
Vars: map[string]interface{}{
"bucket_name": "test-bucket-" + random.UniqueId(),
},
}
// Clean up resources after test
defer terraform.Destroy(t, terraformOptions)
// Run terraform init and apply
terraform.InitAndApply(t, terraformOptions)
// Get outputs and verify
bucketName := terraform.Output(t, terraformOptions, "bucket_name")
assert.NotEmpty(t, bucketName)
}
Cost Management
// Use tags for automated cleanup
Vars: map[string]interface{}{
"tags": map[string]string{
"Environment": "test",
"TTL": "2h", // Auto-delete after 2 hours
},
}
Critical Patterns
- Always use
t.Parallel()- Enables parallel test execution - Always use
defer terraform.Destroy()- Ensures cleanup - Use unique identifiers - Avoid resource conflicts
- Tag resources - Enable cost tracking and automated cleanup
- Use separate AWS accounts - Isolate test infrastructure
Real-world Costs
- Small module (S3, IAM): $0-5 per run
- Medium module (VPC, EC2): $5-20 per run
- Large module (RDS, ECS cluster): $20-100 per run
Optimization with Test Stages
// Test stages for faster iteration
stage := test_structure.RunTestStage
stage(t, "setup", func() {
terraform.InitAndApply(t, opts)
})
stage(t, "validate", func() {
// Assertions here
})
stage(t, "teardown", func() {
terraform.Destroy(t, opts)
})
// Skip stages during development:
// export SKIP_setup=true
// export SKIP_teardown=true
Best Practices Summary
For All Frameworks
- Start with static analysis - Always free, always fast
- Use unique identifiers - Prevent resource conflicts
- Tag test resources - Enable tracking and cleanup
- Separate test accounts - Isolate test infrastructure
- Implement TTL - Automatic resource cleanup
Framework Selection
Quick syntax check? → terraform validate + fmt
Security scan? → trivy + checkov
Terraform 1.6+, simple logic? → Native tests
Complex integration or multi-cloud orchestration? → Terratest
Cost Optimization
- Use mocking for unit tests
- Implement resource TTL tags
- Run integration tests only on main branch
- Use smaller instance types in tests
- Share test resources when safe
LLM Mistake Checklist — Testing
Common model mistakes when generating test code:
- asserts computed values (ARNs, generated names, cloud-assigned IDs) in
command = planmode — must usecommand = apply - indexes set-type nested blocks with
[0]— sets are unordered, useforexpressions orcommand = applyto materialize - treats mocked-provider tests as integration coverage — mocks validate logic only, not provider behavior
- forgets to exercise
validationblocks with invalid inputs — only tests the happy path - skips idempotency (
terraform plan -detailed-exitcodeafter apply) — the most common regression detector - asserts on Terraform syntax instead of module behavior (
terraform validatealready covers syntax) - runs expensive real-cloud integration tests on every commit instead of gating them behind main/scheduled
- omits cleanup, leaving orphaned resources billed against the test account
Back to: Main Skill File