Back to Blog

IaC Testing with Terraform and Pulumi: Catch Config Errors Before They Hit Production

Ensure your cloud infrastructure is reliable and secure with comprehensive IaC testing. Learn to test Terraform and Pulumi code using Terratest, policy validation, and automated testing strategies.

Published

8 min read

Reading time

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 checker
  • terraform fmt: Code formatting
  • tflint: 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.

Related Posts