Skip to content
6 min read·Lesson 5 of 8

Testing IaC with Jest and CDK Assertions

Unit-testing CDK stacks and constructs — fine-grained assertions, snapshot testing, and what to test.

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 categoryPriorityExample
IAM least privilegeHighNo star actions; correct resource ARNs
Security propertiesHighEncryption on, public access off, SSL enforced
Required resources existHighLambda + role + log group all created
Conditional logicHighprod vs dev differences
Resource countsMediumExactly 3 subnets, 2 NAT gateways
OutputsMediumExpected Cfn outputs exist
TagsMediumAll resources have mandatory tags
Full snapshotLowUseful 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

  1. Many unit tests — per construct, fast, no AWS credentials
  2. Some snapshot tests — complex stacks as regression nets
  3. 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.

Key Takeaways

  • CDK applications are TypeScript — test them like TypeScript: with Jest.
  • aws-cdk-lib/assertions provides Template.fromStack() with resource matching, count, and property assertions.
  • Snapshot testing catches unintended changes but can be too noisy without careful setup.
  • Test constructs, not output YAML — verify the intent, not the JSON.
  • Integration tests (cdk-integ-tests) deploy real stacks; use for high-risk constructs.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →