Terraform Testing — Validate, Plan, and Test Your Infrastructure Code
You would never deploy application code without tests, yet most teams push Terraform changes with nothing more than "the plan looks right." Infrastructure bugs are expensive — a misconfigured security group exposes your database, a wrong CIDR block breaks networking for every service, a missing tag violates compliance and triggers an audit. Terraform testing has matured significantly, and there is now a tool for every level of the testing pyramid.
The Testing Pyramid for Infrastructure as Code
Just like application code, infrastructure testing has layers:
| Level | Tool | Speed | What It Catches |
|---|---|---|---|
| Static Analysis | terraform validate, terraform fmt | Seconds | Syntax errors, type mismatches |
| Plan Testing | terraform plan + assertions | Seconds | Wrong resource counts, missing attributes |
| Policy Testing | Sentinel, OPA, Conftest | Seconds | Compliance violations, security misconfigs |
| Unit Testing | terraform test (built-in) | Seconds | Module input/output logic |
| Integration Testing | Terratest, Kitchen-Terraform | Minutes | Real infrastructure behavior |
Work from the bottom of the pyramid up. Catch as much as possible with fast, cheap tests before reaching for slow, expensive integration tests that provision real resources.
Level 1 — terraform validate
The simplest test. It checks syntax, type constraints, and provider schema compliance without touching any remote APIs:
# Validate configuration
terraform init -backend=false
terraform validate
# Output on success:
# Success! The configuration is valid.
# Output on failure:
# Error: Missing required argument
# on main.tf line 12, in resource "aws_instance" "web":
# 12: resource "aws_instance" "web" {
# The argument "ami" is required, but no definition was found.
The -backend=false flag skips backend initialization, so validate runs instantly without needing cloud credentials. Add this as the first step in every CI pipeline.
Level 2 — terraform fmt Check
Format checking catches inconsistent style before review:
# Check formatting without modifying files
terraform fmt -check -recursive -diff
# Exit code 0 = formatted correctly
# Exit code 3 = files need formatting (shows diff)
# In CI
- name: Check Format
run: |
terraform fmt -check -recursive
if [ $? -ne 0 ]; then
echo "Run 'terraform fmt -recursive' to fix formatting"
exit 1
fi
Level 3 — Plan as a Test
terraform plan is an underrated testing tool. With -detailed-exitcode, you can assert whether changes are expected:
# Exit code 0 = no changes (idempotent)
# Exit code 1 = error
# Exit code 2 = changes detected
terraform plan -detailed-exitcode
echo $?
For testing idempotency — verifying that running plan twice produces no changes — this is powerful:
# Apply the configuration
terraform apply -auto-approve
# Plan again — should show zero changes
terraform plan -detailed-exitcode
if [ $? -ne 0 ]; then
echo "FAIL: Configuration is not idempotent"
exit 1
fi
Level 4 — terraform test (Built-in Since 1.6)
Terraform 1.6 introduced a native testing framework. Test files use the .tftest.hcl extension and live alongside your configuration:
modules/vpc/
main.tf
variables.tf
outputs.tf
tests/
vpc_basic.tftest.hcl
vpc_custom_cidr.tftest.hcl
Here is a test file for a VPC module:
# tests/vpc_basic.tftest.hcl
variables {
vpc_cidr = "10.0.0.0/16"
environment = "test"
vpc_name = "test-vpc"
}
run "creates_vpc_with_correct_cidr" {
command = plan
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block does not match expected value"
}
assert {
condition = aws_vpc.main.tags["Environment"] == "test"
error_message = "Environment tag is incorrect"
}
}
run "creates_expected_subnet_count" {
command = plan
assert {
condition = length(aws_subnet.private) == 3
error_message = "Expected 3 private subnets"
}
assert {
condition = length(aws_subnet.public) == 3
error_message = "Expected 3 public subnets"
}
}
Run the tests with:
terraform test
# Output:
# tests/vpc_basic.tftest.hcl... in progress
# run "creates_vpc_with_correct_cidr"... pass
# run "creates_expected_subnet_count"... pass
# tests/vpc_basic.tftest.hcl... tearing down
# tests/vpc_basic.tftest.hcl... pass
#
# Success! 2 passed, 0 failed.
Using command = plan runs the test in plan-only mode, which means no real resources are created. For tests that need real infrastructure, use command = apply.
Level 5 — Mocking Providers
Terraform 1.7 added mock providers for testing without any cloud interaction:
# tests/with_mocks.tftest.hcl
mock_provider "aws" {
mock_data "aws_availability_zones" {
defaults = {
names = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
}
variables {
vpc_cidr = "10.0.0.0/16"
}
run "test_with_mocked_azs" {
command = plan
assert {
condition = length(aws_subnet.private) == 3
error_message = "Should create one subnet per AZ"
}
}
Mock providers make tests completely offline and deterministic. No credentials needed, no API rate limits, no cost.
Level 6 — Terratest (Go-Based Integration Testing)
Terratest is the heavyweight champion of infrastructure testing. It deploys real resources, runs assertions against them, and tears everything down:
// test/vpc_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.99.0.0/16",
"environment": "test",
"vpc_name": "terratest-vpc",
},
})
// Destroy resources after test
defer terraform.Destroy(t, terraformOptions)
// Deploy infrastructure
terraform.InitAndApply(t, terraformOptions)
// Get outputs
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
publicSubnetIds := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
// Assert VPC exists and has correct CIDR
vpc := aws.GetVpcById(t, vpcId, "us-east-1")
assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)
// Assert expected number of subnets
assert.Equal(t, 3, len(publicSubnetIds))
}
Terratest is powerful but slow and expensive — it creates real cloud resources. Reserve it for critical modules like networking and security, and run it in a dedicated test account.
Policy Testing with OPA and Conftest
Open Policy Agent (OPA) lets you write policies in Rego and test Terraform plans against them:
# Generate plan JSON
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
# Test with Conftest
conftest test tfplan.json --policy policy/
# policy/security.rego
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not has_encryption(resource)
msg := sprintf("S3 bucket '%s' must have encryption enabled", [resource.address])
}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_security_group_rule"
resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
resource.change.after.from_port == 22
msg := sprintf("Security group '%s' allows SSH from 0.0.0.0/0", [resource.address])
}
has_encryption(resource) {
resource.change.after.server_side_encryption_configuration[_]
}
CI Integration
Combine all testing levels into a single pipeline:
# .github/workflows/terraform-test.yml
name: Terraform Tests
on:
pull_request:
paths: ['modules/**']
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Format Check
run: terraform fmt -check -recursive modules/
- name: Validate All Modules
run: |
for dir in modules/*/; do
echo "Validating $dir..."
terraform -chdir="$dir" init -backend=false
terraform -chdir="$dir" validate
done
- name: Run terraform test
run: |
for dir in modules/*/; do
echo "Testing $dir..."
terraform -chdir="$dir" init -backend=false
terraform -chdir="$dir" test
done
- name: Policy Check
run: |
terraform -chdir=environments/staging init -backend=false
terraform -chdir=environments/staging plan -out=tfplan
terraform -chdir=environments/staging show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/
Closing Notes
Terraform testing is no longer optional. Start with validate and fmt in every pipeline, add terraform test for module logic, use OPA/Conftest for security policies, and reach for Terratest when you need end-to-end confidence. The cost of a testing pipeline is nothing compared to the cost of a misconfigured production environment. In the next post, we will dive into Terraform security scanning — tools like tfsec, Checkov, and policy-as-code frameworks that catch vulnerabilities before they reach your cloud account.
