Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Testing Terraform Code

Overview​

Infrastructure bugs can be expensive — a typo in a security group can expose a database, and recovering from a bad deployment is harder than rolling back application code. Testing Terraform configurations catches mistakes before they hit production. This reference covers testing strategies from static analysis to end-to-end deployment tests.

The infrastructure testing pyramid​

LevelWhat it testsSpeedConfidence
Static analysisterraform fmt, validate, linting rulesSecondsSyntax and structure
Unit testsPlan output: resource counts, attribute valuesSeconds to minutesConfiguration correctness
Policy testsOPA/Rego rules: tags, encryption, securitySecondsCompliance and security
Integration testsDeploy a real module, verify it worksMinutesModule behavior
End-to-end testsDeploy full stack, verify from user perspective10–30 minutesSystem-level correctness

Static analysis​

Start with the built-in checks — no extra tooling needed:

# Formatting check
terraform fmt -check -recursive -diff

# Syntax and provider validation
terraform init -backend=false
terraform validate

In CI:

- name: Terraform Format
run: terraform fmt -check -recursive

- name: Terraform Validate
run: terraform validate

Unit tests with Terratest​

Terratest is a Go testing framework for infrastructure. It uses Terraform's plan output to verify configurations without deploying anything.

Basic plan test​

package test

import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)

func TestAlbModulePlan(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir: "../modules/networking/alb",
Vars: map[string]interface{}{
"name": "test-alb",
"server_port": 8080,
"subnet_ids": []string{"subnet-abc", "subnet-def"},
"vpc_id": "vpc-abc123",
},
}

// Initialize and generate plan only (no apply)
plan := terraform.InitAndPlanAndShowWithStructNoLogTempPlanFile(t, opts)

// Verify expected resource counts
planStruct := plan.ResourcePlannedValuesMap
assert.Equal(t, 4, len(planStruct)) // ALB, listener, target group, security group
}

Verifying plan attributes​

func TestAsgModulePlan(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir: "../modules/cluster/asg-rolling-deploy",
Vars: map[string]interface{}{
"cluster_name": "test-asg",
"instance_type": "t2.micro",
"min_size": 1,
"max_size": 3,
"server_port": 8080,
"subnet_ids": []string{"subnet-abc"},
},
}

// Expect: 1 launch config, 1 ASG, 2 security group rules, 1 security group
plan := terraform.InitAndPlanAndShowWithStructNoLogTempPlanFile(t, opts)
assert.Equal(t, 5, len(plan.ResourcePlannedValuesMap))

// Verify a specific resource attribute
launchConfig, exists := plan.ResourcePlannedValuesMap["aws_launch_configuration.app"]
assert.True(t, exists)
assert.Equal(t, "t2.micro", launchConfig.AttributeValues["instance_type"])
}

Integration tests: deploy and verify​

Integration tests deploy real infrastructure, verify it works, then clean up.

Deploy and validate​

func TestAlbExample(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir: "../examples/alb",
Vars: map[string]interface{}{
"environment": "test",
},
// Retry transient failures
MaxRetries: 3,
RetryableTerraformErrors: map[string]string{
".*unable to verify the SSL certificate.*": "Retry intermittent SSL errors",
},
}

// Always clean up
defer terraform.Destroy(t, opts)

// Deploy
terraform.InitAndApply(t, opts)

// Get output
albDnsName := terraform.OutputRequired(t, opts, "alb_dns_name")

// Verify with HTTP request (retry — ALB can take a moment)
url := fmt.Sprintf("http://%s", albDnsName)
http_helper.HttpGetWithRetryWithCustomValidation(
t, url, nil, 10, 5*time.Second,
func(status int, body string) bool {
return status == 404 // Default ALB response
},
)

// Or check for a specific response
// status, body := http_helper.HttpGet(t, url)
// assert.Equal(t, 200, status)
// assert.Contains(t, body, "Hello, World")
}

Testing a composed module​

func TestHelloWorldApp(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir: "../examples/hello-world-app",
Vars: map[string]interface{}{
"environment": "test",
"instance_type": "t2.micro",
"min_size": 1,
"max_size": 1,
},
}

defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)

albDnsName := terraform.OutputRequired(t, opts, "alb_dns_name")

// Wait for the app to respond (instances need time to bootstrap)
http_helper.HttpGetWithRetry(
t,
fmt.Sprintf("http://%s", albDnsName),
nil,
200, // expected status
"Hello, World", // expected body substring
30, // retries
10*time.Second, // time between retries
)
}

