Pulumi Testing Strategies: Unit and Integration Tests
Pulumi brings real programming languages to infrastructure — TypeScript, Python, Go, C#. With that comes a genuine testing story: unit tests that mock the cloud provider and integration tests that deploy to a real environment.
Pulumi's Testing Model
Pulumi provides two distinct testing approaches:
Unit testing — uses the Pulumi testing SDK to mock resource creation. Fast, no cloud credentials needed, catches logic errors in your infrastructure code.
Integration testing — uses the Pulumi Automation API to deploy a real stack to a real cloud, run assertions, and tear it down. Slow, costs money, catches real-world failures.
Both are valuable. Use unit tests during development; run integration tests in CI before merging.
Unit Testing in TypeScript/Node.js
Setup
npm install --save-dev @pulumi/pulumi mocha ts-mocha @types/mocha chai @types/chaiThe Infrastructure Code
// infrastructure/webServer.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
export function createWebServer(config: {
name: string;
instanceType: string;
environment: string;
}) {
const securityGroup = new aws.ec2.SecurityGroup(`${config.name}-sg`, {
description: `Security group for ${config.name}`,
ingress: [
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
{ protocol: "tcp", fromPort: 443, toPort: 443, cidrBlocks: ["0.0.0.0/0"] },
],
egress: [
{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] },
],
tags: {
Name: config.name,
Environment: config.environment,
},
});
const instance = new aws.ec2.Instance(`${config.name}-instance`, {
instanceType: config.instanceType,
ami: "ami-0c55b159cbfafe1f0",
vpcSecurityGroupIds: [securityGroup.id],
tags: {
Name: config.name,
Environment: config.environment,
},
});
return { instance, securityGroup };
}Unit Tests with Mocking
// infrastructure/webServer.test.ts
import * as pulumi from "@pulumi/pulumi";
import { expect } from "chai";
// Set up mocks BEFORE importing infrastructure code
pulumi.runtime.setMocks({
newResource: function (args: pulumi.runtime.MockResourceArgs): { id: string, state: any } {
return {
id: `${args.name}-id`,
state: { ...args.inputs },
};
},
call: function (args: pulumi.runtime.MockCallArgs) {
return args.inputs;
},
});
// Import infrastructure AFTER mocks are set
import { createWebServer } from "./webServer";
describe("createWebServer", () => {
it("creates a security group with the correct name", async () => {
const { securityGroup } = createWebServer({
name: "test-server",
instanceType: "t3.micro",
environment: "test",
});
const name = await new Promise<string>(resolve => {
securityGroup.id.apply(id => resolve(id));
});
expect(name).to.include("test-server");
});
it("allows HTTP and HTTPS ingress", async () => {
const { securityGroup } = createWebServer({
name: "test-server",
instanceType: "t3.micro",
environment: "test",
});
const ingress = await new Promise<any[]>(resolve => {
(securityGroup as any).ingress.apply((v: any) => resolve(v));
});
const ports = ingress.map((rule: any) => rule.fromPort);
expect(ports).to.include(80);
expect(ports).to.include(443);
});
it("tags resources with environment", async () => {
const { instance } = createWebServer({
name: "prod-server",
instanceType: "t3.large",
environment: "production",
});
const tags = await new Promise<Record<string, string>>(resolve => {
(instance as any).tags.apply((t: any) => resolve(t));
});
expect(tags["Environment"]).to.equal("production");
expect(tags["Name"]).to.equal("prod-server");
});
});Run with:
npx ts-mocha -p tsconfig.json 'infrastructure/**/*.test.ts'Unit Testing in Python
pip install pulumi pytest pytest-mock# infrastructure/web_server.py
import pulumi
import pulumi_aws as aws
def create_web_server(name: str, instance_type: str, environment: str):
sg = aws.ec2.SecurityGroup(
f"{name}-sg",
description=f"Security group for {name}",
ingress=[
aws.ec2.SecurityGroupIngressArgs(
protocol="tcp", from_port=80, to_port=80,
cidr_blocks=["0.0.0.0/0"]
),
],
tags={"Name": name, "Environment": environment},
)
instance = aws.ec2.Instance(
f"{name}-instance",
instance_type=instance_type,
ami="ami-0c55b159cbfafe1f0",
vpc_security_group_ids=[sg.id],
tags={"Name": name, "Environment": environment},
)
return {"instance": instance, "security_group": sg}# infrastructure/test_web_server.py
import pytest
import pulumi
from unittest.mock import MagicMock
class MyMocks(pulumi.runtime.Mocks):
def new_resource(self, args: pulumi.runtime.MockResourceArgs):
return [args.name + '-id', args.inputs]
def call(self, args: pulumi.runtime.MockCallArgs):
return {}
pulumi.runtime.set_mocks(MyMocks())
from web_server import create_web_server
@pulumi.runtime.test
def test_security_group_has_correct_tags():
def check_tags(args):
security_group_tags = args[0]
assert security_group_tags["Environment"] == "production"
assert security_group_tags["Name"] == "prod-server"
resources = create_web_server("prod-server", "t3.large", "production")
return pulumi.Output.all(resources["security_group"].tags).apply(check_tags)
@pulumi.runtime.test
def test_instance_type_is_set():
def check_instance(args):
instance_type = args[0]
assert instance_type == "t3.large"
resources = create_web_server("test", "t3.large", "test")
return pulumi.Output.all(resources["instance"].instance_type).apply(check_instance)pytest infrastructure/test_web_server.py -vIntegration Testing with the Automation API
The Automation API lets you control Pulumi programmatically — deploy, query, and destroy stacks from within a test.
// integration-tests/deploy.test.ts
import * as upath from "upath";
import { LocalWorkspace } from "@pulumi/pulumi/automation";
import * as aws from "@aws-sdk/client-ec2";
const projectName = "web-server-integration-test";
describe("Web server deployment", function () {
this.timeout(10 * 60 * 1000); // 10 minutes
let stackName: string;
before(async () => {
stackName = `test-${Date.now()}`;
const stack = await LocalWorkspace.createOrSelectStack({
stackName,
workDir: upath.join(__dirname, ".."),
});
await stack.setConfig("aws:region", { value: "us-east-1" });
await stack.setConfig("instanceType", { value: "t3.micro" });
await stack.setConfig("environment", { value: "test" });
// Deploy the stack
const upResult = await stack.up({ onOutput: process.stdout.write.bind(process.stdout) });
console.log("Deploy result:", upResult.summary.result);
});
after(async () => {
// Always destroy after tests
const workspace = await LocalWorkspace.create({ workDir: upath.join(__dirname, "..") });
const stack = await workspace.selectStack(stackName);
await stack.destroy({ onOutput: process.stdout.write.bind(process.stdout) });
await stack.workspace.removeStack(stackName);
});
it("should have a running EC2 instance", async () => {
const workspace = await LocalWorkspace.create({ workDir: upath.join(__dirname, "..") });
const stack = await workspace.selectStack(stackName);
const outputs = await stack.outputs();
const instanceId = outputs["instanceId"].value;
expect(instanceId).toBeTruthy();
// Verify with AWS SDK
const ec2 = new aws.EC2Client({ region: "us-east-1" });
const result = await ec2.send(new aws.DescribeInstancesCommand({
InstanceIds: [instanceId],
}));
const state = result.Reservations?.[0]?.Instances?.[0]?.State?.Name;
expect(state).toBe("running");
});
it("should respond on port 80", async () => {
const workspace = await LocalWorkspace.create({ workDir: upath.join(__dirname, "..") });
const stack = await workspace.selectStack(stackName);
const outputs = await stack.outputs();
const publicIp = outputs["publicIp"].value;
const response = await fetch(`http://${publicIp}`);
expect(response.status).toBe(200);
});
});Validating Configurations Without Deploying
For complex business logic in your Pulumi code — selecting instance sizes based on environment, computing CIDR ranges, building IAM policy documents — test that logic separately as pure functions:
// infrastructure/config.ts
export function selectInstanceType(environment: string, workload: string): string {
if (environment === "production") {
return workload === "cpu-intensive" ? "c5.2xlarge" : "t3.large";
}
if (environment === "staging") {
return "t3.medium";
}
return "t3.micro"; // dev/test
}
export function computeVpcCidr(region: string, index: number): string {
const regionIndex = ["us-east-1", "us-west-2", "eu-west-1"].indexOf(region);
if (regionIndex === -1) throw new Error(`Unknown region: ${region}`);
return `10.${regionIndex}.${index}.0/24`;
}// infrastructure/config.test.ts
import { expect } from "chai";
import { selectInstanceType, computeVpcCidr } from "./config";
describe("selectInstanceType", () => {
it("returns large instance for production", () => {
expect(selectInstanceType("production", "web")).to.equal("t3.large");
});
it("returns compute-optimized for cpu-intensive production", () => {
expect(selectInstanceType("production", "cpu-intensive")).to.equal("c5.2xlarge");
});
it("returns micro for dev", () => {
expect(selectInstanceType("dev", "any")).to.equal("t3.micro");
});
});
describe("computeVpcCidr", () => {
it("computes us-east-1 CIDR", () => {
expect(computeVpcCidr("us-east-1", 0)).to.equal("10.0.0.0/24");
});
it("throws for unknown region", () => {
expect(() => computeVpcCidr("ap-southeast-1", 0)).to.throw("Unknown region");
});
});These tests run instantly with no mocking needed — pure function testing.
CI Configuration
name: Pulumi Tests
on:
pull_request:
paths:
- 'infrastructure/**'
jobs:
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
working-directory: infrastructure/
- run: npx ts-mocha -p tsconfig.json 'infrastructure/**/*.test.ts'
working-directory: infrastructure/
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: unit-tests
environment: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: pulumi/actions@v5
with:
command: up
stack-name: test-${{ github.run_id }}
work-dir: infrastructure/
- run: npm test
working-directory: integration-tests/
- uses: pulumi/actions@v5
if: always() # Destroy even if tests fail
with:
command: destroy
stack-name: test-${{ github.run_id }}
work-dir: infrastructure/
env:
AWS_ACCESS_KEY_ID: ${{ secrets.TEST_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.TEST_AWS_SECRET_ACCESS_KEY }}
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}Summary
| Test Type | What It Catches | Speed | Cloud Cost |
|---|---|---|---|
| Pure function tests | Config logic errors | Instant | None |
| Unit tests (mocked) | Resource configuration, tagging | Seconds | None |
| Integration tests | Real deployment failures | Minutes | Cloud costs |
Pulumi's real programming languages make infrastructure code actually testable — not just "validate the YAML." Start with unit tests for all your infrastructure functions, add integration tests for critical paths, and use pure function tests for any logic that doesn't touch Pulumi resources directly.
For verifying that your deployed infrastructure serves traffic correctly, HelpMeTest provides continuous end-to-end monitoring after every Pulumi deployment.