Unit and Integration Testing for Terraform Modules
A comprehensive guide to testing Terraform modules at every level—from static analysis to integration tests—ensuring infrastructure code is reliable before it reaches production.

The Problem: Infrastructure Code Without Tests
Infrastructure-as-code promised repeatability and reliability. But most teams treat Terraform modules like untested scripts—they work until they don't, and failures happen in production.
Our platform team manages 89 Terraform modules used across 14 AWS accounts. Before implementing a testing strategy, we averaged 2.3 production infrastructure incidents per month caused by module changes that "worked in dev." After building a multi-layer testing pipeline, that number dropped to 0.2 per month—a 91% reduction.
The Testing Pyramid for Infrastructure
Just like application code, infrastructure benefits from a testing pyramid:
- Static Analysis (fastest, cheapest): Linting, formatting, validation
- Unit Tests: Testing module logic in isolation with mocked providers
- Contract Tests: Verifying module outputs match expected schemas
- Integration Tests: Deploying real infrastructure and validating behavior
- End-to-End Tests: Full environment provisioning with cross-module dependencies
Layer 1: Static Analysis
Static analysis catches 60% of issues in seconds. We run these checks on every commit:
# .tflint.hcl
plugin "aws" {
enabled = true
version = "0.31.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_naming_convention" {
enabled = true
format = "snake_case"
}
rule "terraform_documented_variables" {
enabled = true
}
rule "terraform_documented_outputs" {
enabled = true
}
rule "aws_instance_invalid_type" {
enabled = true
}
Our CI pipeline runs validation in parallel:
#!/bin/bash
# scripts/static-analysis.sh
set -euo pipefail
echo "Running terraform fmt check..."
terraform fmt -check -recursive -diff
echo "Running terraform validate..."
for module in modules/*/; do
echo "Validating ${module}..."
(cd "$module" && terraform init -backend=false && terraform validate)
done
echo "Running tflint..."
for module in modules/*/; do
echo "Linting ${module}..."
(cd "$module" && tflint --init && tflint)
done
echo "Running tfsec..."
tfsec . --minimum-severity HIGH --format json > tfsec-results.json
Layer 2: Unit Testing with Terraform Test
Terraform 1.6+ introduced native testing. We use tftest files to validate module behavior without provisioning real resources:
# tests/vpc_module.tftest.hcl
provider "aws" {
region = "us-east-1"
}
variables {
environment = "test"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
enable_nat = true
single_nat_gw = false
}
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"
}
}
run "creates_three_public_subnets" {
command = plan
assert {
condition = length(aws_subnet.public) == 3
error_message = "Expected 3 public subnets, got ${length(aws_subnet.public)}"
}
}
run "creates_three_private_subnets" {
command = plan
assert {
condition = length(aws_subnet.private) == 3
error_message = "Expected 3 private subnets, got ${length(aws_subnet.private)}"
}
}
run "nat_gateways_per_az_when_multi_nat" {
command = plan
assert {
condition = length(aws_nat_gateway.main) == 3
error_message = "Expected one NAT GW per AZ when single_nat_gw=false"
}
}
run "tags_include_environment" {
command = plan
assert {
condition = aws_vpc.main.tags["Environment"] == "test"
error_message = "VPC missing Environment tag"
}
}
Layer 3: Contract Testing
Contract tests verify that module outputs match the schema expected by consumers. This prevents breaking changes from propagating:
# tests/vpc_contract.tftest.hcl
run "output_contract_validation" {
command = plan
# Verify output types and structure
assert {
condition = can(output.vpc_id)
error_message = "Module must output vpc_id"
}
assert {
condition = can(output.private_subnet_ids)
error_message = "Module must output private_subnet_ids"
}
assert {
condition = length(output.private_subnet_ids) > 0
error_message = "private_subnet_ids must not be empty"
}
assert {
condition = can(cidrhost(output.vpc_cidr, 0))
error_message = "vpc_cidr output must be a valid CIDR"
}
}
Layer 4: Integration Testing with Terratest
For full integration tests that provision real infrastructure, we use Terratest in Go:
// test/vpc_integration_test.go
package test
import (
"testing"
"fmt"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVPCModule(t *testing.T) {
t.Parallel()
awsRegion := "us-east-1"
uniqueID := fmt.Sprintf("test-%s", random.UniqueId())
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"environment": uniqueID,
"vpc_cidr": "10.99.0.0/16",
"azs": []string{"us-east-1a", "us-east-1b"},
"enable_nat": true,
"single_nat_gw": true,
},
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": awsRegion,
},
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Validate VPC exists and has correct CIDR
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
vpc := aws.GetVpcById(t, vpcID, awsRegion)
assert.Equal(t, "10.99.0.0/16", vpc.CidrBlock)
// Validate subnets are in correct AZs
subnetIDs := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
require.Len(t, subnetIDs, 2)
for _, subnetID := range subnetIDs {
subnet := aws.GetSubnetById(t, subnetID, awsRegion)
assert.True(t, subnet.MapPublicIpOnLaunch == false,
"Private subnets should not map public IPs")
}
// Validate NAT Gateway connectivity
natGWIDs := terraform.OutputList(t, terraformOptions, "nat_gateway_ids")
assert.Len(t, natGWIDs, 1, "Single NAT GW mode should create one gateway")
}
CI/CD Pipeline Integration
Our pipeline runs tests in order of speed, failing fast on cheap checks:
# .github/workflows/terraform-test.yaml
name: Terraform Module Tests
on:
pull_request:
paths: ['modules/**', 'tests/**']
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Format Check
run: terraform fmt -check -recursive
- name: Validate All Modules
run: ./scripts/validate-modules.sh
- name: TFLint
run: ./scripts/run-tflint.sh
- name: Security Scan
run: tfsec . --minimum-severity HIGH
unit-tests:
needs: static-analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Run Unit Tests
run: |
for module in modules/*/; do
if [ -d "${module}tests" ]; then
echo "Testing ${module}..."
(cd "$module" && terraform test)
fi
done
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run Integration Tests
env:
AWS_ROLE_ARN: ${{ secrets.TEST_ACCOUNT_ROLE }}
run: |
cd test
go test -v -timeout 30m -tags=integration ./...
Results
| Metric | Before | After |
|---|---|---|
| Infra incidents from module changes | 2.3/month | 0.2/month |
| Time to validate a module PR | 45 min (manual) | 12 min (automated) |
| Module reuse across teams | 34% | 78% |
| Confidence in terraform apply | "hope it works" | Verified by 4 test layers |
Key Takeaways
-
Start with static analysis. It's free, fast, and catches the majority of issues. Make
terraform fmt,validate, andtflintnon-negotiable. -
Use native Terraform tests for logic.
terraform testwithcommand = planvalidates module behavior without provisioning anything—runs in seconds. -
Contract tests prevent breaking changes. When multiple teams consume your modules, contract tests are the safety net that allows independent evolution.
-
Reserve integration tests for critical paths. They're slow and expensive. Run them on non-draft PRs and for modules that manage networking, security, or data stores.
-
Fail fast, fail cheap. Structure your pipeline so the cheapest tests run first. A formatting error shouldn't trigger a 30-minute integration test suite.
Untested infrastructure code is a liability disguised as automation. The testing pyramid for Terraform gives you confidence that terraform apply will do what you expect—every time.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.