IaC Testing with Terraform and Pulumi: Catch Config Errors Before They Hit Production
In the early days of cloud infrastructure, changes were made manually through web consoles or CLI commands. No version control. No code review. No testing. Just cross your fingers and hope nothing breaks.
Infrastructure as Code (IaC) changed everything. Now we define infrastructure declaratively in code—enabling version control, collaboration, and automation. But there's a catch: if your infrastructure is code, it needs to be tested like code.
A misconfigured security group can expose your database to the internet. A typo in a Terraform module can delete production resources. An untested Pulumi change can bring down your entire application.
This guide covers comprehensive testing strategies for IaC using Terraform and Pulumi, including unit tests, integration tests, policy validation, and CI/CD integration. Whether you're managing a handful of resources or a multi-region, multi-account cloud empire, these techniques will help you deploy infrastructure confidently.
Why Test Infrastructure as Code?
| Risk Without Testing | Impact | Testing Solution |
|---|---|---|
| Syntax errors | Deployment failures | Static analysis, linting |
| Logical errors | Misconfigured resources | Unit tests with mocks |
| Security misconfigurations | Data breaches, compliance violations | Policy-as-code validation |
| Breaking changes | Production outages | Integration tests in ephemeral environments |
| Drift detection | Inconsistent state | Automated drift detection |
The IaC Testing Pyramid
Just like application testing, IaC testing follows a pyramid:
graph TB
A[Integration Tests<br/>Full deployments to test environments] --> B[Policy Tests<br/>Security, compliance, cost validation]
B --> C[Unit Tests<br/>Logic validation with mocks]
C --> D[Static Analysis<br/>Linting, formatting, validation]
style A fill:#ff9999
style B fill:#ffcc99
style C fill:#ffff99
style D fill:#99ff99
Bottom (Fast, Many): Static analysis catches syntax errors in seconds.
Middle: Unit and policy tests validate logic without deploying.
Top (Slow, Few): Integration tests deploy to real cloud environments.
Testing Terraform
1. Static Analysis and Linting
The first line of defense catches syntax errors and style issues.
Tools:
terraform validate: Built-in syntax checkerterraform fmt: Code formattingtflint: Advanced linting with plugin support
# Basic validation
terraform init
terraform validate
# Format code
terraform fmt -recursive
# Advanced linting
tflint --init
tflint
Example .tflint.hcl:
plugin "aws" {
enabled = true
version = "0.27.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "aws_instance_invalid_type" {
enabled = true
}
rule "aws_s3_bucket_versioning_enabled" {
enabled = true
}
2. Policy-as-Code Testing
Enforce security and compliance rules before deployment using Open Policy Agent (OPA) or HashiCorp Sentinel.
Example OPA Policy (Rego):
# policies/s3_encryption.rego
package terraform
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
not resource.change.after.server_side_encryption_configuration
msg := sprintf("S3 bucket '%s' must have encryption enabled", [resource.name])
}
Test the policy:
# Generate Terraform plan JSON
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Run OPA policy check
opa exec --decision terraform/deny --bundle policies/ tfplan.json
3. Unit Testing with Terratest
Terratest is a Go library for writing automated tests for infrastructure code.
Installation:
go get github.com/gruntwork-io/terratest/modules/terraform
Example Test (Go):
// test/s3_bucket_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestS3BucketCreation(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../examples/s3-bucket",
Vars: map[string]interface{}{
"bucket_name": "test-bucket-12345",
"region": "us-east-1",
},
}
// Clean up resources after test
defer terraform.Destroy(t, terraformOptions)
// Run terraform init and apply
terraform.InitAndApply(t, terraformOptions)
// Validate outputs
bucketID := terraform.Output(t, terraformOptions, "bucket_id")
assert.Equal(t, "test-bucket-12345", bucketID)
bucketARN := terraform.Output(t, terraformOptions, "bucket_arn")
assert.Contains(t, bucketARN, "arn:aws:s3:::test-bucket-12345")
}
Run the test:
cd test
go test -v -timeout 30m
4. Integration Testing
Deploy to ephemeral environments and validate:
func TestFullInfrastructureDeployment(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../infrastructure",
Vars: map[string]interface{}{
"environment": "test",
"vpc_cidr": "10.0.0.0/16",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
// Test VPC was created
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
// Test application is accessible
appURL := terraform.Output(t, terraformOptions, "app_url")
http_helper.HttpGetWithRetry(t, appURL, nil, 200, "Hello World", 30, 5*time.Second)
}
Testing Pulumi
Pulumi uses general-purpose programming languages (TypeScript, Python, Go, C#), making testing more familiar.
1. Unit Testing Pulumi Programs
Example TypeScript Pulumi Code:
// index.ts
import * as aws from '@pulumi/aws';
export function createBucket(name: string) {
return new aws.s3.Bucket(name, {
versioning: { enabled: true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: 'AES256',
},
},
},
});
}
Unit Test (Jest):
// index.test.ts
import * as pulumi from '@pulumi/pulumi';
import { createBucket } from './index';
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs): { id: string; state: any } => {
return {
id: args.name + '_id',
state: args.inputs,
};
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs;
},
});
describe('S3 Bucket', () => {
it('should enable versioning', async () => {
const bucket = createBucket('test-bucket');
const versioning = await bucket.versioning;
expect(versioning.enabled).toBe(true);
});
it('should enable encryption', async () => {
const bucket = createBucket('test-bucket');
const encryption = await bucket.serverSideEncryptionConfiguration;
expect(encryption.rule.applyServerSideEncryptionByDefault.sseAlgorithm).toBe('AES256');
});
});
Run tests:
npm test
2. Property Testing with Pulumi
Validate resource properties without deploying:
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
describe('Infrastructure Stack', () => {
let stack: pulumi.Stack;
beforeAll(async () => {
stack = await pulumi.runtime.runDeployment(async () => {
const bucket = new aws.s3.Bucket('my-bucket', {
versioning: { enabled: true },
});
return { bucketName: bucket.id };
});
});
it('bucket should have correct tags', async () => {
const bucketResource = stack.resources.find((r) => r.type === 'aws:s3/bucket:Bucket');
expect(bucketResource).toBeDefined();
expect(bucketResource.props.tags).toContain({ Environment: 'production' });
});
});
3. Integration Testing with Pulumi
// integration.test.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import axios from 'axios';
describe('Full Stack Deployment', () => {
let stack: pulumi.automation.Stack;
beforeAll(async () => {
const stackName = `test-stack-${Date.now()}`;
stack = await pulumi.automation.LocalWorkspace.createOrSelectStack({
stackName,
projectName: 'my-project',
program: async () => {
// Define your infrastructure here
const bucket = new aws.s3.Bucket('test-bucket');
return { bucketName: bucket.id };
},
});
await stack.up();
});
afterAll(async () => {
await stack.destroy();
await stack.workspace.removeStack(stack.name);
});
it('should deploy bucket', async () => {
const outputs = await stack.outputs();
expect(outputs.bucketName).toBeDefined();
});
it('should be accessible via API', async () => {
const outputs = await stack.outputs();
const apiUrl = outputs.apiUrl.value;
const response = await axios.get(apiUrl);
expect(response.status).toBe(200);
});
});
Security and Compliance Testing
Using Checkov
Checkov scans IaC for security issues:
# Install
pip install checkov
# Scan Terraform
checkov -d ./terraform
# Scan Pulumi (after pulumi preview --json)
checkov -f pulumi-preview.json --framework pulumi
Example output:
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.my_bucket
File: /main.tf:10-15
Check: CKV_AWS_21: "Ensure S3 bucket has versioning enabled"
PASSED for resource: aws_s3_bucket.my_bucket
Custom Policy Rules
Create custom checks for your organization:
# custom_checks/s3_naming.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
class S3BucketNaming(BaseResourceCheck):
def __init__(self):
name = "Ensure S3 bucket follows naming convention"
id = "CKV_CUSTOM_1"
supported_resources = ['aws_s3_bucket']
categories = ['CONVENTION']
super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf):
bucket_name = conf.get('bucket', [''])[0]
if not bucket_name.startswith('mycompany-'):
return CheckResult.FAILED
return CheckResult.PASSED
check = S3BucketNaming()
CI/CD Integration
GitHub Actions Workflow
name: Infrastructure Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.6.0
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Validate
run: |
terraform init
terraform validate
- name: Run TFLint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: v0.48.0
- run: tflint --init
- run: tflint -f compact
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run Terratest
run: |
cd test
go test -v -timeout 30m
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Best Practices
| Practice | Why It Matters |
|---|---|
| Use modules | Encapsulate reusable logic, easier to test in isolation |
| Test in ephemeral environments | Avoid state conflicts, enable parallel testing |
| Automate testing in CI | Catch issues before merge |
| Version lock dependencies | Ensure reproducible builds |
| Use policy-as-code | Enforce security/compliance automatically |
| Test disaster recovery | Validate backup/restore procedures |
| Monitor drift | Alert when actual state diverges from code |
Conclusion
Infrastructure as Code without testing is just as risky as application code without tests. The unique challenges of IaC—real cloud resources, costs, state management—require a layered testing strategy: static analysis for quick feedback, unit tests for logic validation, policy tests for security, and integration tests for end-to-end confidence.
Start small: add linting and validation to your CI pipeline today. Next week, write your first Terratest. In a month, automate policy checks. The investment pays dividends in reduced outages, faster deployments, and better sleep.
Ready to test your infrastructure like you test your code? Sign up for ScanlyApp and integrate IaC testing into your DevOps workflow.
Related articles: Also see testing Helm charts as the Kubernetes layer on top of your IaC, GitOps workflows that automate IaC deployments end to end, and ephemeral environments built from the IaC code you are testing.
