cloud-devops

AWS CDK Testing in TypeScript: Test Before Deploy 2026

June 18, 2026

AWS CDK Testing in TypeScript: Test Before Deploy 2026

To unit-test an AWS CDK stack in TypeScript, synthesize it to a CloudFormation template with Template.fromStack() and assert against that template using the aws-cdk-lib/assertions module and Jest. Nothing is deployed and no AWS account is required.

TL;DR

This hands-on guide adds a full test suite to an AWS CDK app in TypeScript so you catch infrastructure mistakes before cdk deploy ever runs. You will scaffold a project with the CDK CLI, build a small IngestPipeline construct (an encrypted, versioned S3 bucket plus a Lambda function), then write fine-grained assertions, partial matchers, value captures, a snapshot test, and a governance test using Aspects and Annotations — all with Jest 30.4.21 and the aws-cdk-lib 2.260.02 assertions module. Every test here was executed against a real synth on 18 June 2026: 3 suites, 10 tests, all passing, with no AWS credentials and nothing deployed. Budget about 30–40 minutes.

What you'll learn

  • How to scaffold a CDK TypeScript project wired for Jest
  • How to write fine-grained assertions against the synthesized CloudFormation template
  • How to use Match for partial, order-independent, and "must be absent" matching
  • How to capture generated values (like a Ref) and cross-check them
  • How to assert resource-level policies such as DeletionPolicy: Retain
  • When snapshot tests help — and the regression trap they hide
  • How to test governance rules with Aspects and Annotations
  • How to run the whole suite in CI without an AWS account

Why test CDK at all

CDK code looks like an application, but it produces a CloudFormation template. A typo in a bucket policy, a Lambda with the wrong runtime, or a construct that quietly drops encryption will not fail to compile — it will deploy and then cause an incident. The CDK ships an assertions library that inspects the synthesized template without contacting AWS, so you can prove your stack produces the resources you intend. This is the same idea behind reviewing Terraform plans before apply, covered in our Terraform S3 native state locking tutorial; here the verification happens in your test runner.

The tests below run in milliseconds and need no network, so they belong in the same pull-request check that runs your application tests. Each one fails loudly the moment a refactor changes the generated infrastructure, which is exactly when a reviewer wants to know — not after the change has already reached an environment.

Prerequisites

  • Node.js 22+ (the sandbox used Node 22.22.3; Node 20 or newer works)
  • npm 10+
  • No AWS account or credentials — synthesis is entirely local
  • Pinned versions used throughout: aws-cdk-lib 2.260.02, aws-cdk (CLI) 2.1128.0, constructs 10.6.0, TypeScript 5.9.3, Jest 30.4.21, ts-jest 29.4.11, @types/jest 30.0.0, @types/node 24.13.2

Step 1: Scaffold a CDK TypeScript project

Create an empty directory and let the CDK CLI generate a TypeScript app. Using npx avoids a global install.

mkdir ingest-pipeline && cd ingest-pipeline
npx --yes aws-cdk@2.1128.0 init app --language typescript

The scaffold already wires up Jest. The generated jest.config.js looks like this3:

module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/test'],
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],
};

That jest-autoclean helper registers an afterAll hook that deletes the temporary cloud-assembly directories each synth creates, so a large test run does not leave temporary synthesis directories behind4.

One adjustment makes the output clean. The scaffold's tsconfig.json uses module: "NodeNext", and under that setting ts-jest prints a TS151002 hybrid-module warning unless isolated modules are on. Add one line to compilerOptions:

{
  "compilerOptions": {
    "isolatedModules": true
  }
}

To pin the exact versions this guide was verified with, install them explicitly:

npm install aws-cdk-lib@2.260.0 constructs@10.6.0
npm install -D aws-cdk@2.1128.0 jest@30.4.2 ts-jest@29.4.11 \
  @types/jest@30.0.0 @types/node@24.13.2 typescript@5.9.3 ts-node@10.9.2

Step 2: Define the infrastructure under test

Replace lib/ingest-pipeline.ts with a small reusable construct: a hardened S3 bucket and a Lambda processor that reads from it. This is the code your tests will hold to account.

import { Construct } from 'constructs';
import { Duration, RemovalPolicy } from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export interface IngestPipelineProps {
  /** Timeout for the processor function. Defaults to 30 seconds. */
  readonly processorTimeout?: Duration;
}

export class IngestPipeline extends Construct {
  public readonly bucket: s3.Bucket;
  public readonly processor: lambda.Function;

