CDK Fine-Grained Assertions vs. Snapshot Tests: When to Use Each

CDK Fine-Grained Assertions vs. Snapshot Tests: When to Use Each

AWS CDK has two testing approaches that serve different purposes and are commonly confused:

  1. Fine-grained assertions (hasResourceProperties, hasResource, resourceCountIs) — assert specific properties of specific resources
  2. Snapshot tests (toMatchSnapshot) — assert the entire synthesized CloudFormation template hasn't changed

Most CDK projects use one or the other exclusively, which leaves coverage gaps. This guide covers what each approach tests, when each is appropriate, and how to combine them effectively.

The Core Difference

Fine-grained assertions test intent: "does this stack create a Lambda function with a 30-second timeout?"

Snapshot tests test change: "has anything in the synthesized template changed since I last looked?"

They answer different questions. You need both.

Setup

npm install --save-dev aws-cdk-lib jest @types/jest ts-jest
// jest.config.ts
export default {
  testEnvironment: "node",
  roots: ["<rootDir>/test"],
  testMatch: ["**/*.test.ts"],
  transform: { "^.+\\.tsx?$": "ts-jest" },
};

Fine-Grained Assertions

hasResourceProperties

The most common fine-grained assertion. Checks that at least one resource of the given type has the specified properties (partial match — extra properties are allowed):

// test/api-stack.test.ts
import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { ApiStack } from "../lib/api-stack";

describe("ApiStack", () => {
  let template: Template;
  
  beforeAll(() => {
    const app = new cdk.App();
    const stack = new ApiStack(app, "ApiStack", {
      env: { account: "123456789012", region: "us-east-1" },
    });
    template = Template.fromStack(stack);
  });
  
  test("Lambda function has correct timeout", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      Timeout: 30,
      MemorySize: 512,
      Runtime: "nodejs18.x",
    });
  });
  
  test("Lambda function has environment variables", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      Environment: {
        Variables: {
          NODE_ENV: "production",
          LOG_LEVEL: "info",
        },
      },
    });
  });
  
  test("API Gateway is configured with CORS", () => {
    template.hasResourceProperties("AWS::ApiGateway::RestApi", {
      Name: "ApiStack-API",
    });
  });
  
  test("DynamoDB table has pay-per-request billing", () => {
    template.hasResourceProperties("AWS::DynamoDB::Table", {
      BillingMode: "PAY_PER_REQUEST",
    });
  });
  
  test("DynamoDB table has point-in-time recovery", () => {
    template.hasResourceProperties("AWS::DynamoDB::Table", {
      PointInTimeRecoverySpecification: {
        PointInTimeRecoveryEnabled: true,
      },
    });
  });
});

resourceCountIs

Assert the number of resources of a given type:

test("stack creates exactly one Lambda function", () => {
  template.resourceCountIs("AWS::Lambda::Function", 1);
});

test("stack creates exactly two DynamoDB tables", () => {
  template.resourceCountIs("AWS::DynamoDB::Table", 2);
});

hasResource with Conditions

Match resources by both type and logical ID condition:

test("Lambda execution role has correct policies", () => {
  template.hasResource("AWS::IAM::Role", {
    Properties: {
      AssumeRolePolicyDocument: {
        Statement: [
          {
            Effect: "Allow",
            Principal: { Service: "lambda.amazonaws.com" },
            Action: "sts:AssumeRole",
          },
        ],
      },
    },
  });
});

findResources for Multiple Assertions

When you need to assert on all resources of a type:

test("all Lambda functions have X-Ray tracing enabled", () => {
  const lambdas = template.findResources("AWS::Lambda::Function");
  
  Object.values(lambdas).forEach((lambda) => {
    expect(lambda.Properties.TracingConfig?.Mode).toBe("Active");
  });
});

test("no security group allows unrestricted inbound access", () => {
  const sgs = template.findResources("AWS::EC2::SecurityGroup");
  
  Object.entries(sgs).forEach(([id, sg]) => {
    const ingressRules = sg.Properties.SecurityGroupIngress || [];
    ingressRules.forEach((rule: any) => {
      expect(rule.CidrIp).not.toBe("0.0.0.0/0");
      expect(rule.CidrIpv6).not.toBe("::/0");
    });
  });
});

Snapshot Tests

Snapshot tests capture the entire synthesized CloudFormation template and fail when anything changes:

// test/api-stack.snapshot.test.ts
import * as cdk from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { ApiStack } from "../lib/api-stack";

test("ApiStack matches snapshot", () => {
  const app = new cdk.App();
  const stack = new ApiStack(app, "ApiStack", {
    env: { account: "123456789012", region: "us-east-1" },
  });
  const template = Template.fromStack(stack);
  
  expect(template.toJSON()).toMatchSnapshot();
});

