Terraform Testing with Checkov and Terratest: Unit and Integration Testing Guide

Terraform Testing with Checkov and Terratest: Unit and Integration Testing Guide

Terraform modules can look correct but deploy broken or insecure infrastructure. Checkov catches security misconfigurations statically before a plan runs. Terratest deploys real infrastructure to validate it works end-to-end. Together, they give you a complete testing pyramid for Terraform: fast static checks in every PR, and real infrastructure validation in nightly CI. This guide covers how to combine both, plus the newer native tftest framework, into a cohesive Terraform testing strategy.

Key Takeaways

Run Checkov on every PR — it's fast and catches obvious mistakes. Unencrypted S3 buckets, public RDS instances, missing resource tags, overly permissive IAM policies — Checkov finds these in seconds without touching a real cloud account.

Terratest is integration testing, not unit testing. It deploys real infrastructure. Budget real cloud costs and run it in dedicated test accounts, not in your dev or production AWS account.

Use tftest for module unit tests without real cloud resources. The native terraform test command (v1.6+) runs mock-based tests that validate module logic without deployment. This is the middle layer between Checkov and Terratest.

Always test module outputs, not just resource creation. A module that creates a VPC but outputs the wrong subnet IDs will silently break the modules that depend on it.

Parallelize Terratest with t.Parallel() but isolate with unique prefixes. Running multiple Terratest tests in the same account without unique resource names causes intermittent failures that waste debugging time.

The Terraform Testing Pyramid

         /\
        /  \      Terratest
       /    \     Real cloud, real validation
      /------\    Slow (~20 min), expensive
     /        \
    /  tftest  \  terraform test
   /    unit    \ Mock providers, no cloud
  /--------------\ Fast (~30 sec), free
 /                \
/    Checkov       \ Static analysis
/   security scan   \ Instant, no cloud
--------------------

Layer 1: Checkov for Static Security Scanning

Checkov runs without cloud credentials, making it ideal for PR checks:

# Install
pip install checkov

<span class="hljs-comment"># Scan a Terraform directory
checkov -d ./modules/networking --framework terraform

<span class="hljs-comment"># Scan a specific file
checkov -f main.tf

<span class="hljs-comment"># Output options
checkov -d . --output json > checkov-report.json
checkov -d . --output sarif > checkov-report.sarif  <span class="hljs-comment"># GitHub Security tab

Common Failures and How to Fix Them

# BAD — Checkov fails CKV_AWS_18: S3 access logging disabled
resource "aws_s3_bucket" "assets" {
  bucket = "my-assets-bucket"
}

# GOOD
resource "aws_s3_bucket" "assets" {
  bucket = "my-assets-bucket"
}

resource "aws_s3_bucket_logging" "assets" {
  bucket        = aws_s3_bucket.assets.id
  target_bucket = aws_s3_bucket.logs.id
  target_prefix = "s3-access-logs/"
}

resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
  bucket = aws_s3_bucket.assets.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Custom Checkov Checks

Write custom checks for your organization's standards:

# checks/required_tags.py
from checkov.common.models.enums import CheckResult, CheckCategories
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck

REQUIRED_TAGS = {"Environment", "Team", "CostCenter", "Project"}

class RequiredTagsCheck(BaseResourceCheck):
    def __init__(self):
        name = "Ensure required tags are present on all taggable resources"
        id = "CKV_CUSTOM_1"
        supported_resources = [
            "aws_instance",
            "aws_s3_bucket",
            "aws_rds_instance",
            "aws_eks_cluster"
        ]
        categories = [CheckCategories.GENERAL_SECURITY]
        super().__init__(name=name, id=id, categories=categories,
                         supported_resources=supported_resources)
    
    def scan_resource_conf(self, conf):
        tags = conf.get("tags", [{}])
        if isinstance(tags, list):
            tags = tags[0] if tags else {}
        
        missing = REQUIRED_TAGS - set(tags.keys())
        
        if missing:
            self.evaluated_keys = ["tags"]
            return CheckResult.FAILED
        return CheckResult.PASSED

check = RequiredTagsCheck()
# Run with custom check
checkov -d . --external-checks-dir ./checks

Integrating Checkov in CI

# .github/workflows/terraform-security.yml
name: Terraform Security Scan

on:
  pull_request:
    paths:
      - '**.tf'
      - '**.tfvars'

jobs:
  checkov:
    runs-on: ubuntu-latest
    permissions:
      security-events: write  # For SARIF upload
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Run Checkov
        id: checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          framework: terraform
          output_format: sarif
          output_file_path: checkov-report.sarif
          soft_fail: false  # Fail the PR on security issues
          skip_check: CKV_AWS_136  # Example: skip if not applicable
      
      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: checkov-report.sarif

Layer 2: Native tftest for Module Unit Tests

The terraform test command (v1.6+) provides a built-in testing framework:

