Terraform Testing Guide: Terratest, tflint, and Validation Strategies

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 apply touches 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 validate

terraform 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/tflint

Configuration

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 compact

tflint 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 terraform

Checkov 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-1

Test Cost Management

Terratest creates and destroys real resources — this has cost implications:

  1. Always use defer terraform.Destroy — runs even if the test panics
  2. Use dedicated test accounts with budget alerts
  3. Run Terratest only on PRs, not every commit
  4. 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.

Read more