Terraform Testing Guide: Terratest, tflint, and Validation Strategies
Infrastructure-as-Code introduced a new class of bug: the infrastructure configuration error. A missing variable, a wrong CIDR block, or an IAM permission misconfiguration can take down a service just as effectively as a software bug. Testing your Terraform code prevents these failures.
This guide covers the full Terraform testing stack: static analysis, unit-level validation, and integration testing with real cloud resources.
Why Test Terraform?
The traditional argument against testing infrastructure code is "just review it." But Terraform configurations grow complex: modules with dozens of variables, conditional resource creation, complex locals, and cross-module dependencies. Manual review misses edge cases.
Testing Terraform provides:
- Early feedback: catch errors before
terraform applytouches real resources - Refactoring confidence: change a module knowing existing behavior is verified
- Documentation: tests describe intended behavior and acceptable inputs
- Regression prevention: a broken module stays broken — tests don't forget
Layer 1: Formatting and Syntax
The cheapest tests are format checks:
# Check formatting (fails if not formatted)
terraform <span class="hljs-built_in">fmt -check -recursive
<span class="hljs-comment"># Validate syntax and internal consistency
terraform validateterraform validate catches:
- Undefined variables
- Wrong argument types
- Invalid resource configurations
- Circular dependencies
Run both in CI before any other test. They're fast (no cloud calls) and catch the most basic errors.
Layer 2: Static Analysis with tflint
tflint is a linter that goes deeper than terraform validate. It catches:
- Deprecated attributes
- Invalid instance types for AWS
- Missing required tags
- Variable naming conventions
- Potential typos in resource arguments
Installation
# macOS
brew install tflint
<span class="hljs-comment"># Linux
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh <span class="hljs-pipe">| bash
<span class="hljs-comment"># Docker
docker run --<span class="hljs-built_in">rm -v $(<span class="hljs-built_in">pwd):/data -t ghcr.io/terraform-linters/tflintConfiguration
Create .tflint.hcl in your repo root:
plugin "aws" {
enabled = true
version = "0.29.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_naming_convention" {
enabled = true
format = "snake_case"
}
rule "terraform_required_version" {
enabled = true
}
rule "terraform_required_providers" {
enabled = true
}
rule "aws_instance_invalid_type" {
enabled = true
}
rule "aws_resource_missing_tags" {
enabled = true
tags = ["Environment", "Team", "Service"]
}Running tflint
# Initialize plugins
tflint --init
<span class="hljs-comment"># Lint current directory
tflint
<span class="hljs-comment"># Lint recursively (all modules)
tflint --recursive
<span class="hljs-comment"># With compact output for CI
tflint --format compacttflint can detect that you're using t2.micro when you meant t3.micro, or that you forgot required tags — things terraform validate won't catch.
Layer 3: Unit Testing with Terratest
Terratest is a Go library for writing tests that provision real infrastructure, run assertions, and tear it down. It's "integration testing" by some definitions, but it's the closest thing Terraform has to unit tests for individual modules.
Setup
You need Go installed:
go version # needs 1.21+Initialize a Go test module alongside your Terraform:
infrastructure/
├── modules/
│ └── vpc/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── test/
│ ├── go.mod
│ └── vpc_test.go// test/go.mod
module github.com/myorg/terraform-vpc-test
go 1.21
require (
github.com/gruntwork-io/terratest v0.46.7
github.com/stretchr/testify v1.8.4
)Writing a Terratest Test
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVpcCreation(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"environment": "test",
"name": "test-vpc",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
subnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
require.NotEmpty(t, vpcId, "VPC ID should not be empty")
assert.Len(t, subnetIds, 3, "Should create 3 private subnets")
}
func TestVpcOutputs(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"vpc_cidr": "10.1.0.0/16",
"environment": "staging",
"name": "staging-vpc",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
cidr := terraform.Output(t, terraformOptions, "vpc_cidr")
assert.Equal(t, "10.1.0.0/16", cidr)
}Run with:
cd <span class="hljs-built_in">test/
go <span class="hljs-built_in">test -v -<span class="hljs-built_in">timeout 30m ./...Testing Without Real Resources
For some module logic — locals, data source outputs, conditional resource creation — you can use terraform plan and parse the plan output instead of actually applying:
func TestVpcPlanOnly(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
},
PlanFilePath: "/tmp/vpc-plan",
}
planStruct := terraform.InitAndPlanAndShowWithStruct(t, terraformOptions)
// Verify expected resources will be created
terraform.RequirePlannedValuesMapKeyExists(t, planStruct, "aws_vpc.main")
terraform.RequirePlannedValuesMapKeyExists(t, planStruct, "aws_internet_gateway.main")
}Plan-only tests are much faster (seconds vs minutes) and don't require cloud credentials beyond plan permissions.
Layer 4: Policy Testing
For compliance rules, use checkov or OPA/conftest (covered in a separate article):
# Install checkov
pip install checkov
<span class="hljs-comment"># Scan Terraform plan output
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
checkov -f plan.json --framework terraform_plan
<span class="hljs-comment"># Scan Terraform files directly
checkov -d . --framework terraformCheckov ships with hundreds of built-in policies for CIS benchmarks, SOC2, PCI-DSS, and more.
CI Pipeline Integration
A complete Terraform testing pipeline in GitHub Actions:
name: Terraform CI
on:
pull_request:
paths:
- 'infrastructure/**'
jobs:
validate:
name: Format and Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.6"
- name: Format check
run: terraform fmt -check -recursive
working-directory: infrastructure/
- name: Init
run: terraform init -backend=false
working-directory: infrastructure/
- name: Validate
run: terraform validate
working-directory: infrastructure/
lint:
name: tflint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: terraform-linters/setup-tflint@v4
with:
tflint_version: v0.50.3
- name: Init tflint
run: tflint --init
- name: Run tflint
run: tflint --recursive --format compact
security:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bridgecrewio/checkov-action@v12
with:
directory: infrastructure/
framework: terraform
soft_fail: false
integration-test:
name: Terratest
runs-on: ubuntu-latest
needs: [validate, lint, security]
if: github.event_name == 'pull_request'
environment: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.6"
terraform_wrapper: false # Required for Terratest
- name: Run Terratest
run: go test -v -timeout 30m ./...
working-directory: infrastructure/modules/vpc/test/
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TEST_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TEST_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1Test Cost Management
Terratest creates and destroys real resources — this has cost implications:
- Always use
defer terraform.Destroy— runs even if the test panics - Use dedicated test accounts with budget alerts
- Run Terratest only on PRs, not every commit
- Use plan-only tests where possible — free and fast
Tag all test resources for easy cost attribution and cleanup:
# variables.tf
variable "test_run_id" {
description = "Unique ID for this test run (for cleanup)"
type = string
default = ""
}Summary
| Test Layer | Tool | Cost | Speed |
|---|---|---|---|
| Format | terraform fmt |
Free | Instant |
| Syntax | terraform validate |
Free | Seconds |
| Static analysis | tflint | Free | Seconds |
| Security/compliance | checkov | Free | Seconds |
| Plan verification | Terratest (plan mode) | Free | Seconds |
| Integration testing | Terratest (apply mode) | Cloud costs | Minutes |
Start with the free layers and add Terratest incrementally. A Terraform codebase with formatting, validation, linting, and security scanning already has dramatically better quality than one with no tests at all.
For monitoring your deployed infrastructure, HelpMeTest provides continuous health checks and end-to-end testing that verifies your infrastructure is serving traffic correctly after every deploy.