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 changesLayer 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 leadLayer 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-deployCI 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-1Python 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:
- Fine-grained assertions — use
Template.hasResourceProperties()to assert security rules, IAM policies, and resource configurations on every PR - Snapshot tests — catch unintended template regressions, require human review of changes
- 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."