  constructor(scope: Construct, id: string, props: IngestPipelineProps = {}) {
    super(scope, id);

    this.bucket = new s3.Bucket(this, 'Raw', {
      versioned: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    this.processor = new lambda.Function(this, 'Processor', {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline('exports.handler = async () => ({ ok: true });'),
      timeout: props.processorTimeout ?? Duration.seconds(30),
      environment: { RAW_BUCKET: this.bucket.bucketName },
    });

    this.bucket.grantRead(this.processor);
  }
}

A few choices matter for the tests below: versioned: true and S3_MANAGED encryption, enforceSSL (which adds a bucket policy denying non-TLS access), RemovalPolicy.RETAIN (which becomes a CloudFormation DeletionPolicy), and grantRead (which generates an IAM policy). Node 22 is a current Lambda runtime supported through April 2027; NODEJS_24_X is also available if you prefer the newer line5.

Wrap the construct in a stack in lib/app-stack.ts:

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { IngestPipeline } from './ingest-pipeline';

export class AppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    new IngestPipeline(this, 'Ingest');
  }
}

Step 3: Write your first fine-grained assertion

A fine-grained assertion checks one specific thing about the generated template — "this resource type exists this many times" or "this resource has this property." It is the workhorse of CDK testing and the right default for catching regressions6. Create test/ingest-pipeline.test.ts:

import { App, Stack, Duration } from 'aws-cdk-lib';
import { Template, Match, Capture } from 'aws-cdk-lib/assertions';
import { IngestPipeline } from '../lib/ingest-pipeline';

function synth(props = {}): Template {
  const app = new App();
  const stack = new Stack(app, 'TestStack');
  new IngestPipeline(stack, 'Ingest', props);
  return Template.fromStack(stack);
}

test('creates exactly one encrypted, versioned bucket', () => {
  const template = synth();
  template.resourceCountIs('AWS::S3::Bucket', 1);
  template.hasResourceProperties('AWS::S3::Bucket', {
    VersioningConfiguration: { Status: 'Enabled' },
  });
});

Template.fromStack(stack) synthesizes the stack in memory and returns an object you can query. resourceCountIs fails if the count is off; hasResourceProperties fails if no resource of that type has a superset of the properties you list. Run it:

npm test

You should see the test pass. Nothing touched AWS — the template exists only in memory.

Step 4: Partial matching with Match

Real templates nest deeply, and you rarely want to assert every property. The Match matchers let you assert just the parts you care about7. Add these tests to the same file:

test('bucket uses S3-managed (AES256) encryption', () => {
  synth().hasResourceProperties('AWS::S3::Bucket', {
    BucketEncryption: {
      ServerSideEncryptionConfiguration: Match.arrayWith([
        Match.objectLike({
          ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' },
        }),
      ]),
    },
  });
});

test('enforceSSL adds a deny-insecure-transport policy', () => {
  synth().hasResourceProperties('AWS::S3::BucketPolicy', {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Effect: 'Deny',
          Condition: { Bool: { 'aws:SecureTransport': 'false' } },
        }),
      ]),
    },
  });
});

test('processor runs Node 22, 30s timeout, no reserved concurrency', () => {
  synth().hasResourceProperties('AWS::Lambda::Function', Match.objectLike({
    Runtime: 'nodejs22.x',
    Handler: 'index.handler',
    Timeout: 30,
    ReservedConcurrentExecutions: Match.absent(),
  }));
});

Three matchers do the heavy lifting. Match.arrayWith([...]) passes if the array contains the listed elements in any order — essential because CloudFormation statement order is not guaranteed. Match.objectLike({...}) does a recursive partial match, so extra keys are ignored. And Match.absent() asserts a key is not present — here, proving the function has no reserved concurrency. Match.stringLikeRegexp('...') (used next) matches a string against a regex.

The same nested matchers let you assert generated IAM. Because the construct calls bucket.grantRead(this.processor), CDK emits an IAM policy whose statement allows reading objects from the bucket. This test confirms that read permission is present:

test('grantRead wires an IAM policy allowing s3:GetObject', () => {
  synth().hasResourceProperties('AWS::IAM::Policy', {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Action: Match.arrayWith(['s3:GetObject*']),
          Effect: 'Allow',
        }),
      ]),
    },
  });
});

Asserting on the synthesized policy keeps the permissions your constructs generate under test: if a refactor dropped the grantRead call, the s3:GetObject* statement would disappear and this test would fail before the change reached an account. (To prove a grant is not broader than read, you would additionally assert that write actions such as s3:PutObject are absent — grantReadWrite would still satisfy the assertion above, since it also includes s3:GetObject*.)

Step 5: Cross-reference generated values with Capture

CDK assigns logical IDs and wires references for you, so you cannot hardcode them. Capture grabs the actual generated value during matching so you can assert on it afterward.

test('processor env var points at the created bucket', () => {
  const template = synth();
  const bucketRef = new Capture();

  template.hasResourceProperties('AWS::Lambda::Function', {
    Environment: { Variables: { RAW_BUCKET: bucketRef } },
  });

  // The captured value is a CloudFormation { Ref: <logicalId> } object.
  const ref = bucketRef.asObject().Ref as string;
  expect(ref).toMatch(/^IngestRaw/);
  const buckets = template.findResources('AWS::S3::Bucket');
  expect(Object.keys(buckets)).toContain(ref);
});

After the match runs, bucketRef.asObject() returns the real value — { Ref: 'IngestRawC4D239E4' }. We confirm the Lambda's RAW_BUCKET variable references a logical ID that actually exists in the template via findResources, which returns a map keyed by logical ID. One gotcha: the captured value is a plain JavaScript object, so assert on it with Jest matchers like toMatch, not with a CDK Match.* matcher.

Step 6: Assert resource-level metadata with hasResource

hasResourceProperties only looks at the Properties block. To check resource-level attributes like DeletionPolicy, use hasResource, which sees the whole resource:

test('bucket is retained on stack deletion', () => {
  synth().hasResource('AWS::S3::Bucket', {
    DeletionPolicy: 'Retain',
    UpdateReplacePolicy: 'Retain',
  });
});

Because the construct sets RemovalPolicy.RETAIN, CloudFormation should keep the bucket if the stack is deleted. This test guards against an accidental policy change that would make production data deletable.

Step 7: Snapshot testing — and when not to trust it

A snapshot test serializes the entire template and compares it to a stored baseline. Create test/snapshot.test.ts:

import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { IngestPipeline } from '../lib/ingest-pipeline';

test('IngestPipeline matches the stored snapshot', () => {
  const app = new App();
  const stack = new Stack(app, 'SnapStack');
  new IngestPipeline(stack, 'Ingest');
  expect(Template.fromStack(stack).toJSON()).toMatchSnapshot();
});

The first run writes the baseline; later runs fail if anything changes. Snapshots are genuinely useful for refactoring: when you reshape your CDK code but intend zero output change, an unchanged snapshot proves it.

The trap is treating snapshots as regression tests. AWS's own guidance is explicit that snapshot testing is not good at catching regressions, because it compares the whole template and non-code changes — a construct adopting a new best practice, a CDK Toolkit update that adds metadata, or a changed context value — can shift the synthesized output and break the snapshot without any real deployment difference6. Use snapshots to lock down refactors; use the fine-grained assertions from Steps 3–6 to catch regressions. (Our inline Lambda code keeps this snapshot deterministic; Code.fromAsset would embed a content hash that changes the snapshot on every code edit.)

Step 8: Test governance rules with Aspects and Annotations

Beyond individual resources, you often want a policy across the whole app — for example, "every S3 bucket must have versioning." An Aspect visits every construct in the tree; we attach a synthesis-time error if a bucket violates the rule. Create lib/require-versioning.ts:

import { IAspect, Annotations } from 'aws-cdk-lib';
import { IConstruct } from 'constructs';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';

/** Fails validation if any S3 bucket has versioning disabled. */
export class RequireBucketVersioning implements IAspect {
  public visit(node: IConstruct): void {
    if (node instanceof CfnBucket) {
      const versioning = node.versioningConfiguration as
        | { status?: string }
        | undefined;
      if (!versioning || versioning.status !== 'Enabled') {
        Annotations.of(node).addError(
          'S3 buckets must have versioning enabled (data-retention policy).',
        );
      }
    }
  }
}

The Annotations assertion API lets you test that the Aspect fires correctly. Create test/governance.test.ts:

import { App, Stack, Aspects } from 'aws-cdk-lib';
import { Annotations, Match } from 'aws-cdk-lib/assertions';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { IngestPipeline } from '../lib/ingest-pipeline';
import { RequireBucketVersioning } from '../lib/require-versioning';

test('flags a bucket created without versioning', () => {
  const app = new App();
  const stack = new Stack(app, 'BadStack');
  new Bucket(stack, 'Unversioned'); // versioning off by default
  Aspects.of(stack).add(new RequireBucketVersioning());

  Annotations.fromStack(stack).hasError(
    '*',
    Match.stringLikeRegexp('versioning enabled'),
  );
});

