Infrastructure Testing Patterns with Terratest and Pulumi
Practical infrastructure testing with Terratest and Pulumi: from unit-testing resource configuration to integration tests against real cloud environments.

Infrastructure code deserves the same testing rigor as application code, but the testing patterns look fundamentally different. You can't unit-test a VPC the same way you unit-test a function—there's real cloud infrastructure involved, deployments take minutes, and failures leave resources running that cost money.
Terratest and Pulumi's testing frameworks approach this problem differently but complement each other well. Terratest wraps Terraform with Go-based integration tests. Pulumi's testing SDK offers unit tests that mock cloud providers and integration tests that actually deploy. Understanding when to use each saves both time and cloud spend.
Unit Testing Infrastructure Configuration
Unit tests for infrastructure validate the shape and properties of resources without deploying anything. They run in milliseconds and catch configuration errors early.
// ❌ No tests — configuration errors discovered in production
// main.tf just applies and you hope it works
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
// Forgot versioning, encryption, public access block...
}// ✅ Pulumi unit tests — validate resource properties before deploy
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// The infrastructure module under test
function createSecureBucket(
name: string
): aws.s3.Bucket {
const bucket = new aws.s3.Bucket(name, {
versioning: { enabled: true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
},
});
new aws.s3.BucketPublicAccessBlock(
`${name}-public-access`,
{
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}
);
return bucket;
}
// Unit test — no cloud resources created
describe("Secure S3 Bucket", () => {
let resources: any[];
beforeAll(() => {
// Mock Pulumi runtime
pulumi.runtime.setMocks({
newResource: (args) => {
resources.push(args);
return { id: `${args.name}-id`, state: args.inputs };
},
call: (args) => args.inputs,
});
resources = [];
});
it("should enable versioning", async () => {
const bucket = createSecureBucket("test-bucket");
const versioning = await new Promise((resolve) =>
bucket.versioning.apply((v) => resolve(v))
);
expect(versioning).toEqual({ enabled: true });
});
it("should create a public access block", () => {
createSecureBucket("test-bucket");
const publicAccessBlock = resources.find(
(r) => r.type === "aws:s3:BucketPublicAccessBlock"
);
expect(publicAccessBlock).toBeDefined();
expect(publicAccessBlock.inputs.blockPublicAcls).toBe(
true
);
expect(publicAccessBlock.inputs.blockPublicPolicy).toBe(
true
);
});
it("should use KMS encryption", async () => {
const bucket = createSecureBucket("test-bucket");
const encryption = await new Promise((resolve) =>
bucket.serverSideEncryptionConfiguration.apply((v) =>
resolve(v)
)
);
expect(encryption).toEqual({
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
});
});
});These tests validate that your infrastructure module produces the expected resource configurations. They catch issues like missing encryption, disabled versioning, or absent access controls without spending a minute waiting for a Terraform plan.
Integration Testing with Terratest
Integration tests deploy real infrastructure, validate it works, and tear it down. Terratest is the established tool for this with Terraform.
package test
import (
"crypto/tls"
"fmt"
"testing"
"time"
"github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/gruntwork-io/terratest/modules/random"
"github.com/gruntwork-io/terratest/modules/terraform"
)
func TestWebServerModule(t *testing.T) {
t.Parallel()
uniqueID := random.UniqueId()
instanceName := fmt.Sprintf("test-web-%s", uniqueID)
terraformOptions := terraform.WithDefaultRetryableErrors(
t,
&terraform.Options{
TerraformDir: "../modules/web-server",
Vars: map[string]interface{}{
"instance_name": instanceName,
"environment": "test",
"instance_type": "t3.micro",
},
// Prevent destruction on first pass for debugging
// Comment out for CI
// EnvVars: map[string]string{
// "SKIP_destroy": "true",
// },
},
)
// Always clean up resources when test finishes
defer terraform.Destroy(t, terraformOptions)
// Deploy the infrastructure
terraform.InitAndApply(t, terraformOptions)
// Retrieve outputs for validation
publicIP := terraform.Output(t, terraformOptions, "public_ip")
instanceID := terraform.Output(
t,
terraformOptions,
"instance_id",
)
// Validate the server is responding
url := fmt.Sprintf("http://%s:80", publicIP)
tlsConfig := tls.Config{InsecureSkipVerify: false}
http_helper.HttpGetWithRetryWithCustomValidation(
t,
url,
&tlsConfig,
30, // max retries
5*time.Second, // sleep between retries
func(status int, body string) bool {
return status == 200
},
)
// Validate instance tags
expectedTags := map[string]string{
"Name": instanceName,
"Environment": "test",
"ManagedBy": "terraform",
}
actualTags := terraform.OutputMap(
t,
terraformOptions,
"instance_tags",
)
for key, expected := range expectedTags {
actual, exists := actualTags[key]
if !exists {
t.Errorf("Expected tag %s not found", key)
}
if actual != expected {
t.Errorf(
"Tag %s: expected %s, got %s",
key,
expected,
actual,
)
}
}
}The defer terraform.Destroy pattern ensures cleanup even when tests fail. Running tests in parallel with unique IDs prevents resource naming conflicts when multiple test runs execute simultaneously in CI.
Testing Network Configuration
Network infrastructure tests verify that security groups, routing, and connectivity work correctly after deployment.
func TestVPCNetworkIsolation(t *testing.T) {
t.Parallel()
uniqueID := random.UniqueId()
terraformOptions := terraform.WithDefaultRetryableErrors(
t,
&terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"vpc_name": fmt.Sprintf("test-vpc-%s", uniqueID),
"cidr_block": "10.0.0.0/16",
"environment": "test",
},
},
)
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
privateSubnetIDs := terraform.OutputList(
t,
terraformOptions,
"private_subnet_ids",
)
publicSubnetIDs := terraform.OutputList(
t,
terraformOptions,
"public_subnet_ids",
)
// Verify subnet counts
if len(privateSubnetIDs) < 2 {
t.Errorf(
"Expected at least 2 private subnets, got %d",
len(privateSubnetIDs),
)
}
if len(publicSubnetIDs) < 2 {
t.Errorf(
"Expected at least 2 public subnets, got %d",
len(publicSubnetIDs),
)
}
// Verify private subnets don't have public IPs
for _, subnetID := range privateSubnetIDs {
subnet := aws.GetSubnet(t, subnetID, "us-east-1")
if subnet.MapPublicIpOnLaunch {
t.Errorf(
"Private subnet %s has public IP mapping enabled",
subnetID,
)
}
}
// Verify VPC has expected CIDR
vpc := aws.GetVpcById(t, vpcID, "us-east-1")
if vpc.CidrBlock != "10.0.0.0/16" {
t.Errorf("VPC CIDR: expected 10.0.0.0/16, got %s", vpc.CidrBlock)
}
}Structuring the Test Pipeline
Infrastructure tests are slow. Organizing them into tiers and running them selectively keeps the feedback loop tight.
# .github/workflows/infra-test.yml
name: Infrastructure Tests
on:
pull_request:
paths:
- 'infra/**'
- 'modules/**'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Pulumi unit tests
run: |
cd infra
npm ci
npm test -- --testPathPattern=unit
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
concurrency:
group: infra-integration-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run Terratest integration tests
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_TEST_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_TEST_SECRET }}
AWS_DEFAULT_REGION: us-east-1
run: |
cd test
go test -v -timeout 30m -run TestBasic ./...
full-stack-tests:
runs-on: ubuntu-latest
needs: integration-tests
if: github.event.pull_request.labels.*.name == 'infra-full-test'
steps:
- uses: actions/checkout@v4
- name: Run full stack tests
run: |
cd test
go test -v -timeout 60m ./...The concurrency group ensures only one integration test runs per PR branch, preventing resource conflicts. Full-stack tests only run when explicitly triggered by a label, since they're the most expensive.
Key Takeaways
Unit tests for infrastructure using mocked providers catch configuration errors—missing encryption, wrong tags, absent security controls—in milliseconds without any cloud deployment. Terratest integration tests deploy real resources, verify they work, and destroy them automatically with defer terraform.Destroy, ensuring cleanup even on test failure. Unique IDs via random.UniqueId() and t.Parallel() let integration tests run concurrently without resource name collisions. Network tests should verify the security-critical properties: private subnets don't map public IPs, security groups restrict access correctly, and routing tables isolate traffic. A tiered pipeline—unit tests on every push, integration tests on PRs, full-stack tests on explicit trigger—balances feedback speed with cloud cost. The combination of Pulumi's unit testing for fast resource validation and Terratest's integration testing for real deployment verification covers the infrastructure testing spectrum from seconds to minutes.


