One of the most underrated aspects of CDK is testability. CloudFormation templates and Terraform plans are hard to unit-test; CDK stacks are TypeScript classes that you test with Jest. This lesson shows how.
Setup
npm install --save-dev jest @types/jest ts-jest aws-cdk-lib constructs
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
transform: { '^.+\.tsx?$': 'ts-jest' },
}
The Template Assertion API
aws-cdk-lib/assertions is the built-in test helper. It works by synthesising the stack and then letting you make assertions against the CloudFormation JSON output:
import * as cdk from 'aws-cdk-lib'
import { Template, Match } from 'aws-cdk-lib/assertions'
import { WebStack } from '../lib/web-stack'
describe('WebStack', () => {
let template: Template
beforeEach(() => {
const app = new cdk.App()
const stack = new WebStack(app, 'TestStack', {
env: { account: '123456789', region: 'us-east-1' },
stage: 'dev',
})
template = Template.fromStack(stack)
})
test('creates an S3 bucket with versioning', () => {
template.hasResourceProperties('AWS::S3::Bucket', {
VersioningConfiguration: { Status: 'Enabled' },
})
})
test('bucket blocks all public access', () => {
template.hasResourceProperties('AWS::S3::Bucket', {
PublicAccessBlockConfiguration: {
BlockPublicAcls: true,
BlockPublicPolicy: true,
IgnorePublicAcls: true,
RestrictPublicBuckets: true,
},
})
})
test('creates exactly one CloudFront distribution', () => {
template.resourceCountIs('AWS::CloudFront::Distribution', 1)
})
test('has CloudFront redirect to HTTPS', () => {
template.hasResourceProperties('AWS::CloudFront::Distribution', {
DistributionConfig: {
DefaultCacheBehavior: {
ViewerProtocolPolicy: 'redirect-to-https',
},
},
})
})
})
Match Helpers
The Match object provides partial matching — you don't need to specify the entire CloudFormation resource:
// Partial deep match — only check the fields you care about
template.hasResourceProperties('AWS::SQS::Queue', {
VisibilityTimeout: 30,
// other properties you don't care about are ignored
})
// Array contains
template.hasResourceProperties('AWS::S3::BucketPolicy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Effect: 'Allow',
Principal: { Service: 's3.amazonaws.com' },
}),
]),
},
})
// Not — assert a property does NOT exist
template.hasResourceProperties('AWS::S3::Bucket', {
PublicAccessBlockConfiguration: Match.absent(), // should not be missing
})
// Literal — exact match
Match.exact({ Status: 'Enabled' })
Testing Outputs and Parameters
test('exports the bucket name', () => {
template.hasOutput('BucketName', {
Value: Match.anyValue(),
})
})
test('has a distribution URL output', () => {
const outputs = template.findOutputs('*', {})
expect(Object.keys(outputs)).toContain('DistributionUrl')
})
test('no unexpected parameters', () => {
const params = template.findParameters('*', {})
// CDK generates params for S3 asset bootstrap -- allow those
Object.keys(params).forEach((p) => {
expect(p).toMatch(/^BootstrapVersion|AssetParameters/)
})
Testing IAM — the Most Important Category
test('lambda has least-privilege S3 access', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Effect: 'Allow',
Action: Match.arrayWith(['s3:GetObject', 's3:ListBucket']),
// should NOT have s3:DeleteObject or s3:*
}),
]),
},
})
})
test('lambda role has no star actions', () => {
const policies = template.findResources('AWS::IAM::Policy', {})
Object.values(policies).forEach((policy) => {
const statements = policy.Properties?.PolicyDocument?.Statement ?? []
statements.forEach((stmt: { Action: string | string[] }) => {
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action]
actions.forEach((action: string) => {
expect(action).not.toBe('*')
expect(action).not.toMatch(/^*/)
})
})
})
})
IAM over-privilege is a leading cause of cloud breaches. Testing that your constructs emit least-privilege policies is the highest-value test you can write.
Snapshot Testing
test('template matches snapshot', () => {
expect(template.toJSON()).toMatchSnapshot()
})
On first run, Jest writes the snapshot to __snapshots__/. On subsequent runs it diffs. Snapshot tests catch regressions — cdk diff equivalent in a test.
Caveats:
- CDK generates unique IDs that change on refactors — snapshots break noisily.
- Snapshots are hard to review in PRs.
- Prefer targeted assertions over full snapshots; reserve snapshots for complex constructs where exhaustive coverage is impractical.
What to Test
| Test category | Priority | Example |
|---|---|---|
| IAM least privilege | High | No star actions; correct resource ARNs |
| Security properties | High | Encryption on, public access off, SSL enforced |
| Required resources exist | High | Lambda + role + log group all created |
| Conditional logic | High | prod vs dev differences |
| Resource counts | Medium | Exactly 3 subnets, 2 NAT gateways |
| Outputs | Medium | Expected Cfn outputs exist |
| Tags | Medium | All resources have mandatory tags |
| Full snapshot | Low | Useful as regression net for complex stacks |
Integration Tests
Unit tests run against synthesised JSON. Integration tests (cdk-integ-tests) deploy real stacks and verify real AWS state:
import { App } from 'aws-cdk-lib'
import { IntegTest } from '@aws-cdk/integ-tests-alpha'
import { WebStack } from '../lib/web-stack'
const app = new App()
const stack = new WebStack(app, 'IntegWebStack', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT!, region: 'us-east-1' },
stage: 'dev',
})
new IntegTest(app, 'WebInteg', { testCases: [stack] })
Run with integ-runner. Integration tests are slow and cost money — run them pre-release, not on every PR.
Testing Constructs in Isolation
describe('SecureBucket construct', () => {
test('lifecycle rule for intelligent tiering', () => {
const app = new cdk.App()
const stack = new cdk.Stack(app, 'S')
new SecureBucket(stack, 'B', { bucketPurpose: 'test', team: 'eng', costCenter: 'cc-1' })
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
LifecycleConfiguration: {
Rules: Match.arrayWith([
Match.objectLike({
Status: 'Enabled',
Transitions: Match.arrayWith([
{ StorageClass: 'INTELLIGENT_TIERING' },
]),
}),
]),
},
})
})
})
A construct test creates a throwaway stack, adds the construct, synthesises, and asserts. Tests run in under a second — no AWS credentials needed.
The Testing Pyramid
- Many unit tests — per construct, fast, no AWS credentials
- Some snapshot tests — complex stacks as regression nets
- Few integration tests — per major release / high-risk construct
This mirrors the standard application testing pyramid. CDK's testability is one of the best arguments for it over raw CloudFormation. In the next lesson we use that testability inside a CI/CD pipeline.