AWS CDK Testing Patterns: Assertions, Snapshots, and Integration Tests

AWS CDK Testing Patterns: Assertions, Snapshots, and Integration Tests

AWS CDK generates CloudFormation templates from TypeScript, Python, or Java code. The CDK Assertions library lets you test the generated template — verifying resource configurations without deploying anything. CDK snapshot tests catch unintended template changes. CDK integ tests validate actual deployments in real AWS accounts. Together, these three patterns give you a complete CDK testing strategy.

Key Takeaways

Use fine-grained assertions over snapshot tests for critical security properties. Snapshot tests catch changes, but fine-grained assertions communicate intent. An assertion that "no security group allows 0.0.0.0/0 ingress" is self-documenting; a snapshot is not.

Snapshots are for regression prevention, not specification. Snapshot tests tell you something changed — you still have to decide if the change was intentional. They're best for catching accidental drift in complex templates.

CDK integ tests deploy real AWS resources — use dedicated test accounts. Never run integ tests in production or dev accounts. Use a separate AWS account for test environments, and always clean up with integ:destroy.

Test construct outputs, not just template presence. CDK constructs expose typed outputs. Assert that the VPC ID, Lambda ARN, and table name outputs are non-empty and have expected formats.

Higher-level constructs (L2/L3) have built-in secure defaults — test that you haven't overridden them. CDK L2 constructs block public access to S3 by default. If your code passes blockPublicAccess: s3.BlockPublicAccess.BLOCK_ACLS, your test should catch that regression.

CDK Testing Layers

integ tests         Deploy to real AWS, full validation
(CDK Integ Tests)   Slow, costs money, runs nightly

fine-grained        Assert on synthesized CloudFormation
assertions          Fast, no AWS needed, runs on every PR
(CDK Assertions)

snapshot tests      Catch template regressions
(jest snapshots)    Fast, but need human review of changes

Layer 1: Fine-Grained Assertions

The aws-cdk-lib/assertions module lets you query the synthesized CloudFormation template:

Setting Up

// test/networking-stack.test.ts
import { App } from "aws-cdk-lib";
import { Template, Match } from "aws-cdk-lib/assertions";
import { NetworkingStack } from "../lib/networking-stack";

let template: Template;

beforeAll(() => {
  const app = new App();
  const stack = new NetworkingStack(app, "TestNetworkingStack", {
    env: { account: "123456789", region: "us-east-1" },
    vpcCidr: "10.0.0.0/16",
    maxAzs: 2,
  });
  template = Template.fromStack(stack);
});

Testing VPC and Subnet Configuration

describe("VPC Configuration", () => {
  test("VPC is created with correct CIDR", () => {
    template.hasResourceProperties("AWS::EC2::VPC", {
      CidrBlock: "10.0.0.0/16",
      EnableDnsHostnames: true,
      EnableDnsSupport: true,
    });
  });

  test("Two public and two private subnets are created", () => {
    // Count subnets by type
    const publicSubnets = template.findResources("AWS::EC2::Subnet", {
      Properties: {
        Tags: Match.arrayWith([
          { Key: "aws-cdk:subnet-type", Value: "Public" }
        ])
      }
    });
    
    const privateSubnets = template.findResources("AWS::EC2::Subnet", {
      Properties: {
        Tags: Match.arrayWith([
          { Key: "aws-cdk:subnet-type", Value: "Private" }
        ])
      }
    });
    
    expect(Object.keys(publicSubnets)).toHaveLength(2);
    expect(Object.keys(privateSubnets)).toHaveLength(2);
  });

  test("NAT gateways are deployed for private subnet egress", () => {
    template.resourceCountIs("AWS::EC2::NatGateway", 2); // One per AZ
  });

  test("Internet gateway is attached to VPC", () => {
    template.resourceCountIs("AWS::EC2::InternetGateway", 1);
    template.resourceCountIs("AWS::EC2::VPCGatewayAttachment", 1);
  });
});

Testing Security Groups

describe("Security Group Rules", () => {
  test("No security group allows unrestricted ingress from 0.0.0.0/0", () => {
    const sgs = template.findResources("AWS::EC2::SecurityGroup");
    
    for (const [logicalId, sg] of Object.entries(sgs)) {
      const ingressRules = sg.Properties?.SecurityGroupIngress ?? [];
      
      for (const rule of ingressRules) {
        if (rule.CidrIp === "0.0.0.0/0" || rule.CidrIpv6 === "::/0") {
          // Allow HTTP/HTTPS to ALB, but block everything else
          const allowedPorts = [80, 443];
          expect(allowedPorts).toContain(rule.FromPort);
        }
      }
    }
  });

  test("Application security group allows HTTPS only", () => {
    template.hasResourceProperties("AWS::EC2::SecurityGroup", {
      GroupDescription: Match.stringLikeRegexp("Application"),
      SecurityGroupIngress: Match.arrayWith([
        Match.objectLike({
          FromPort: 443,
          ToPort: 443,
          IpProtocol: "tcp",
        })
      ])
    });
  });
});

Testing IAM Policies

