Pulumi tests aren’t just about checking if your infrastructure code runs; they’re about ensuring it behaves correctly and maintains desired state.

Here’s a basic unit test for a Pulumi program written in TypeScript, targeting an AWS S3 bucket. Notice how we’re not actually deploying anything to AWS.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as assert from "assert";

// This is your main program file (e.g., index.ts)
// export function createBucket(name: string): aws.s3.Bucket {
//   return new aws.s3.Bucket(name);
// }

// This is your test file (e.g., index.test.ts)
describe("S3Bucket", () => {
  let bucket: aws.s3.Bucket;

  beforeEach(async () => {
    // Mock the Pulumi stack and capture resource creation.
    // This simulates a Pulumi deployment without actually touching cloud resources.
    await pulumi.runtime.test.run(async () => {
      // Import or define your infrastructure code here.
      // For this example, we'll assume createBucket is exported from your main file.
      // If not, you'd define it directly here or import it.
      const createBucket = (name: string) => new aws.s3.Bucket(name);
      bucket = createBucket("my-test-bucket");
    });
  });

  it("should create an S3 bucket with the correct name", () => {
    // Assert that a resource of type aws.s3.Bucket was created.
    const bucketResource = pulumi.runtime.test.getStack().getResource("aws:s3/bucket:Bucket", "my-test-bucket");
    assert(bucketResource, "S3 bucket resource should exist");

    // You can also make assertions about the properties.
    // Note: For unit tests, you're often asserting on the *inputs* to the resource
    // or known outputs that don't require a cloud provider call.
    // For example, if you had a specific ACL you wanted to set:
    // const bucketWithAcl = createBucket("my-acl-bucket", { acl: "private" });
    // const bucketResourceWithAcl = pulumi.runtime.test.getStack().getResource("aws:s3/bucket:Bucket", "my-acl-bucket");
    // assert.equal(bucketResourceWithAcl.acl, "private", "Bucket ACL should be private");
  });

  it("should have predictable outputs", async () => {
    // You can export values from your stack and test them.
    const bucketName = bucket.id; // In a real deployment, this would be bucket.id.getOutputValue()
    pulumi.runtime.test.addOutput("bucketName", bucketName);

    // In a test, getOutputValue is not available. You assert on the declared output.
    const outputs = pulumi.runtime.test.getStack().getOutputs();
    assert.equal(outputs.bucketName.value, "my-test-bucket", "Output bucket name should match");
  });
});

The core problem Pulumi testing solves is the gap between writing infrastructure code and verifying its actual, real-world state in a cloud provider. Unit tests, like the above, let you check the structure and intent of your code without incurring any cloud costs or waiting for deployments. You’re essentially running a simulated Pulumi engine.

The pulumi.runtime.test.run function is the key. It sets up a mock Pulumi engine. When your infrastructure code (the part inside pulumi.runtime.test.run) instantiates resources like new aws.s3.Bucket("my-bucket"), the mock engine captures these creations. It doesn’t call AWS APIs. Instead, it builds an in-memory representation of the stack and its resources.

After pulumi.runtime.test.run completes, pulumi.runtime.test.getStack() gives you access to this simulated stack. You can then use getResource(type, name) to find specific resources that were "created" and assert their properties. For outputs, pulumi.runtime.test.getStack().getOutputs() retrieves the declared outputs, and you can check their value property.

This unit testing approach is invaluable for rapidly iterating on the logic of your infrastructure. You can test configurations, conditional resource creation, and the relationships between resources with near-instant feedback. It’s the first line of defense against typos in resource names, incorrect property values, or logical errors in how you’re composing your infrastructure.

For more complex scenarios, especially those involving interactions between resources or dependencies that require actual cloud provisioning, you’ll move to integration tests. These typically involve a dedicated test stack. You’ll use Pulumi’s programmatic API or the CLI to deploy this test stack to a real, isolated cloud environment (e.g., a dedicated AWS account or a specific resource group).

An integration test might look like this:

import * as pulumi from "@pulumi/pulumi";
import * * as aws from "@pulumi/aws";
import * as assert from "assert";
import { stack } from "@pulumi/pulumi"; // Import stack

// Assume this is your main program file that you want to integration test
// export function createWebServer(name: string, instanceType: string): aws.ec2.Instance {
//   const instance = new aws.ec2.Instance(`${name}-webserver`, {
//     instanceType: instanceType,
//     ami: "ami-0abcdef1234567890", // Replace with a valid AMI ID for your region
//     tags: {
//       Name: name,
//     },
//   });
//   return instance;
// }

