Pulumi Testing
Why Pulumi Changes the Testing Game
Pulumi's fundamental advantage is that infrastructure is defined in real programming languages -- TypeScript, Python, Go, or C#. This means you can use the same testing frameworks, assertion libraries, and CI patterns you already know. There is no need to learn a new DSL or testing tool: if you know pytest, you can test Pulumi Python programs. If you know vitest, you can test Pulumi TypeScript programs.
This is a significant departure from Terraform's HCL, where testing requires either specialized tools (Terratest, written in Go) or JSON plan analysis. With Pulumi, the test is just code testing code.
Unit Testing with Pulumi Mocks
Pulumi's mock framework lets you test infrastructure logic without deploying anything. The mocks intercept all cloud API calls and return predictable responses.
TypeScript Unit Tests
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import { describe, it, expect, beforeAll } from "vitest";
// Mock all Pulumi resource creation
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs) => {
// Return mock values for each resource type
switch (args.type) {
case "aws:s3/bucket:Bucket":
return {
id: `${args.name}-id`,
state: {
...args.inputs,
arn: `arn:aws:s3:::${args.inputs.bucket || args.name}`,
bucket: args.inputs.bucket || args.name,
},
};
case "aws:rds/instance:Instance":
return {
id: `${args.name}-id`,
state: {
...args.inputs,
endpoint: "mock-db.cluster-abc123.us-east-1.rds.amazonaws.com",
},
};
default:
return {
id: `${args.name}-id`,
state: args.inputs,
};
}
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs;
},
});
describe("S3 Data Bucket", () => {
it("must have encryption enabled", async () => {
const bucket = new aws.s3.Bucket("test-bucket", {
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
},
});
const encryption = await new Promise((resolve) =>
bucket.serverSideEncryptionConfiguration.apply((config) =>
resolve(config?.rule?.applyServerSideEncryptionByDefault?.sseAlgorithm)
)
);
expect(encryption).toBe("aws:kms");
});
it("must block public access", async () => {
const bucket = new aws.s3.Bucket("test-bucket", {});
const publicAccessBlock = new aws.s3.BucketPublicAccessBlock("test-pab", {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
const blockAcls = await new Promise((resolve) =>
publicAccessBlock.blockPublicAcls.apply(resolve)
);
expect(blockAcls).toBe(true);
});
it("must have versioning enabled", async () => {
const bucket = new aws.s3.Bucket("test-bucket", {
versioning: { enabled: true },
});
const versioning = await new Promise((resolve) =>
bucket.versioning.apply((v) => resolve(v?.enabled))
);
expect(versioning).toBe(true);
});
});
Python Unit Tests
# tests/test_infrastructure.py
import pulumi
import pytest
class MockResource:
"""Mock Pulumi resource provider for unit testing."""
def __init__(self):
self.resources = {}
def __call__(self, args):
self.resources[args.name] = args.inputs
return pulumi.runtime.MockResourceArgs(
id_=f"{args.name}-id",
state=args.inputs,
)
@pytest.fixture
def mock_pulumi():
mock = MockResource()
pulumi.runtime.set_mocks(
pulumi.runtime.Mocks(
new_resource=mock,
call=lambda args: {},
)
)
return mock
def test_database_not_publicly_accessible(mock_pulumi):
"""RDS instances must never be publicly accessible."""
import pulumi_aws as aws
db = aws.rds.Instance(
"test-db",
instance_class="db.t4g.micro",
allocated_storage=20,
engine="postgres",
publicly_accessible=False,
)
# Assert the publicly_accessible property
def check(value):
assert value is False, "Database must not be publicly accessible"
db.publicly_accessible.apply(check)
def test_rds_has_encryption(mock_pulumi):
"""RDS instances must have storage encryption enabled."""
import pulumi_aws as aws
db = aws.rds.Instance(
"test-db",
instance_class="db.t4g.micro",
allocated_storage=20,
engine="postgres",
storage_encrypted=True,
)
def check(value):
assert value is True, "Database storage must be encrypted"
db.storage_encrypted.apply(check)
Policy Tests with Pulumi CrossGuard
Pulumi CrossGuard is Pulumi's policy-as-code framework. Unlike external tools like Checkov or tfsec that analyze HCL, CrossGuard policies are written in the same language as your infrastructure and run at deployment time.
TypeScript Policy Pack
// policy/index.ts
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
import * as aws from "@pulumi/aws";
new PolicyPack("security-policies", {
policies: [
{
name: "s3-no-public-read",
description: "S3 buckets must not allow public read access.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args, reportViolation) => {
if (bucket.acl === "public-read" || bucket.acl === "public-read-write") {
reportViolation("S3 bucket has public read access. Use 'private' ACL.");
}
}),
},
{
name: "rds-encryption-required",
description: "RDS instances must have storage encryption enabled.",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(aws.rds.Instance, (db, args, reportViolation) => {
if (!db.storageEncrypted) {
reportViolation("RDS instance must have storageEncrypted set to true.");
}
}),
},
{
name: "ec2-no-public-ip",
description: "EC2 instances must not have public IP addresses in production.",
enforcementLevel: "advisory",
validateResource: validateResourceOfType(aws.ec2.Instance, (instance, args, reportViolation) => {
if (instance.associatePublicIpAddress) {
reportViolation("EC2 instance should not have a public IP. Use a load balancer.");
}
}),
},
{
name: "required-tags",
description: "All taggable resources must have required tags.",
enforcementLevel: "mandatory",
validateResource: (args, reportViolation) => {
const tags = (args.props as any).tags;
const requiredTags = ["Environment", "Team", "CostCenter"];
if (tags) {
for (const required of requiredTags) {
if (!tags[required]) {
reportViolation(`Missing required tag: ${required}`);
}
}
}
},
},
],
});
Running CrossGuard Policies
# Run policies against a Pulumi preview (dry run)
pulumi preview --policy-pack ./policy/
# Run policies on actual deployment
pulumi up --policy-pack ./policy/
# Publish policies to Pulumi Cloud for organization-wide enforcement
pulumi policy publish ./policy/
Integration Testing with Pulumi Automation API
The Pulumi Automation API allows you to drive Pulumi from within test code, similar to how Terratest drives Terraform:
// tests/integration/s3-bucket.test.ts
import { LocalWorkspace, Stack } from "@pulumi/pulumi/automation";
import { S3Client, GetBucketEncryptionCommand } from "@aws-sdk/client-s3";
import { describe, it, expect, afterAll } from "vitest";
describe("S3 Data Bucket Integration Test", () => {
let stack: Stack;
let bucketName: string;
it("deploys and validates the bucket", async () => {
// Create a stack using inline Pulumi program
stack = await LocalWorkspace.createOrSelectStack({
stackName: "test-integration",
projectName: "s3-test",
program: async () => {
const aws = await import("@pulumi/aws");
const bucket = new aws.s3.Bucket("test-data-bucket", {
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
},
versioning: { enabled: true },
});
return { bucketName: bucket.bucket };
},
});
// Deploy real infrastructure
const upResult = await stack.up({ onOutput: console.log });
bucketName = upResult.outputs.bucketName.value;
// Verify with AWS SDK
const s3 = new S3Client({ region: "us-east-1" });
const encryption = await s3.send(
new GetBucketEncryptionCommand({ Bucket: bucketName })
);
expect(
encryption.ServerSideEncryptionConfiguration?.Rules?.[0]
?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm
).toBe("aws:kms");
}, 120_000); // 2-minute timeout for real infra
afterAll(async () => {
if (stack) {
await stack.destroy({ onOutput: console.log });
await stack.workspace.removeStack("test-integration");
}
});
});
Pulumi vs Terraform Testing Comparison
| Aspect | Terraform | Pulumi |
|---|---|---|
| Unit test language | Go (Terratest) or Python (plan JSON) | Same language as infra (TS, Python, Go, C#) |
| Mock support | Limited (plan analysis) | Built-in mock framework |
| Policy framework | External (OPA, Checkov, tfsec) | Built-in (CrossGuard) |
| Integration tests | Terratest (Go only) | Automation API (any language) |
| Test speed (unit) | Fast (plan analysis) | Very fast (mocks, no cloud) |
| Test speed (integration) | Slow (real infra) | Slow (real infra) |
| Learning curve | Moderate (learn Go for Terratest) | Low (use your existing language) |
| Community tooling | Extensive (Terratest, Checkov, tfsec) | Growing (CrossGuard, native tests) |
Which to Choose
- If your team already uses Terraform and Go, Terratest is mature and well-documented.
- If your team uses TypeScript or Python, Pulumi's native testing is significantly lower friction.
- If you need organization-wide policy enforcement, Pulumi CrossGuard offers a more integrated experience than bolting OPA onto Terraform.
- Regardless of choice, the testing principle is the same: static checks at the base, plan/preview analysis in the middle, real infrastructure at the top.