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 namesrandom.UniqueId() prevents test run collisions.
  5. Clean up aggressivelydefer 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