// This is your integration test file (e.g., integration.test.ts)
describe("WebServer Integration Test", () => {
  // Use a different stack name for integration tests to avoid conflicts.
  // This can be set via PULUMI_STACK environment variable or hardcoded.
  const testStackName = "integration-test-webserver";
  const testProjectName = "my-infra-project"; // Your Pulumi project name

  beforeAll(async () => {
    // Ensure you're in the correct project directory context for Pulumi CLI commands.
    // This might involve setting process.chdir() or running from a specific directory.
    // For simplicity, assume this test runs from the project root.

    // Deploy the stack using Pulumi CLI
    const command = `pulumi up --stack ${testStackName} --yes --skip-preview --non-interactive --logtostderr`;
    console.log(`Running integration test deployment: ${command}`);
    const { stdout, stderr } = await stack.runCommand(command); // Use stack.runCommand for better error handling and output capture

    console.log("Pulumi UP STDOUT:", stdout);
    console.error("Pulumi UP STDERR:", stderr);

    if (stderr.includes("error")) {
      throw new Error(`Pulumi deployment failed: ${stderr}`);
    }
  });

  afterAll(async () => {
    // Destroy the stack after tests are complete.
    const command = `pulumi destroy --stack ${testStackName} --yes --skip-preview --non-interactive --logtostderr`;
    console.log(`Running integration test teardown: ${command}`);
    const { stdout, stderr } = await stack.runCommand(command);

    console.log("Pulumi DESTROY STDOUT:", stdout);
    console.error("Pulumi DESTROY STDERR:", stderr);

    if (stderr.includes("error")) {
      console.error(`Pulumi destroy failed: ${stderr}`);
      // Continue to allow other tests to run, but log the failure.
    }
  });

  it("should create an EC2 instance with the specified instance type", async () => {
    // Fetch the stack outputs after deployment.
    // This requires the stack to have exported values.
    // For example, your main program would have:
    // export const webServerInstanceId = instance.id;
    // export const webServerPublicIp = instance.publicIp;

    // Pulumi CLI command to get outputs
    const getOutputsCommand = `pulumi stack output --stack ${testStackName} --json`;
    console.log(`Fetching stack outputs: ${getOutputsCommand}`);
    const { stdout: outputsStdout, stderr: outputsStderr } = await stack.runCommand(getOutputsCommand);

    if (outputsStderr) {
      console.error("Error fetching stack outputs:", outputsStderr);
      throw new Error(`Failed to fetch stack outputs: ${outputsStderr}`);
    }

    const outputs = JSON.parse(outputsStdout);
    const instanceId = outputs.webServerInstanceId; // Assuming your program exports this
    const instanceType = outputs.webServerInstanceType; // Assuming your program exports this

    assert(instanceId, "Instance ID should be exported from the stack");
    assert(instanceType, "Instance Type should be exported from the stack");

    // Now, use the AWS SDK to verify the actual state of the deployed resource.
    // You'll need to configure AWS credentials for this.
    const awsSdk = require("aws-sdk");
    const ec2 = new awsSdk.EC2({ region: process.env.AWS_REGION || "us-east-1" }); // Ensure region is set

    try {
      const instanceDescription = await ec2.describeInstances({
        InstanceIds: [instanceId],
      }).promise();

      assert(instanceDescription.Reservations.length > 0, "Instance should exist in AWS");
      const instance = instanceDescription.Reservations[0].Instances[0];

      assert.equal(instance.InstanceType, instanceType, `Instance type should be ${instanceType}`);
      assert.equal(instance.State.Name, "running", "Instance should be in a running state");

    } catch (error) {
      console.error("Error describing EC2 instance:", error);
      throw error;
    }
  });
});

The core idea here is to use Pulumi’s CLI commands programmatically within your test runner (like Mocha or Jest). pulumi up provisions the infrastructure, and pulumi destroy cleans it up. Between these, you can query the deployed resources using the cloud provider’s SDK (e.g., aws-sdk, google-cloud, azure-sdk) to verify their actual state. This is where you’d check things like network configurations, security group rules, or the content of a deployed object.

The crucial part of integration tests is isolation. You need to ensure your tests don’t interfere with each other or with production environments. This means using distinct stack names, potentially dedicated AWS accounts or subscriptions, and always cleaning up resources with pulumi destroy.

The most surprising thing about Pulumi’s testing infrastructure is how seamlessly it blends declarative infrastructure-as-code with imperative testing logic. You’re not just testing code; you’re testing the outcome of your code in the real world, using the same tools you use to manage that world.

When you’re running integration tests, the pulumi destroy command is your best friend for cleanup. However, failures can leave resources behind. A robust integration test suite will have multiple layers of cleanup, including automated pulumi destroy in afterAll hooks, and potentially manual checks or cleanup scripts for environments that are frequently used for testing. This ensures you don’t accrue unexpected cloud costs.

The next step after mastering unit and integration tests is often implementing smoke tests for your deployed environments, or exploring strategies for testing infrastructure changes before they hit production.

Want structured learning?

Take the full Pulumi course →