Module Structure

modules/
└── networking/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    └── tests/
        ├── default_vpc.tftest.hcl
        └── custom_cidr.tftest.hcl

Writing tftest Tests

# modules/networking/tests/default_vpc.tftest.hcl

# Mock the AWS provider so no real resources are created
mock_provider "aws" {
  mock_resource "aws_vpc" {
    defaults = {
      id = "vpc-mock12345"
    }
  }
  
  mock_resource "aws_subnet" {
    defaults = {
      id = "subnet-mock12345"
    }
  }
  
  mock_data "aws_availability_zones" {
    defaults = {
      names = ["us-east-1a", "us-east-1b", "us-east-1c"]
    }
  }
}

variables {
  vpc_cidr        = "10.0.0.0/16"
  environment     = "test"
  availability_zones = ["us-east-1a", "us-east-1b"]
}

run "vpc_created_with_correct_cidr" {
  command = plan
  
  assert {
    condition     = aws_vpc.main.cidr_block == var.vpc_cidr
    error_message = "VPC CIDR block does not match input variable"
  }
  
  assert {
    condition     = aws_vpc.main.enable_dns_hostnames == true
    error_message = "DNS hostnames must be enabled for service discovery"
  }
}

run "subnet_count_matches_azs" {
  command = plan
  
  assert {
    condition     = length(aws_subnet.public) == length(var.availability_zones)
    error_message = "Number of public subnets must equal number of AZs"
  }
  
  assert {
    condition     = length(aws_subnet.private) == length(var.availability_zones)
    error_message = "Number of private subnets must equal number of AZs"
  }
}

run "outputs_are_populated" {
  command = plan
  
  assert {
    condition     = output.vpc_id != ""
    error_message = "vpc_id output must not be empty"
  }
  
  assert {
    condition     = length(output.public_subnet_ids) > 0
    error_message = "public_subnet_ids output must not be empty"
  }
}
# Run all tests in a module
<span class="hljs-built_in">cd modules/networking
terraform <span class="hljs-built_in">test

<span class="hljs-comment"># Run a specific test file
terraform <span class="hljs-built_in">test -filter=tests/default_vpc.tftest.hcl

<span class="hljs-comment"># Run with verbose output
terraform <span class="hljs-built_in">test -verbose

Layer 3: Terratest for Integration Testing

Terratest deploys real infrastructure and validates it works:

Project Setup

mkdir <span class="hljs-built_in">test && <span class="hljs-built_in">cd <span class="hljs-built_in">test
go mod init github.com/your-org/terraform-tests
go get github.com/gruntwork-io/terratest/modules/terraform
go get github.com/gruntwork-io/terratest/modules/aws
go get github.com/stretchr/testify/assert

Basic Module Test

// test/networking_test.go
package test

import (
    "fmt"
    "testing"
    "github.com/gruntwork-io/terratest/modules/aws"
    "github.com/gruntwork-io/terratest/modules/random"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestNetworkingModule(t *testing.T) {
    t.Parallel()  // Run multiple tests concurrently
    
    // Generate unique names to avoid conflicts between parallel runs
    uniqueID := random.UniqueId()
    prefix := fmt.Sprintf("test-%s", uniqueID)
    awsRegion := "us-east-1"
    
    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../modules/networking",
        Vars: map[string]interface{}{
            "vpc_cidr":      "10.100.0.0/16",
            "environment":   "test",
            "name_prefix":   prefix,
        },
        EnvVars: map[string]string{
            "AWS_DEFAULT_REGION": awsRegion,
        },
    })
    
    // Always destroy at the end of the test
    defer terraform.Destroy(t, terraformOptions)
    
    // Deploy
    terraform.InitAndApply(t, terraformOptions)
    
    // Get outputs
    vpcID := terraform.Output(t, terraformOptions, "vpc_id")
    publicSubnetIDs := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
    privateSubnetIDs := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    
    // Validate VPC exists and has correct properties
    vpc := aws.GetVpcById(t, vpcID, awsRegion)
    assert.Equal(t, "10.100.0.0/16", aws.GetCidrBlockOfVpc(t, vpcID, awsRegion))
    assert.True(t, *vpc.EnableDnsHostnames)
    
    // Validate subnets
    require.Len(t, publicSubnetIDs, 2, "Expected 2 public subnets (one per AZ)")
    require.Len(t, privateSubnetIDs, 2, "Expected 2 private subnets (one per AZ)")
    
    // Validate subnet routing — public subnets should have internet gateway route
    for _, subnetID := range publicSubnetIDs {
        routeTable := aws.GetRouteTablesForSubnet(t, subnetID, awsRegion)
        hasIGWRoute := false
        for _, table := range routeTable {
            for _, route := range table.Routes {
                if route.GatewayId != nil && *route.GatewayId != "local" {
                    hasIGWRoute = true
                }
            }
        }
        assert.True(t, hasIGWRoute, "Public subnet %s must have route to internet gateway", subnetID)
    }
}

