Testing Pulumi Infrastructure Code with Go and Python
Pulumi infrastructure code is real programming-language code, so it deserves real tests. Pulumi's SDK provides a mock framework for unit tests (no cloud required) and an Automation API for integration tests against ephemeral stacks. This guide covers both layers in Go and Python, with CI patterns for each.
Key Takeaways
Pulumi's mocks replace cloud calls entirely. Unit tests run in milliseconds with no cloud credentials—the mock framework intercepts resource registrations and lets you assert on inputs before anything is deployed.
Test resource properties, not existence. Checking that an S3 bucket was created is weak. Check that it has versioning enabled, ACL set to private, and the correct tags.
Use ephemeral stacks for integration tests. The Automation API lets you create, deploy, and destroy a complete stack programmatically—ideal for CI without manual state management.
Mock outputs control downstream resources. When resource A feeds resource B, set mock outputs on A to control what B receives—this lets you test the full dependency chain without a real cloud.
Keep stacks short-lived. Integration test stacks should be created fresh per test run and destroyed on completion (even on failure). Never reuse a test stack across runs.
Why Pulumi Testing Is Different
Terraform users write HCL—a declarative config language with limited expressibility. Pulumi users write real programs in Go, Python, TypeScript, or C#. This is a superpower for complex infrastructure, but it also means your infrastructure code can have all the same bugs as application code: wrong conditionals, off-by-one errors, incorrect string formatting, broken loops.
Pulumi's testing framework reflects this. You test Pulumi programs the same way you test any program: unit tests with mocks for fast iteration, integration tests against real infrastructure for confidence.
Unit Testing in Go
Pulumi's Go SDK includes pulumi/pulumix and the integration package. For unit tests, you use pulumi.RunErr with mocks:
// infra/stack_test.go
package main
import (
"sync"
"testing"
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/s3"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/stretchr/testify/assert"
)
type mocks struct{}
// NewResource is called every time a resource is registered
func (m *mocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
outputs := args.Inputs.Copy()
switch args.TypeToken {
case "aws:s3/bucket:Bucket":
outputs["id"] = resource.NewStringProperty("my-test-bucket")
outputs["arn"] = resource.NewStringProperty("arn:aws:s3:::my-test-bucket")
outputs["bucketDomainName"] = resource.NewStringProperty("my-test-bucket.s3.amazonaws.com")
case "aws:s3/bucketVersioningV2:BucketVersioningV2":
outputs["id"] = resource.NewStringProperty("my-test-bucket")
}
return args.Name + "_id", outputs, nil
}
// Call is invoked for provider function calls
func (m *mocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
return resource.PropertyMap{}, nil
}
func TestS3BucketConfiguration(t *testing.T) {
err := pulumi.RunErr(func(ctx *pulumi.Context) error {
infra, err := createInfrastructure(ctx)
if err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(3)
// Assert bucket has versioning enabled
pulumi.All(infra.bucket.Bucket, infra.versioning.VersioningConfiguration).ApplyT(
func(all []interface{}) error {
defer wg.Done()
bucketName := all[0].(string)
config := all[1].(s3.BucketVersioningV2VersioningConfiguration)
assert.Equal(t, "Enabled", config.Status, "versioning should be enabled")
assert.NotEmpty(t, bucketName)
return nil
},
)
// Assert bucket has correct tags
infra.bucket.Tags.ApplyT(func(tags map[string]string) error {
defer wg.Done()
assert.Equal(t, "production", tags["Environment"])
assert.Equal(t, "platform", tags["Team"])
return nil
})
// Assert server-side encryption is configured
infra.bucket.ServerSideEncryptionConfiguration.ApplyT(
func(enc *s3.BucketServerSideEncryptionConfiguration) error {
defer wg.Done()
assert.NotNil(t, enc, "server-side encryption must be configured")
return nil
},
)
wg.Wait()
return nil
}, pulumi.WithMocks("project", "stack", &mocks{}))
assert.NoError(t, err)
}Your infrastructure program is just a normal Go function:
// infra/main.go
type Infrastructure struct {
bucket *s3.Bucket
versioning *s3.BucketVersioningV2
}
func createInfrastructure(ctx *pulumi.Context) (*Infrastructure, error) {
bucket, err := s3.NewBucket(ctx, "app-bucket", &s3.BucketArgs{
Tags: pulumi.StringMap{
"Environment": pulumi.String("production"),
"Team": pulumi.String("platform"),
},
ServerSideEncryptionConfiguration: &s3.BucketServerSideEncryptionConfigurationArgs{
Rule: &s3.BucketServerSideEncryptionConfigurationRuleArgs{
ApplyServerSideEncryptionByDefault: &s3.BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefaultArgs{
SseAlgorithm: pulumi.String("aws:kms"),
},
},
},
})
if err != nil {
return nil, err
}
versioning, err := s3.NewBucketVersioningV2(ctx, "app-bucket-versioning", &s3.BucketVersioningV2Args{
Bucket: bucket.Bucket,
VersioningConfiguration: &s3.BucketVersioningV2VersioningConfigurationArgs{
Status: pulumi.String("Enabled"),
},
})
if err != nil {
return nil, err
}
return &Infrastructure{bucket: bucket, versioning: versioning}, nil
}Unit Testing in Python
Python uses pytest. Pulumi provides pulumi.runtime.set_mocks:
# test_stack.py
import pytest
import pulumi
class MyMocks(pulumi.runtime.Mocks):
def new_resource(self, args: pulumi.runtime.MockResourceArgs):
outputs = args.inputs.copy()
if args.typ == "aws:ec2/vpc:Vpc":
outputs["id"] = "vpc-12345678"
outputs["arn"] = "arn:aws:ec2:us-east-1:123456789:vpc/vpc-12345678"
outputs["defaultRouteTableId"] = "rtb-12345678"
elif args.typ == "aws:ec2/subnet:Subnet":
outputs["id"] = f"subnet-{args.name}"
outputs["arn"] = f"arn:aws:ec2:us-east-1:123456789:subnet/subnet-{args.name}"
return [args.name + "_id", outputs]
def call(self, args: pulumi.runtime.MockCallArgs):
return {}
pulumi.runtime.set_mocks(
MyMocks(),
project="myproject",
stack="testing",
preview=False,
)
# Import AFTER set_mocks — this is critical
import infra
@pulumi.runtime.test
def test_vpc_has_correct_cidr():
def check_cidr(args):
cidr_block, = args
assert cidr_block == "10.0.0.0/16", f"Expected 10.0.0.0/16, got {cidr_block}"
return pulumi.Output.all(infra.vpc.cidr_block).apply(check_cidr)
@pulumi.runtime.test
def test_vpc_has_dns_enabled():
def check_dns(args):
dns_hostnames, dns_support = args
assert dns_hostnames is True, "DNS hostnames must be enabled"
assert dns_support is True, "DNS support must be enabled"
return pulumi.Output.all(
infra.vpc.enable_dns_hostnames,
infra.vpc.enable_dns_support
).apply(check_dns)
@pulumi.runtime.test
def test_subnets_are_tagged():
def check_tags(args):
tags, = args
assert "Environment" in tags, "subnet must have Environment tag"
assert tags["Environment"] == "staging"
return pulumi.Output.all(infra.private_subnet.tags).apply(check_tags)
@pulumi.runtime.test
def test_nat_gateway_has_eip():
def check_eip(args):
allocation_id, = args
assert allocation_id is not None, "NAT gateway must have an EIP"
return pulumi.Output.all(infra.nat_gateway.allocation_id).apply(check_eip)Run with:
python -m pytest test_stack.py -vMocking Output Values for Dependency Testing
When resource B depends on output from resource A, your mocks need to return the right output so B receives correct inputs:
class NetworkMocks(pulumi.runtime.Mocks):
def new_resource(self, args):
outputs = args.inputs.copy()
if args.typ == "aws:ec2/vpc:Vpc":
outputs["id"] = "vpc-test-12345"
elif args.typ == "aws:ec2/subnet:Subnet":
# Verify the subnet is using the correct VPC
assert args.inputs.get("vpcId") == "vpc-test-12345", \
f"Subnet must reference correct VPC, got: {args.inputs.get('vpcId')}"
outputs["id"] = f"subnet-test-{args.name}"
elif args.typ == "aws:ec2/routeTableAssociation:RouteTableAssociation":
assert "subnet-test-" in args.inputs.get("subnetId", ""), \
"Route table association must reference a test subnet"
return [args.name + "_id", outputs]This pattern lets you test the full dependency chain without a real cloud.
Integration Tests with the Automation API
The Automation API lets you manage Pulumi stacks programmatically—create, deploy, assert, destroy:
// test/integration_test.go
package test
import (
"context"
"fmt"
"os"
"testing"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVpcIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
runID := os.Getenv("GITHUB_RUN_ID")
if runID == "" {
runID = "local"
}
stackName := fmt.Sprintf("test-%s", runID)
s, err := auto.NewStackLocalSource(ctx, stackName, "../infra")
require.NoError(t, err)
s.SetConfig(ctx, "aws:region", auto.ConfigValue{Value: "us-east-1"})
s.SetConfig(ctx, "environment", auto.ConfigValue{Value: "test"})
defer func() {
s.Destroy(ctx, optdestroy.ProgressStreams(os.Stdout))
s.Workspace().RemoveStack(ctx, stackName)
}()
res, err := s.Up(ctx, optup.ProgressStreams(os.Stdout))
require.NoError(t, err, "stack up failed")
assert.Equal(t, "succeeded", res.Summary.Result)
vpcID, ok := res.Outputs["vpcId"]
require.True(t, ok, "vpcId output must exist")
assert.Regexp(t, `^vpc-[a-f0-9]+$`, vpcID.Value.(string))
subnetIDs, ok := res.Outputs["privateSubnetIds"]
require.True(t, ok, "privateSubnetIds output must exist")
subnets := subnetIDs.Value.([]interface{})
assert.Equal(t, 3, len(subnets), "should have 3 private subnets")
}Python Integration Tests with pytest
# test/test_integration.py
import os
import pytest
import pulumi.automation as auto
@pytest.fixture(scope="module")
def stack():
run_id = os.environ.get("GITHUB_RUN_ID", "local")
stack_name = f"test-{run_id}"
s = auto.create_or_select_stack(
stack_name=stack_name,
work_dir="../infra",
)
s.set_config("aws:region", auto.ConfigValue(value="us-east-1"))
s.set_config("environment", auto.ConfigValue(value="test"))
s.workspace.install_plugin("aws", "v6.0.0")
up_result = s.up(on_output=print)
assert up_result.summary.result == "succeeded"
yield up_result
s.destroy(on_output=print)
s.workspace.remove_stack(stack_name)
def test_vpc_output_exists(stack):
assert "vpcId" in stack.outputs
assert stack.outputs["vpcId"].value.startswith("vpc-")
def test_correct_subnet_count(stack):
subnet_ids = stack.outputs["privateSubnetIds"].value
assert len(subnet_ids) == 3, f"Expected 3 subnets, got {len(subnet_ids)}"
def test_load_balancer_dns_accessible(stack):
import urllib.request
lb_dns = stack.outputs["loadBalancerDns"].value
response = urllib.request.urlopen(f"http://{lb_dns}/health", timeout=10)
assert response.status == 200CI Pipeline
# .github/workflows/pulumi-tests.yml
name: Pulumi Tests
on:
pull_request:
paths: ['infra/**']
push:
branches: [main]
jobs:
unit-tests-go:
name: Go Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.21"
- name: Run unit tests
working-directory: infra
run: go test -v -short ./...
unit-tests-python:
name: Python Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pulumi pulumi-aws pytest
- name: Run unit tests
working-directory: infra
run: python -m pytest tests/unit/ -v
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: [unit-tests-go, unit-tests-python]
if: github.ref == 'refs/heads/main'
environment: aws-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.21"
- name: Run integration tests
working-directory: test
run: go test -v -run Integration -timeout 30m ./...
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-1
GITHUB_RUN_ID: ${{ github.run_id }}Compliance Testing Across All Resources
One powerful Pulumi testing pattern is asserting compliance across every resource of a given type. Define a helper that collects all resources and checks required tags:
@pulumi.runtime.test
def test_all_ec2_instances_use_approved_amis():
approved_amis = {
"ami-0abcdef1234567890", # Amazon Linux 2023
"ami-0fedcba9876543210", # Ubuntu 22.04 hardened
}
def check_ami(args):
ami_id, instance_name = args
assert ami_id in approved_amis, \
f"Instance '{instance_name}' uses unapproved AMI: {ami_id}. " \
f"Approved: {approved_amis}"
checks = []
for instance in infra.all_ec2_instances:
checks.append(
pulumi.Output.all(instance.ami, instance._name).apply(check_ami)
)
return pulumi.Output.all(*checks)This approach scales to any compliance requirement: approved regions, required tags, encryption settings, or security group rules.
Conclusion
Pulumi's testing story is stronger than most IaC tools precisely because infrastructure code is real code. Unit tests with mocks give you millisecond feedback on logic errors, property constraints, and dependency wiring. Integration tests with ephemeral stacks give you confidence that the actual cloud resources are provisioned correctly. Use both, run unit tests on every commit, and gate merges on integration tests passing. Your infrastructure deserves the same quality bar as your application code.