Pulumi Testing Strategies: Unit and Integration Tests

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/chai

The 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 -v

Integration 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.

Read more