Infrastructure as Code Testing in 2026: Terratest, Checkov, and the Policy-as-Code Revolution
Infrastructure as Code was supposed to make infrastructure reliable and repeatable. And it does — but only if you test it. Yet for years, “testing IaC” meant manually running terraform plan and eyeballing the output. In 2026, the tooling for testing infrastructure code has matured into a full discipline: static analysis, unit tests, integration tests, and policy enforcement, each with specialized tools that fit naturally into CI/CD pipelines. This guide covers the three layers every mature IaC testing strategy needs, and shows how to implement them without grinding developer velocity to a halt.
Why IaC Testing Is Different from Application Testing
Testing application code is relatively well-understood. You call a function with known inputs and assert on the output. Testing infrastructure code is harder for several structural reasons.
Infrastructure tests often require provisioning real (or simulated) cloud resources, which is slow and costs money. A single end-to-end Terraform test against AWS can take 10-20 minutes and provision dozens of resources. Infrastructure “outputs” are not return values — they are the state of a cloud environment, which must be queried through provider APIs. And critically, infrastructure bugs often manifest as security misconfigurations that are invisible to functional tests: an S3 bucket that works correctly but is publicly readable, a security group that routes traffic as intended but exposes an unexpected port to the internet.
These characteristics push IaC testing toward a layered strategy, with each layer catching different failure modes at different costs.
Layer 1: Static Analysis with Checkov
The cheapest and fastest layer is static analysis — parsing IaC files without executing them. Checkov, from Bridgecrew (now Palo Alto Networks), is the most comprehensive open source static analyzer for Terraform, CloudFormation, Kubernetes manifests, Helm charts, Dockerfile, and ARM templates.
# Install Checkov
pip install checkov
# Scan a Terraform directory
checkov -d ./terraform/modules/networking
# Scan with specific checks enabled/disabled
checkov -d ./terraform \
--check CKV_AWS_18,CKV_AWS_21 \ # Enable only these checks
--skip-check CKV_AWS_8 # Skip this check
# Output SARIF for GitHub Code Scanning integration
checkov -d ./terraform -o sarif > checkov-results.sarif
Checkov ships with over 1,000 built-in checks across providers. For AWS Terraform, it will flag common misconfigurations like unencrypted EBS volumes, S3 buckets with public ACLs, RDS instances without deletion protection, Lambda functions without reserved concurrency, and IAM policies with wildcard actions. Most checks fire within seconds, making Checkov a natural fit for a pre-commit hook that blocks problematic IaC before it ever reaches a PR.
Custom Checks in Python
The built-in checks cover well-known best practices, but every organization has internal standards that Checkov’s default ruleset won’t cover. Custom checks are Python classes that extend Checkov’s base check types:
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories
class RequireTagOwner(BaseResourceCheck):
def __init__(self):
name = "Ensure all AWS resources have an 'owner' tag"
id = "CKV_CUSTOM_1"
supported_resources = ["aws_*"] # Matches all AWS resources
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 {}
if "owner" in tags:
return CheckResult.PASSED
return CheckResult.FAILED
scanner = RequireTagOwner()
# Load custom checks from a directory
checkov -d ./terraform --external-checks-dir ./custom-checks
This pattern lets platform teams encode organizational policies as code and distribute them through a shared checks repository. Every team’s CI pipeline loads the shared checks automatically — enforcing consistent standards without requiring manual review of every PR.
Layer 2: Policy-as-Code with OPA and Sentinel
Static analysis checks are binary pass/fail rules against the syntax of IaC files. Policy-as-code tools operate at a higher level: they evaluate the semantic intent of infrastructure changes, often with access to the full Terraform plan output (which includes computed values and interpolated references that static analysis cannot see).
Open Policy Agent (OPA) with Conftest
OPA’s Rego language lets you write policies that query structured data — and Terraform plan JSON is structured data. The conftest tool applies Rego policies to configuration files and plan outputs in a single command.
# Convert a Terraform plan to JSON for policy evaluation
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Apply OPA policies with conftest
conftest test tfplan.json --policy ./policies/
# policies/deny_public_s3.rego
package main
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_s3_bucket"
resource.values.acl == "public-read"
msg := sprintf("S3 bucket '%v' has public-read ACL — not allowed in production",
[resource.address])
}
deny[msg] {
change := input.resource_changes[_]
change.type == "aws_security_group_rule"
change.change.after.cidr_blocks[_] == "0.0.0.0/0"
change.change.after.from_port == 22
msg := sprintf("Security group rule '%v' allows SSH from 0.0.0.0/0",
[change.address])
}
Because Rego evaluates the full plan JSON — including computed resource IDs, interpolated values, and cross-resource references — it can catch policies that are impossible to express in static analysis. For example, you can write a policy that denies any security group rule that allows inbound port 22 to any instance that has a public IP address, correlating data across multiple resources in a single policy.
HashiCorp Sentinel (Terraform Enterprise/Cloud)
Teams using Terraform Cloud or Enterprise have access to Sentinel, HashiCorp’s policy-as-code framework. Sentinel policies run as a first-class gate in the Terraform run workflow — after plan, before apply — and have native access to the plan, state, and configuration through purpose-built modules.
# Sentinel policy: restrict instance types to approved sizes
import "tfplan/v2" as tfplan
allowed_instance_types = ["t3.micro", "t3.small", "t3.medium", "t3.large"]
main = rule {
all tfplan.resource_changes as _, rc {
rc.type is not "aws_instance" or
rc.change.after.instance_type in allowed_instance_types
}
}
Sentinel integrates with Terraform’s run pipeline without requiring any changes to the Terraform code itself — policies are configured at the workspace or organization level and apply automatically to every run.
Layer 3: Integration Tests with Terratest
Static analysis and policy checks catch misconfigurations before deployment. But they cannot verify that infrastructure actually works once provisioned. Does the VPC allow the expected traffic? Does the RDS cluster accept connections from the application subnet? Does the ECS service start healthy and pass its load balancer health check? These questions require real infrastructure, and Terratest answers them.
Terratest is a Go testing library (from Gruntwork) that provisions real infrastructure, runs assertions against it, and tears it down — all orchestrated as standard Go test functions.
// vpc_test.go — test that a VPC module creates the expected subnets
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/gruntwork-io/terratest/modules/aws"
"github.com/stretchr/testify/assert"
)
func TestVPCModule(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_cidr": "10.0.0.0/16",
"availability_zones": []string{"us-east-1a", "us-east-1b"},
"environment": "test",
},
}
// Ensure cleanup even if the test fails
defer terraform.Destroy(t, terraformOptions)
// Apply the module
terraform.InitAndApply(t, terraformOptions)
// Assert on outputs
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
// Query AWS directly to verify subnet count
subnets := aws.GetSubnetsForVpc(t, vpcID, "us-east-1")
assert.Equal(t, 4, len(subnets)) // 2 public + 2 private
// Verify no subnet has auto-assign public IP (security requirement)
for _, subnet := range subnets {
assert.False(t, aws.IsPublicSubnet(t, subnet.SubnetId, "us-east-1"),
"Subnet %s should not auto-assign public IPs", subnet.SubnetId)
}
}
# Run Terratest tests (standard Go test runner)
cd test/
go test -v -run TestVPCModule -timeout 30m
Managing Terratest Costs and Speed
The primary challenge with Terratest is cost and speed. A full end-to-end test suite for a complex module can take 30-60 minutes and cost tens of dollars per run. Several strategies keep this manageable:
Parallelize with t.Parallel(): Terratest tests use unique resource suffixes (typically from a random hex string) to avoid conflicts, so multiple tests can run in parallel against the same AWS account.
Use test stages: Terratest’s stage package lets you skip the provision or destroy phase during development, so you can iterate on assertions against already-provisioned infrastructure without waiting for a full apply cycle each time.
Separate fast and slow tests: Tag unit-like tests (validating outputs, checking tags) separately from integration tests (verifying network connectivity, testing failover). Run the fast tests on every PR and the slow tests only on merge to main or on a nightly schedule.
Use LocalStack for AWS tests: LocalStack provides a local emulation of AWS services. Not all services are fully supported, but for S3, SQS, DynamoDB, and Lambda, LocalStack lets you run Terratest tests locally or in CI without AWS credentials or costs.
// Use LocalStack endpoint in test configuration
terraformOptions := &terraform.Options{
TerraformDir: "../modules/storage",
EnvVars: map[string]string{
"AWS_DEFAULT_REGION": "us-east-1",
"AWS_ACCESS_KEY_ID": "test",
"AWS_SECRET_ACCESS_KEY": "test",
},
Vars: map[string]interface{}{
"localstack_endpoint": "http://localhost:4566",
},
}
Integrating All Three Layers into CI/CD
The three layers work best when applied at different points in the development workflow, each acting as a progressively deeper and more expensive gate:
- Pre-commit: Run Checkov on changed Terraform files. Fast (seconds), zero cloud cost, catches the most common misconfigurations before they reach code review.
- PR checks: Run
terraform plan, convert to JSON, run Conftest/OPA policies. Moderate cost (plan is read-only), catches semantic policy violations with context from computed values. - Merge to main: Run Terratest integration tests on a dedicated test account. Slow (minutes to hours), real cloud cost, but verifies actual behavior. Automatically destroy all test infrastructure after the test run.
- Scheduled nightly: Run the full compliance test suite, including drift detection (compare current state against expected state) and security posture checks. Catch configuration drift introduced by manual console changes.
# .github/workflows/terraform-test.yml (excerpt)
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
soft_fail: false
output_format: sarif
output_file_path: checkov.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov.sarif
policy-check:
runs-on: ubuntu-latest
needs: static-analysis
steps:
- name: Terraform Plan
run: terraform plan -out=tfplan.binary && terraform show -json tfplan.binary > tfplan.json
- name: Conftest Policy Check
uses: instrumenta/conftest-action@v1
with:
files: tfplan.json
policy: policies/
The Compliance-as-Code Frontier
Beyond catching bugs and misconfigurations, the policy-as-code approach enables a shift in how organizations handle compliance. Instead of periodic audits where a security team reviews infrastructure manually, compliance requirements are encoded as machine-executable policies that run continuously on every change.
Frameworks like SOC 2, PCI-DSS, and CIS Benchmarks have been translated into Checkov check sets and OPA policy libraries. Running these in CI means your compliance posture is verified on every infrastructure change, and audit evidence is generated automatically as test artifacts — signed by the CI system, with a full history in git.
Conclusion
IaC testing in 2026 is a discipline with a clear tool hierarchy: Checkov for fast static analysis, OPA/Conftest or Sentinel for semantic policy enforcement, and Terratest for end-to-end integration verification. None of these layers is sufficient alone. Static analysis catches misconfigurations but cannot verify behavior. Policy checks enforce organizational standards but cannot test that the infrastructure actually functions. Integration tests verify real behavior but are too slow and expensive to run on every commit. The teams that get this right run all three layers at the appropriate point in their pipeline, treating infrastructure code with the same rigor as application code — because the consequences of a misconfigured VPC or an overpermissioned IAM role are at least as serious as a bug in production code.
