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 tabCommon 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 ./checksIntegrating 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.sarifLayer 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.hclWriting 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 -verboseLayer 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/assertBasic 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-1Continuous 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 failedSet 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:
- Checkov — static security scanning on every PR, no cloud costs, catches misconfigurations instantly
- terraform test (tftest) — native unit tests with mock providers, validate module logic without deployment
- 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.