All articles

// Knowledge.log — 技術記事

Testing CDK Stacks Without a Deploy: Assertions and Synth as a Contract

How to use cdk synth and aws-cdk-lib/assertions to lock down a CDK stack's properties in CI, before any real deploy hits AWS.

A bucket loses its encryption, an IAM policy picks up a last-minute s3:*, and nobody notices until cdk deploy has already run in production. Not because code review was skipped — the diff looked innocent — but because nothing in the pipeline checked the CloudFormation the stack actually produces. cdk synth generates that CloudFormation locally, without touching any account, and aws-cdk-lib/assertions lets you write tests that fail when the template drifts from what it's supposed to be. That turns into a CI gate that runs in seconds, with no Docker and no credentials.

This article walks through building that gate: a small stack (S3 bucket + IAM role), tests with node:test that lock down encryption and the policy, and cdk synth --lookups false as a second layer of verification. Everything here ran on this host, with real numbers.

Synth is not deploy

Worth repeating, because the two commands are easy to mix up:

  • cdk synth reads the CDK app (the TypeScript/JavaScript code) and produces a cloud assembly — one or more CloudFormation templates under cdk.out/. It's a local transformation: code becomes JSON. No resource gets created, no AWS API needs to answer.
  • cdk deploy runs synth internally and then sends that template to CloudFormation, which requires a bootstrapped environment and valid permissions.

The caveat lives in context lookups. Constructs like Vpc.fromLookup or StringParameter.valueFromLookup query the account during synth to resolve values (an existing VPC, a parameter). If the stack doesn't use lookups, cdk synth runs with zero credentials — verified here, with AWS_PROFILE empty. If the stack does use lookups and there's no committed cdk.context.json, the CLI needs credentials to fetch the value. The --lookups false flag removes that ambiguity in CI: if a lookup turns out to be necessary, synth fails closed instead of silently trying to call AWS.

Versions used

Everything below ran on this host with Node.js 22.23.2 (the 22.x line, supported by CDK until 2027-10-30), aws-cdk-lib 2.269.0, constructs ^10.5.0, and the CLI published as aws-cdk 2.1141.0. Since 2.1000.0, the CLI's version number no longer moves in lockstep with the library's — each aws-cdk-lib release works with whatever CLI was current when it shipped, plus any newer CLI. Node 24.x is also in CDK's support table, but this host runs 22; Node 18.x fell out of support on 2025-11-30 and shouldn't be taught anymore.

Assertions come from aws-cdk-lib/assertions, the v2 API. The @aws-cdk/assert module is CDK v1 and shouldn't show up in new code. The test runner here is node:test, built into Node 22 — the official testing guide uses Jest in its TypeScript examples, but that's the AWS article's choice, not a requirement of the assertions library. Pulling in jest@^24.9.0 (the version the guide still cites) makes little sense when the native runner already does the job.

The example stack

An S3 bucket with explicit SSE-S3 and full public-access blocking, plus a Lambda role with read-only permission:

import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
import { Bucket, BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

export interface SecureBucketStackProps extends StackProps {
  encryption?: BucketEncryption;
  actions?: string[];
}

export class SecureBucketStack extends Stack {
  constructor(scope: Construct, id: string, props?: SecureBucketStackProps) {
    super(scope, id, props);

    const bucket = new Bucket(this, "Data", {
      encryption: props?.encryption ?? BucketEncryption.S3_MANAGED,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
    });

    const role = new Role(this, "Reader", {
      assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
    });
    role.addToPolicy(new PolicyStatement({
      effect: Effect.ALLOW,
      actions: props?.actions ?? ["s3:GetObject"],
      resources: [bucket.arnForObjects("*")],
    }));

    new CfnOutput(this, "BucketName", { value: bucket.bucketName });
  }
}

The SecureBucketStackProps type extends StackProps with two optional fields, encryption and actions. In production nobody needs to set them — the defaults (BucketEncryption.S3_MANAGED and ["s3:GetObject"]) already are the behavior the stack is supposed to have. They exist so the failure tests, coming up next, can swap those values and prove the template changes when the definition gets loosened, without reaching for props as any.

Synthesizing this stack (npx aws-cdk@2.1141.0 synth --lookups false, no AWS_PROFILE) finished with exit 0 and produced, among other things, this snippet inside AWS::S3::Bucket:

"BucketEncryption": {
  "ServerSideEncryptionConfiguration": [
    { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } }
  ]
},
"PublicAccessBlockConfiguration": {
  "BlockPublicAcls": true,
  "BlockPublicPolicy": true,
  "IgnorePublicAcls": true,
  "RestrictPublicBuckets": true
}

and the IAM policy with a single action, which CloudFormation represents as a string — not an array:

"Action": "s3:GetObject",
"Effect": "Allow"