describe("IAM Permissions", () => {
  test("Lambda execution role has least-privilege permissions", () => {
    template.hasResourceProperties("AWS::IAM::Policy", {
      PolicyDocument: {
        Statement: Match.arrayWith([
          Match.objectLike({
            Effect: "Allow",
            Action: Match.arrayWith(["s3:GetObject"]),
            Resource: Match.not(Match.stringLikeRegexp("\\*$"))  // No wildcard resources
          })
        ])
      }
    });
  });

  test("No wildcard actions in Lambda role", () => {
    const roles = template.findResources("AWS::IAM::Role", {
      Properties: {
        AssumeRolePolicyDocument: {
          Statement: Match.arrayWith([
            Match.objectLike({
              Principal: { Service: "lambda.amazonaws.com" }
            })
          ])
        }
      }
    });
    
    // Find attached policies and check no wildcard actions
    const policies = template.findResources("AWS::IAM::Policy");
    for (const [_, policy] of Object.entries(policies)) {
      const statements = policy.Properties?.PolicyDocument?.Statement ?? [];
      for (const stmt of statements) {
        if (stmt.Effect === "Allow") {
          const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
          for (const action of actions) {
            expect(action).not.toBe("*");
            expect(action).not.toMatch(/:\*$/);
          }
        }
      }
    }
  });
});

Testing S3 Buckets

describe("S3 Bucket Configuration", () => {
  test("All S3 buckets have versioning enabled", () => {
    const buckets = template.findResources("AWS::S3::Bucket");
    
    for (const [logicalId, bucket] of Object.entries(buckets)) {
      const versioning = bucket.Properties?.VersioningConfiguration;
      expect(versioning?.Status).toBe("Enabled");
      // ... if one fails, the test message shows which logical ID
    }
  });

  test("All S3 buckets block public access", () => {
    const buckets = template.findResources("AWS::S3::Bucket");
    
    for (const [logicalId, bucket] of Object.entries(buckets)) {
      const blockConfig = bucket.Properties?.PublicAccessBlockConfiguration;
      
      expect(blockConfig?.BlockPublicAcls).toBe(true);
      expect(blockConfig?.BlockPublicPolicy).toBe(true);
      expect(blockConfig?.IgnorePublicAcls).toBe(true);
      expect(blockConfig?.RestrictPublicBuckets).toBe(true);
    }
  });

  test("S3 buckets have server-side encryption", () => {
    template.hasResourceProperties("AWS::S3::Bucket", {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: Match.arrayWith([
          Match.objectLike({
            ServerSideEncryptionByDefault: {
              SSEAlgorithm: Match.anyValue()  // AES256 or aws:kms
            }
          })
        ])
      }
    });
  });
});

Testing Lambda Functions

describe("Lambda Configuration", () => {
  test("Lambda functions have appropriate memory and timeout", () => {
    const functions = template.findResources("AWS::Lambda::Function");
    
    for (const [logicalId, fn] of Object.entries(functions)) {
      const memory = fn.Properties?.MemorySize ?? 128;
      const timeout = fn.Properties?.Timeout ?? 3;
      
      expect(memory).toBeGreaterThanOrEqual(128);
      expect(memory).toBeLessThanOrEqual(3008);
      expect(timeout).toBeLessThanOrEqual(900); // 15 min max
    }
  });

  test("Lambda functions have reserved concurrency set (no unintended scaling)", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      ReservedConcurrentExecutions: Match.anyValue()
    });
  });

  test("Lambda functions use ARM64 architecture for cost efficiency", () => {
    const functions = template.findResources("AWS::Lambda::Function", {
      Properties: {
        // Skip CDK custom resource Lambdas
        Description: Match.absent()
      }
    });
    
    for (const [_, fn] of Object.entries(functions)) {
      const arch = fn.Properties?.Architectures ?? ["x86_64"];
      expect(arch).toContain("arm64");
    }
  });
});

Layer 2: Snapshot Testing

Snapshot tests capture the full synthesized template and fail if it changes unexpectedly:

// test/networking-snapshot.test.ts
import { App } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { NetworkingStack } from "../lib/networking-stack";

test("networking stack matches snapshot", () => {
  const app = new App();
  const stack = new NetworkingStack(app, "SnapshotTest", {
    env: { account: "123456789", region: "us-east-1" },
    vpcCidr: "10.0.0.0/16",
    maxAzs: 2,
  });
  
  const template = Template.fromStack(stack);
  expect(template.toJSON()).toMatchSnapshot();
});
# Update snapshots when changes are intentional
npx jest --updateSnapshot

<span class="hljs-comment"># Review changes before updating
git diff tests/__snapshots__/

When to Update Snapshots

Create a checklist in your PR template for snapshot changes:

## CDK Snapshot Changes Checklist (check if this PR updates snapshots)

- [ ] I understand what CloudFormation resources changed
- [ ] Security group rules reviewed — no new 0.0.0.0/0 ingress
- [ ] IAM permissions reviewed — no new wildcard actions
- [ ] S3 bucket configs reviewed — public access still blocked
- [ ] No new resources that weren't intentionally added
- [ ] Snapshot update approved by team lead