On first run, Jest creates __snapshots__/api-stack.snapshot.test.ts.snap with the full template. On subsequent runs, it compares against this file.

When you intentionally change the stack, update the snapshot:

jest --updateSnapshot
# or
jest -u

When to Use Each: The Decision Framework

Scenario Use Fine-Grained Use Snapshot
Testing security config (encryption, IAM)
Testing specific resource properties
Catching accidental template changes
Reviewing what a PR changes
Testing constructs in isolation
Regression testing for full stacks
TDD for new resources

Problems with Snapshot-Only Testing

Snapshot tests are change detectors, not correctness validators. Common problems:

False positives from CDK upgrades: When you upgrade aws-cdk-lib, Lambda function ARN formats, policy document structures, or logical IDs may change. Your snapshots fail even though nothing is functionally wrong. You end up running jest -u without carefully reviewing what changed.

No coverage for security regressions: If someone removes encryption from a DynamoDB table, the snapshot test catches it (template changed) — but only if you read the diff carefully. Fine-grained assertions would explicitly fail: DynamoDB table must have PointInTimeRecoveryEnabled.

Snapshot files bloat PRs: A snapshot for a moderate CDK stack is 500-2000 lines of JSON. Reviewing snapshot diffs in PRs is impractical.

Problems with Fine-Grained-Only Testing

No regression coverage for structure: Fine-grained assertions test what you thought to test. They miss changes to resources or properties you didn't explicitly assert on. Snapshot tests are a safety net.

Verbose for simple contracts: Testing that a VPC has 3 public subnets and 3 private subnets is easier with a snapshot than with 6+ individual assertions.

For each stack:

  1. Fine-grained assertions for security-critical properties:
    • Encryption configuration
    • IAM permissions (no * actions unless intentional)
    • Public access settings
    • Retention policies
  2. Snapshot tests for structural regression:
    • One snapshot test per major construct or stack
    • Update snapshots as part of intentional changes, not as a CI fix
// test/api-stack.test.ts — Fine-grained assertions
describe("ApiStack security", () => {
  test("S3 bucket blocks public access", () => {
    template.hasResourceProperties("AWS::S3::Bucket", {
      PublicAccessBlockConfiguration: {
        BlockPublicAcls: true,
        BlockPublicPolicy: true,
        IgnorePublicAcls: true,
        RestrictPublicBuckets: true,
      },
    });
  });
  
  test("Lambda execution role does not have admin access", () => {
    const roles = template.findResources("AWS::IAM::ManagedPolicy");
    Object.values(roles).forEach((policy: any) => {
      const statements = policy.Properties.PolicyDocument.Statement;
      statements.forEach((stmt: any) => {
        expect(stmt.Action).not.toBe("*");
        if (Array.isArray(stmt.Action)) {
          expect(stmt.Action).not.toContain("*");
        }
      });
    });
  });
});
// test/api-stack.snapshot.test.ts — Structural regression
test("ApiStack template matches snapshot", () => {
  expect(template.toJSON()).toMatchSnapshot();
});

Using Annotations for CDK Security Rules (cdk-nag)

For automated security rule checking, use cdk-nag alongside your assertions:

import { Aspects } from "aws-cdk-lib";
import { AwsSolutionsChecks, NagSuppressions } from "cdk-nag";

test("stack passes cdk-nag AWS Solutions checks", () => {
  const app = new cdk.App();
  const stack = new ApiStack(app, "ApiStack");
  
  Aspects.of(app).add(new AwsSolutionsChecks());
  
  // Suppress known intentional deviations
  NagSuppressions.addStackSuppressions(stack, [
    {
      id: "AwsSolutions-IAM4",
      reason: "Lambda basic execution role is intentional",
    },
  ]);
  
  // Synthesize — nag errors thrown here
  expect(() => app.synth()).not.toThrow();
});

CI Configuration

# .github/workflows/cdk-tests.yml
- name: Run CDK tests
  run: npx jest --coverage --passWithNoTests

- name: Check snapshots are up to date
  run: |
    npx jest --updateSnapshot
    git diff --exit-code test/__snapshots__/
  # Fails if snapshots were stale (i.e., not committed after last CDK change)

The second step ensures committed snapshots stay in sync: if a PR changes the CDK stack but doesn't update snapshots, the check fails.

Conclusion

Fine-grained assertions and snapshot tests are complementary, not competing. Use fine-grained assertions to enforce correctness for properties you care about — security config, resource counts, environment variables. Use snapshot tests as a structural safety net to catch unintended changes. Together, they give you both the clarity of explicit assertions and the coverage breadth of snapshot regression testing.

Read more

Start now free