Pay attention to this one, because it's a common trap: the moment a second action joins the list, CFN starts serializing Action as an array. A matcher expecting the exact string breaks silently the day someone "just adds one more permission." No warning, no changelog entry: one day it's a string, the next it's an array, and the test that was supposed to guard the policy becomes the hole itself. I cover this in the pitfalls section.

enforceSSL: true also generates a separate AWS::S3::BucketPolicy, with a Deny on s3:* conditioned on aws:SecureTransport: false. This is not the role's IAM policy — it's the bucket policy that rejects non-TLS traffic. Don't conflate the two when writing tests: the wildcard here is expected and correct, it's part of the SSL enforcement — it's the one s3:* in the whole template you should be celebrating rather than blocking, not the dangerous wildcard you're trying to keep out of the Reader policy.

The fully synthesized template weighed in at 7,800 bytes. Synth reported "83 feature flags are not configured" — an informational notice about optional behavior flags, not a failure.

Template.fromStack and the matchers

The assertions library operates on the already-synthesized template, in memory, with no cdk.out on disk:

import { test } from "node:test";
import assert from "node:assert/strict";
import { App } from "aws-cdk-lib";
import { BucketEncryption } from "aws-cdk-lib/aws-s3";
import { Match, Template } from "aws-cdk-lib/assertions";
import { SecureBucketStack, SecureBucketStackProps } from "../lib/secure-bucket-stack.ts";

function synth(props?: SecureBucketStackProps) {
  const app = new App();
  return Template.fromStack(new SecureBucketStack(app, "T", props));
}

test("encrypted bucket and tight IAM pass", () => {
  const template = synth();

  template.resourceCountIs("AWS::S3::Bucket", 1);

  template.hasResourceProperties("AWS::S3::Bucket", {
    BucketEncryption: {
      ServerSideEncryptionConfiguration: [
        { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
      ],
    },
  });

  template.hasResourceProperties("AWS::IAM::Policy", {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({ Action: "s3:GetObject", Effect: "Allow" }),
      ]),
    },
  });

  template.hasOutput("BucketName", { Value: Match.anyValue() });
});

hasResourceProperties does a partial match on the resource's Properties via Match.objectLike by default — the test fails if the mentioned property is missing or different, but ignores other properties on the same resource that weren't mentioned. resourceCountIs checks the count for an entire CFN type. hasOutput checks an output by logicalId (or '*' for all of them). Match.arrayWith requires that at least one item in the array match the pattern, without requiring it to be the only one; Match.objectLike does the same partial match at the object level.

Running this file with node --test test.mjs (equivalent to the .ts version with the native runner), the result on this host was:

tests 3
pass 3
fail 0
duration_ms 2163.172547

With the --experimental-strip-types variant straight on a .ts file (no transpiling step first):

node --experimental-strip-types --test test-strip.ts
tests 1
pass 1
duration_ms 1650.379329

Node 22 already handles TypeScript with no build step for simple cases like this test — no ts-node, no tsc --watch running alongside. Worth remembering that --experimental-strip-types strips types, it doesn't type-check; if the team still wants tsc as a gate, that keeps running separately.

The test that trips when someone loosens the stack

The part that matters isn't the happy-path test — it's what happens when the definition changes:

test("fails when encryption is omitted (UNENCRYPTED)", () => {
  const template = synth({ encryption: BucketEncryption.UNENCRYPTED });

  assert.throws(() => {
    template.hasResourceProperties("AWS::S3::Bucket", {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
        ],
      },
    });
  });
});

test("fails when IAM Action is wildcard", () => {
  const template = synth({ actions: ["s3:*"] });

  assert.throws(() => {
    template.hasResourceProperties("AWS::IAM::Policy", {
      PolicyDocument: {
        Statement: Match.arrayWith([
          Match.objectLike({ Action: "s3:GetObject", Effect: "Allow" }),
        ]),
      },
    });
  });
});