Layer 3: CDK Integ Tests

CDK Integ Tests deploy real AWS infrastructure and validate it works:

Writing Integ Tests

// integ/integ.networking.ts
import { App } from "aws-cdk-lib";
import { IntegTest } from "@aws-cdk/integ-tests-alpha";
import { NetworkingStack } from "../lib/networking-stack";

const app = new App();

const testStack = new NetworkingStack(app, "IntegNetworkingStack", {
  env: {
    account: process.env.CDK_INTEG_ACCOUNT!,
    region: process.env.CDK_INTEG_REGION ?? "us-east-1",
  },
  vpcCidr: "10.99.0.0/16",
  maxAzs: 2,
});

const integ = new IntegTest(app, "NetworkingIntegTest", {
  testCases: [testStack],
  cdkCommandOptions: {
    destroy: {
      args: {
        force: true,
      },
    },
  },
  enableLookups: false,
  stackUpdateWorkflow: true,
});

// Assert on deployed outputs
const vpcId = integ.assertions.awsApiCall("EC2", "describeVpcs", {
  Filters: [
    {
      Name: "tag:aws:cloudformation:stack-name",
      Values: ["IntegNetworkingStack"],
    },
  ],
});

vpcId.assertAtPath("Vpcs.0.CidrBlock", ExpectedResult.stringLikeRegexp("10.99.0.0/16"));
vpcId.assertAtPath("Vpcs.0.State", ExpectedResult.stringLikeRegexp("available"));
# Run integ tests
npx integ-runner --directory integ/ --parallel-regions us-east-1

<span class="hljs-comment"># Dry run (synthesize only, don't deploy)
npx integ-runner --directory integ/ --dry-run

<span class="hljs-comment"># Destroy after run
npx integ-runner --directory integ/ --destroy-after-deploy

CI for CDK Tests

# .github/workflows/cdk-tests.yml
name: CDK Tests

on:
  pull_request:
    paths:
      - 'lib/**'
      - 'test/**'
  schedule:
    - cron: '0 5 * * *'  # Nightly integ tests

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

  integ-tests:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    needs: unit-tests
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.INTEG_ACCOUNT_ID }}:role/CDKIntegTestRole
          aws-region: us-east-1
      
      - name: Run CDK integ tests
        env:
          CDK_INTEG_ACCOUNT: ${{ secrets.INTEG_ACCOUNT_ID }}
          CDK_INTEG_REGION: us-east-1
        run: npx integ-runner --directory integ/ --parallel-regions us-east-1

Python CDK Testing

# test/test_networking_stack.py
import aws_cdk as cdk
from aws_cdk.assertions import Template, Match
import pytest
from infra.networking_stack import NetworkingStack

@pytest.fixture(scope="module")
def template():
    app = cdk.App()
    stack = NetworkingStack(
        app, "TestNetworkingStack",
        env=cdk.Environment(account="123456789", region="us-east-1"),
        vpc_cidr="10.0.0.0/16",
        max_azs=2
    )
    return Template.from_stack(stack)

def test_vpc_cidr(template):
    template.has_resource_properties("AWS::EC2::VPC", {
        "CidrBlock": "10.0.0.0/16",
        "EnableDnsHostnames": True,
    })

def test_no_public_sg_ingress(template):
    sgs = template.find_resources("AWS::EC2::SecurityGroup")
    
    for logical_id, sg in sgs.items():
        ingress_rules = sg.get("Properties", {}).get("SecurityGroupIngress", [])
        for rule in ingress_rules:
            if rule.get("CidrIp") == "0.0.0.0/0":
                allowed_ports = [80, 443]
                assert rule.get("FromPort") in allowed_ports, \
                    f"SG {logical_id} allows 0.0.0.0/0 on non-HTTP/S port"

def test_s3_buckets_encrypted(template):
    buckets = template.find_resources("AWS::S3::Bucket")
    
    for logical_id, bucket in buckets.items():
        encryption = bucket.get("Properties", {}).get("BucketEncryption")
        assert encryption is not None, f"Bucket {logical_id} missing encryption config"

Monitoring with HelpMeTest

After CDK deploys your infrastructure, HelpMeTest provides continuous health monitoring for your AWS applications:

*** Test Cases ***
CDK-Deployed API Health Check
    [Documentation]    Verify API Gateway endpoint is healthy post-CDK deploy
    ${url}=    Get Stack Output    NetworkingStack    ApiGatewayUrl
    ${response}=    GET    ${url}/health
    Status Should Be    200    ${response}

Set up monitoring alerts for your deployed APIs and databases — instant notification when something breaks in production.

Summary

A complete CDK testing strategy uses all three layers:

  1. Fine-grained assertions — use Template.hasResourceProperties() to assert security rules, IAM policies, and resource configurations on every PR
  2. Snapshot tests — catch unintended template regressions, require human review of changes
  3. CDK integ tests — deploy to real AWS, validate actual behavior, run nightly in dedicated test accounts

The key discipline: write assertions that communicate security intent (no wildcard IAM, no public S3, no open security groups), not just assertions that "a resource was created."

Read more