Skip to main content

Pulumi Ep 13: Unit Testing Infrastructure (Jest)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
pulumi - This article is part of a series.
Part 13: This Article
How do you guarantee that a Junior Developer didn’t accidentally expose port 22 (SSH) to 0.0.0.0/0 in your VPC? In Terraform, you run terraform plan, stare at 500 lines of console output, and hope a human spots the error. In Pulumi, we write automated Unit Tests using Jest. Let’s bring Software Engineering rigor to Platform Engineering.

1. The Pulumi Testing Ecosystem
#

Pulumi supports three distinct types of testing:

  1. Unit Tests: Lightning fast, offline tests that use mocks to validate the logic of your TypeScript code (e.g., checking if tags are applied, or if security group rules are correct) without ever talking to AWS.
  2. Property Tests: Policy-as-Code assertions (which we will cover in Episode 14).
  3. Integration Tests: Slow, end-to-end tests that actually deploy real infrastructure to a temporary cloud sandbox, run assertions against the physical resources, and then tear them down.

In this episode, we focus on Unit Tests, because they are the foundation of a rapid CI/CD pipeline.


2. Setting Up Jest
#

Because we are using Node.js, we can use the standard JavaScript testing frameworks like Mocha or Jest. We will use Jest.

Install the necessary dependencies in your Pulumi project:

npm install --save-dev jest @types/jest ts-jest

Initialize the Jest configuration:

npx ts-jest config:init

This creates a jest.config.js file, allowing Jest to natively read your TypeScript Pulumi code.


3. Practice: Writing the Infrastructure Code
#

Let’s write a simple EC2 instance wrapper that we want to test. We want to guarantee that every single EC2 instance created by our team has a Name tag, and never uses a massive m5.8xlarge instance type.

Create a file named compute.ts:

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

export function createWebServer(name: string, size: string) {
    return new aws.ec2.Instance(name, {
        ami: "ami-12345", // Mock AMI
        instanceType: size,
        tags: {
            Name: `Server-${name}`,
            Environment: "production"
        }
    });
}

4. Writing the Mocks and Tests
#

To write a Unit Test, we must prevent Pulumi from actually trying to contact AWS. We do this using pulumi.runtime.setMocks().

Create a file named compute.test.ts:

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

// 1. SETUP THE MOCKS
// This intercepts any API calls to AWS and returns fake data instantly
pulumi.runtime.setMocks({
    newResource: function(args: pulumi.runtime.MockResourceArgs): {id: string, state: any} {
        // Return a fake ID, and just echo back the inputs as the state
        return {
            id: args.inputs.name + "-mock-id",
            state: args.inputs,
        };
    },
    call: function(args: pulumi.runtime.MockCallArgs) {
        return args.inputs;
    },
});

// 2. IMPORT THE INFRASTRUCTURE TO TEST
import { createWebServer } from "./compute";

// 3. WRITE THE TESTS
describe("Web Server Infrastructure", () => {
    let server: aws.ec2.Instance;

    // Before tests run, invoke the infrastructure creation (offline)
    beforeAll(() => {
        server = createWebServer("frontend-app", "t3.micro");
    });

    // TEST 1: Tagging Compliance
    it("must have a Name tag", (done) => {
        server.tags.apply(tags => {
            try {
                expect(tags).toBeDefined();
                expect(tags!["Name"]).toBe("Server-frontend-app");
                done(); // Tell Jest the async check is complete
            } catch (err) {
                done(err);
            }
        });
    });

    // TEST 2: Instance Size Constraints
    it("must not use excessively large instance types", (done) => {
        server.instanceType.apply(type => {
            try {
                expect(type).not.toBe("m5.8xlarge");
                done();
            } catch (err) {
                done(err);
            }
        });
    });
});

The Complexity of .apply() in Tests
#

Notice how the expect() assertions are written inside .apply() blocks, and we use Jest’s done() callback.

Because the properties of a Pulumi resource (like server.tags) are asynchronous Output<T> types, you cannot write expect(server.tags).toBeDefined(). You must unwrap the Output first. The done() callback signals to Jest that it must wait for the Pulumi Engine to resolve the mock Promise before evaluating the assertion.


5. Running the Tests
#

Run the test suite using standard Jest:

npx jest

Expected Terminal Output:

 PASS  ./compute.test.ts
  Web Server Infrastructure
    ✓ must have a Name tag (3 ms)
    ✓ must not use excessively large instance types (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.24 s

Notice the execution time: 3 milliseconds.

You can now integrate this into your GitHub Actions CI pipeline. Every time a developer opens a Pull Request modifying compute.ts, Jest will instantly validate that they haven’t violated tagging or sizing constraints, blocking the merge if they have.


Troubleshooting & Common Errors
#

  1. TypeError: Cannot read properties of undefined (reading 'apply')

    • Root Cause: In the mock newResource function, you forgot to return a state object. If state is undefined, Pulumi will instantiate the resource class with undefined properties, causing .apply() to crash.
    • Solution: Ensure your mock always returns { id: "mock-id", state: args.inputs }.
  2. Test times out after 5000ms

    • Root Cause: You used .apply() but forgot to call the done() callback, or you called it outside the try/catch block and an expectation failed.
    • Solution: Always wrap your expect statements inside a try/catch within the .apply() block, and ensure done(err) is called in the catch.

Conclusion & Next Steps
#

Unit Testing infrastructure is a paradigm shift. It allows Platform Engineering teams to move with the same velocity and confidence as Software Engineering teams. You can refactor massive multi-cloud architectures, run npx jest, and instantly know if you broke any core logic.

However, writing hundreds of expect() statements across dozens of test files to validate security rules (like “No public S3 buckets”) is tedious. Unit tests are meant for testing logic, not enforcing global security policies.

In Episode 14: Policy as Code with CrossGuard, we will explore Pulumi’s dedicated Policy Engine, allowing you to write sweeping, organization-wide security constraints that act as a strict firewall preventing insecure infrastructure from ever deploying.

pulumi - This article is part of a series.
Part 13: This Article