Generating unique resource names​

Use random.UniqueId() to avoid collision between test runs:

import "github.com/gruntwork-io/terratest/modules/random"

func TestMySqlModule(t *testing.T) {
t.Parallel()

uniqueId := random.UniqueId()
dbName := fmt.Sprintf("testdb-%s", uniqueId)

opts := &terraform.Options{
TerraformDir: "../modules/data-stores/mysql",
Vars: map[string]interface{}{
"db_name": dbName,
"db_username": "admin",
"db_password": "testpassword123",
},
}

defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
}

Staged tests (partial re-runs)​

For expensive tests that take a long time to set up infrastructure, use the test_structure module to run in stages:

import "github.com/gruntwork-io/terratest/modules/test-structure"

func TestHelloWorldAppStaged(t *testing.T) {
t.Parallel()

exampleDir := "../examples/hello-world-app"

// Stage 1: Deploy the database
test_structure.RunTestStage(t, "deploy_db", func() {
opts := createDbOptions(t)
test_structure.SaveTerraformOptions(t, exampleDir, opts)
terraform.InitAndApply(t, opts)
})

// Stage 2: Deploy the application
test_structure.RunTestStage(t, "deploy_app", func() {
opts := createAppOptions(t)
test_structure.SaveTerraformOptions(t, exampleDir, opts)
terraform.InitAndApply(t, opts)
})

// Stage 3: Validate
test_structure.RunTestStage(t, "validate", func() {
opts := test_structure.LoadTerraformOptions(t, exampleDir)
albDnsName := terraform.OutputRequired(t, opts, "alb_dns_name")

http_helper.HttpGetWithRetry(
t,
fmt.Sprintf("http://%s", albDnsName),
nil, 200, "Hello, World", 30, 10*time.Second,
)
})

// Stage 4: Teardown
test_structure.RunTestStage(t, "teardown", func() {
opts := test_structure.LoadTerraformOptions(t, exampleDir)
defer terraform.Destroy(t, opts)
})
}

Why staged tests:

  • If validation fails, you can re-run only the validation stage without redeploying everything.
  • Faster iteration — skip deployment when the infrastructure is already up.
  • Better CI integration: separate stages can report separately.

OPA policy tests​

Open Policy Agent (OPA) validates infrastructure against organizational policies:

Rego policy​

# enforce_tagging.rego
package terraform.analysis

import rego.v1

deny_tags[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
not resource.change.after.tags
msg := sprintf("EC2 instance %s must have tags", [resource.address])
}

deny_encryption[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not resource.change.after.server_side_encryption_configuration
msg := sprintf("S3 bucket %s must have server-side encryption enabled", [resource.address])
}

Testing with OPA​

# Generate a Terraform plan in JSON format
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

# Evaluate against policy
opa eval --data enforce_tagging.rego \
--input tfplan.json \
"data.terraform.analysis.deny"

# Expect output: [] (empty = all resources pass the policy)

Go test with OPA​

func TestOpaTaggingPolicy(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir: "../examples/alb",
}

// Run plan but don't apply
terraform.InitAndPlan(t, opts)

// Evaluate OPA rules against the plan
results := terraform.OPAEval(
t, opts,
terraform.OPAEvalOpts{
RulePath: "../opa/enforce_tagging.rego",
RuleName: "data.terraform.analysis.deny",
},
)

// Expect no policy violations
// If violations exist, results will contain the error messages
assert.Empty(t, results)
}

Testing in CI​

# GitHub Actions example
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"

- name: Run Terratest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
cd test
go test -v -timeout 60m -parallel 4

CI considerations:

  • Tests run against real AWS resources — costs money. Use -timeout to prevent runaway costs.
  • Parallel execution reduces CI time but increases concurrent resource count.
  • Always run a destroy step or use defer terraform.Destroy so orphaned resources don't accumulate.
  • Use a dedicated test AWS account, never the production account.

Best practices​

  1. Write a test for every module — at minimum, a plan-level test that validates resource counts.
  2. Use OPA for security policies — enforce tagging, encryption, and public access restrictions across all modules.
  3. Retry HTTP checks — cloud resources (especially ALBs) take time to become available. Use HttpGetWithRetry.
  4. Use unique names — random.UniqueId() prevents test run collisions.
  5. Clean up aggressively — defer terraform.Destroy in every test. Monitor your test account for orphaned resources.
  6. Stage expensive tests — separate deploy, validate, and teardown phases so failures don't waste setup time.

See also​