Both cases are deliberate reproductions of the mistake the article opens with, and they only work because SecureBucketStackProps passes encryption and actions down into the underlying constructs — without that plumbing, both assert.throws calls would be testing the same stack every time and passing for the wrong reason. BucketEncryption.UNENCRYPTED is marked deprecated — CDK itself warns at runtime (aws-cdk-lib.aws_s3.BucketEncryption#UNENCRYPTED is deprecated. S3 applies SSE-S3 when default encryption is not configured. API will be removed in the next major release). That doesn't mean a bucket without an explicit encryption ends up storing data in plaintext — S3 applies SSE-S3 by default regardless. What changes is the template: without the encryption property on the L2 construct, CloudFormation doesn't emit the BucketEncryption key, and that absence is exactly what hasResourceProperties catches. The contract is "the template explicitly declares SSE-S3," not "S3 will store this in plaintext if I forget."

Result of all three tests together, including the two expected-failure ones:

ok 1 - encrypted bucket and tight IAM pass (1329.886104ms)
ok 2 - fails when encryption is omitted (UNENCRYPTED) (43.638388ms)
ok 3 - fails when IAM Action is wildcard (21.630355ms)
1..3
# tests 3
# pass 3
# fail 0

Notice the timing gap between the first test (1329ms) and the two that follow (43ms and 21ms) — that's not synth being skipped. All three tests call synth(), which builds the App, instantiates SecureBucketStack, and runs Template.fromStack to completion; none of them cut a corner in synthesis. The difference is Node's module cache: the first import of aws-cdk-lib loads the whole library once per process, and the next two tests, in the same process, reuse that cache. assert.throws kicks in afterward — it catches the exception the matcher throws when the property doesn't match —, not in place of synthesis.

The partial-match trap

Match.arrayWith and Match.objectLike exist to tolerate fields the test doesn't want to pin down — but the price is that they also tolerate unwanted neighbors. If someone adds a second statement with Action: "s3:*" to the same PolicyDocument.Statement, right next to the s3:GetObject statement, the test above keeps passing: arrayWith only requires at least one item matching the pattern, not that it be the only item in the list.

Two ways to close that gap, both using API that's already documented:

  • Swap Match.arrayWith for Match.arrayEquals when the statement list is small and known, forcing exact equality on the whole array.
  • Add a dedicated assertion with Match.not(Match.stringLikeRegexp(".*\\*.*")) on the Action field, to lock down "no statement in this policy has a wildcard," regardless of how many statements exist.

And remember the serialization quirk: with a single action, Action is a string ("s3:GetObject"); with two or more, it becomes an array (["s3:GetObject", "s3:PutObject"]). A matcher written as Action: "s3:GetObject" (exact equality, no objectLike) breaks the day someone adds a second legitimate permission — even though GetObject is still there. Match.arrayWith from the earlier example is already resilient to this, because it doesn't require equality on the whole list, just the presence of the expected object.

The CI gate

The pipeline needs nothing more than Node, the CDK CLI via npx, and the test runner. No Docker, no AWS key, no login step:

jobs:
  cdk-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: npm
      - run: npm ci
      - run: node --experimental-strip-types --test test/*.ts
      - run: npx aws-cdk@2.1141.0 synth --lookups false --quiet

Two commands do the work: the assertion tests lock down specific properties (encryption, IAM), and synth --lookups false guarantees the whole stack still synthesizes without needing external context — if someone introduces a Vpc.fromLookup with no committed cdk.context.json, this step fails instead of silently demanding credentials later. Pin aws-cdk as a devDependency of the project (as in the package.json used here) so the npx aws-cdk that gets resolved is always the version the team chose, not whatever's newest on the registry the day of the build.

None of these steps creates a resource, so there's no bootstrap, no STS, no deploy ordering to coordinate. It's a definition test, it runs in parallel with any other job, and it fails in seconds.

A definition test is not a post-deploy smoke test

Worth keeping the two apart, since they're different things and neither substitutes for the other:

  • Definition test (what this article covers): App/Stack in memory → CloudFormation → matchers. No resource exists. The failure happens at merge time, before any deploy. It proves the template still declares what it's supposed to declare.
  • Post-deploy smoke test: runs after cdk deploy has already created the real resources — HTTP against an endpoint, SDK calls against the bucket, checking effective IAM via simulate-principal-policy. It proves the real environment is healthy, not that the stack's source code is secure.

AWS documents a third category, integ-tests-alpha with integ-runner, for integration tests that actually deploy a temporary CDK app and assert against real resources (DeployAssert). That's experimental and solves a different problem — worth it when the team needs to validate runtime behavior that only exists after deploy. It's not a prerequisite for this gate; the first two steps of the YAML above already cover "the infrastructure definition is still correct" with no AWS account needed at all.

Watching it in production

No dashboard here — the signal already lives in the CI logs. Two things worth watching:

  • The node --test output: pass/fail per test, plus the deprecation message (BucketEncryption#UNENCRYPTED) when it shows up. If it appears in a PR that has no business touching encryption, that's a sign of silent regression.
  • The exit code of npx aws-cdk synth --lookups false: 0 means success, anything else in CI is the gate doing its job — treat it as a build failure, not a warning to investigate later.

No need to instrument anything beyond that: both commands are already deterministic and run on every push.

Recommendation

This pair — assertions for specific properties and synth --lookups false as a structural check — is worth adopting in any CDK repository that already has more than one person touching the same stack, because the setup cost is low (no new dependency beyond what's already in package.json) and the payoff shows up the first time someone accidentally reverts a security property. Where the recommendation shifts: a one-person prototype stack that's still changing shape every day doesn't get much out of fixed-property tests — they'll break with every legitimate iteration and turn into noise. At that stage, synth --lookups false alone already earns its keep as an "this still compiles" smoke check, and hasResourceProperties tests come in once the stack stabilizes enough to have a contract worth locking down.

awscdk

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles