---
title: 'AWS CDK Testing in TypeScript: Test Before Deploy 2026'
description: 'Learn AWS CDK testing in TypeScript: write fine-grained assertions, snapshot tests, and Aspect validation with Jest to catch infra bugs before you deploy.'
url: https://nerdleveltech.com/aws-cdk-testing-typescript
language: en
date: '2026-06-18'
category: cloud-devops
tags:
  - aws cdk
  - typescript
  - testing
  - jest
  - cloud-devops
  - infrastructure as code
  - assertions
  - aws
markdown_url: https://nerdleveltech.com/aws-cdk-testing-typescript.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/aws-cdk-testing-typescript'
terms: https://nerdleveltech.com/llms.txt
---

# 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.2**[^jest] and the **aws-cdk-lib 2.260.0**[^lib] 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](https://nerdleveltech.com/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.0**[^lib], **aws-cdk (CLI) 2.1128.0**, **constructs 10.6.0**, **TypeScript 5.9.3**, **Jest 30.4.2**[^jest], **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.

```bash
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 this[^scaffold]:

```js
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 behind[^autoclean].

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`:

```json
{
  "compilerOptions": {
    "isolatedModules": true
  }
}
```

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

```bash
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.

```typescript
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 line[^runtimes].

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

```typescript
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 regressions[^testing]. Create `test/ingest-pipeline.test.ts`:

```typescript
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:

```bash
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 about[^assertions]. Add these tests to the same file:

```typescript
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:

```typescript
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.

```typescript
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:

```typescript
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`:

```typescript
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 difference[^testing]. 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`:

```typescript
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`:

```typescript
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 `findError`[^annotations]. 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`:

```yaml
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](https://nerdleveltech.com/github-actions-oidc-aws-terraform-keyless-deploys-tutorial) — knowing the template was validated first. If you also export traces from the deployed app, the [OpenTelemetry Collector Node.js tracing tutorial](https://nerdleveltech.com/opentelemetry-collector-nodejs-tracing-tutorial) pairs well with this CI gate.

## Verification

Run the full suite:

```bash
npm test
```

Expected output (versions and timing will vary):

```text
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:

```bash
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](https://nerdleveltech.com/github-actions-oidc-aws-terraform-keyless-deploys-tutorial).

## Further reading

- AWS — *Test AWS CDK applications*[^testing]
- AWS — *aws-cdk-lib.assertions module reference*[^assertions]

[^lib]: aws-cdk-lib on npm (2.260.0 latest as of 18 June 2026). https://www.npmjs.com/package/aws-cdk-lib
[^jest]: Jest on npm (30.4.2). https://www.npmjs.com/package/jest
[^scaffold]: Scaffold generated by the aws-cdk CLI `cdk init app --language typescript` (aws-cdk 2.1128.0). https://www.npmjs.com/package/aws-cdk
[^autoclean]: 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
[^testing]: AWS — Test AWS CDK applications (fine-grained vs snapshot guidance). https://docs.aws.amazon.com/cdk/v2/guide/testing.html
[^assertions]: AWS — aws-cdk-lib.assertions module (Template, Match, Capture). https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html
[^annotations]: AWS — assertions `Annotations` API (hasError / hasNoError / hasWarning / findError). https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.assertions-readme.html
[^runtimes]: 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