test('passes a compliant pipeline with no errors', () => {
  const app = new App();
  const stack = new Stack(app, 'GoodStack');
  new IngestPipeline(stack, 'Ingest'); // bucket is versioned
  Aspects.of(stack).add(new RequireBucketVersioning());

  Annotations.fromStack(stack).hasNoError('*', Match.anyValue());
});

Annotations.fromStack(stack) exposes hasError, hasNoError, hasWarning, and findError8. The first argument is a construct path ('*' matches any), and the second is a matcher for the message. This pattern turns your compliance rules into tests, so a future change that disables versioning fails CI instead of shipping.

Step 9: Run the suite in CI without an AWS account

Because synthesis is local, the whole suite runs on a plain CI runner with no cloud credentials. Add .github/workflows/test.yml:

name: cdk-tests
on:
  push:
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

That is the entire pipeline. Once these tests pass you can wire a deploy job that assumes a role with OIDC — see our GitHub Actions OIDC keyless AWS deploys tutorial — knowing the template was validated first. If you also export traces from the deployed app, the OpenTelemetry Collector Node.js tracing tutorial pairs well with this CI gate.

Verification

Run the full suite:

npm test

Expected output (versions and timing will vary):

PASS test/governance.test.ts
PASS test/snapshot.test.ts
PASS test/ingest-pipeline.test.ts

Test Suites: 3 passed, 3 total
Tests:       10 passed, 10 total
Snapshots:   1 written, 1 total

To confirm synthesis itself works with no credentials, unset any AWS variables and synth the app:

unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_PROFILE
npx cdk synth

It exits 0 and writes cdk.out/AppStack.template.json containing the S3 bucket, bucket policy, IAM role and policy, and Lambda function — proof the same template your tests asserted against is the one CDK would deploy.

Troubleshooting

ts-jest[config] TS151002: Using hybrid module kind ... isolatedModules. The scaffold's tsconfig.json uses module: "NodeNext". Set "isolatedModules": true in compilerOptions (Step 1). The warning is otherwise harmless but noisy.

A test fails with "Template has N resources ... but none match as expected." This is the normal fine-grained failure message. CDK prints the closest matching resource and marks the offending line, for example !! Expected 25 but received 30 on a Timeout. Read the printed resource, fix either the construct or the assertion, and re-run.

A snapshot test breaks after npm update. Upgrading aws-cdk-lib can legitimately change synthesized output. Confirm the diff is only library-driven, then refresh the baseline with npm test -- -u. This is exactly why snapshots should not be your regression net (Step 7).

Capture assertion throws "is not a function" or never matches. Call .asObject() or .asString() only after the hasResourceProperties call that populates the capture has run, and assert on the result with Jest matchers — not with a CDK Match.* matcher.

A jest worker process was terminated ... SIGKILL. On memory-constrained runners, CDK synth plus parallel Jest workers can exhaust RAM. Run with npm test -- --maxWorkers=1 (or --runInBand). Default workers are fine on a normal machine.

cdk synth prints "NN feature flags are not configured." That is an informational CLI notice in recent CDK versions, not an error; synthesis still succeeds. It does not affect tests, which call Template.fromStack directly.

Next steps

You now have a CDK suite that proves resource configuration, cross-references generated values, locks refactors with a snapshot, and enforces a governance rule — all before anything deploys. From here, add assertions for IAM least-privilege policies, test multiple stacks in one app, and fold these tests into the deploy gate described in the GitHub Actions OIDC keyless deploys tutorial.

Further reading

  • AWS — Test AWS CDK applications6
  • AWS — aws-cdk-lib.assertions module reference7

Footnotes

  1. Jest on npm (30.4.2). https://www.npmjs.com/package/jest 2

  2. aws-cdk-lib on npm (2.260.0 latest as of 18 June 2026). https://www.npmjs.com/package/aws-cdk-lib 2

  3. Scaffold generated by the aws-cdk CLI cdk init app --language typescript (aws-cdk 2.1128.0). https://www.npmjs.com/package/aws-cdk

  4. Generated into jest.config.js by cdk init; implemented in the aws-cdk-lib package as testhelpers/jest-autoclean (registers afterAll(CloudAssembly.cleanupTemporaryDirectories)). https://www.npmjs.com/package/aws-cdk-lib

  5. AWS — Lambda Node.js runtimes (nodejs22.x supported to Apr 2027; nodejs24.x available since Nov 2025). https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html

  6. AWS — Test AWS CDK applications (fine-grained vs snapshot guidance). https://docs.aws.amazon.com/cdk/v2/guide/testing.html 2 3

  7. AWS — aws-cdk-lib.assertions module (Template, Match, Capture). https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html 2

  8. AWS — assertions Annotations API (hasError / hasNoError / hasWarning / findError). https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html