Testing RDS Modules

func TestRDSModule(t *testing.T) {
    t.Parallel()
    
    uniqueID := random.UniqueId()
    awsRegion := "us-east-1"
    
    // First deploy networking as a dependency
    networkingOptions := &terraform.Options{
        TerraformDir: "../modules/networking",
        Vars: map[string]interface{}{
            "name_prefix": fmt.Sprintf("test-%s", uniqueID),
            "vpc_cidr":    "10.101.0.0/16",
        },
    }
    defer terraform.Destroy(t, networkingOptions)
    terraform.InitAndApply(t, networkingOptions)
    
    vpcID := terraform.Output(t, networkingOptions, "vpc_id")
    subnetIDs := terraform.OutputList(t, networkingOptions, "private_subnet_ids")
    
    // Deploy RDS
    rdsOptions := &terraform.Options{
        TerraformDir: "../modules/rds",
        Vars: map[string]interface{}{
            "name_prefix":       fmt.Sprintf("test-%s", uniqueID),
            "vpc_id":            vpcID,
            "subnet_ids":        subnetIDs,
            "instance_class":    "db.t3.micro",  // Cheapest for testing
            "allocated_storage": 20,
            "db_name":           "testdb",
        },
    }
    defer terraform.Destroy(t, rdsOptions)
    terraform.InitAndApply(t, rdsOptions)
    
    dbEndpoint := terraform.Output(t, rdsOptions, "endpoint")
    dbPort := terraform.Output(t, rdsOptions, "port")
    
    // Validate RDS instance exists
    require.NotEmpty(t, dbEndpoint)
    require.Equal(t, "5432", dbPort)
    
    // Validate security group allows access only from VPC CIDR
    dbSecurityGroupID := terraform.Output(t, rdsOptions, "security_group_id")
    sg := aws.GetSecurityGroupById(t, dbSecurityGroupID, awsRegion)
    
    for _, permission := range sg.IpPermissions {
        for _, ipRange := range permission.IpRanges {
            assert.NotEqual(t, "0.0.0.0/0", *ipRange.CidrIp,
                "RDS security group must not allow public access")
        }
    }
}

Retry and Idempotency Testing

func TestModuleIdempotency(t *testing.T) {
    t.Parallel()
    
    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/networking",
        Vars: map[string]interface{}{
            "name_prefix": fmt.Sprintf("test-%s", random.UniqueId()),
        },
    }
    
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)
    
    // Apply a second time — should produce no changes (idempotent)
    exitCode := terraform.PlanExitCode(t, terraformOptions)
    assert.Equal(t, 0, exitCode, 
        "Second terraform plan should show no changes (exit code 0 = no changes)")
}

CI Integration

# .github/workflows/terraform-tests.yml
name: Terraform Tests

on:
  pull_request:
    paths:
      - 'modules/**'
      - 'test/**'
  schedule:
    - cron: '0 3 * * *'  # Nightly full test run

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: modules/
          framework: terraform

  unit-tests:
    needs: static-analysis
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.8.0'
      
      - name: Run terraform test for all modules
        run: |
          for dir in modules/*/; do
            if ls "$dir"tests/*.tftest.hcl 2>/dev/null; then
              echo "Testing $dir"
              cd "$dir"
              terraform test
              cd -
            fi
          done

  integration-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'run-integration-tests')
    
    permissions:
      id-token: write  # For OIDC auth
      contents: read
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.TEST_ACCOUNT_ID }}:role/GithubActionsTestRole
          aws-region: us-east-1
      
      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      
      - name: Run Terratest
        working-directory: test
        run: |
          go test -v -timeout 30m ./...
        env:
          TERRATEST_AWS_REGION: us-east-1

Continuous Monitoring with HelpMeTest

After deploying, HelpMeTest can verify that your infrastructure health checks pass on a schedule:

*** Test Cases ***
Infrastructure Health Check
    [Documentation]    Verify deployed infrastructure responds correctly
    ${response}=    Check HTTP Endpoint    https://api.internal.example.com/health
    Should Be Equal    ${response.status_code}    200    msg=API health check failed

Set health checks for every deployed environment — HelpMeTest's 5-minute monitoring interval catches outages before users report them.

Summary

A complete Terraform testing strategy has three layers:

  1. Checkov — static security scanning on every PR, no cloud costs, catches misconfigurations instantly
  2. terraform test (tftest) — native unit tests with mock providers, validate module logic without deployment
  3. Terratest — integration tests against real cloud infrastructure, run in dedicated test accounts on a schedule

Each layer catches different classes of bugs. Checkov is your first line — fast and free. Terratest is your last line — comprehensive and expensive. The combination prevents both the "my Terraform looks right" false confidence and the "I have to deploy to test" problem.

Read more