Guide · AWS CDK · Testing

MCP Server CDK Testing — Template Assertions, Capture, Snapshot Tests

CDK's assertions library (aws-cdk-lib/assertions) lets you verify that a stack synthesizes the CloudFormation resources you expect — without deploying to AWS. The testing model: synthesize the stack into a Template object, then make assertions against that template's JSON. Three assertion styles cover everything: fine-grained assertions (template.hasResourceProperties, template.resourceCountIs) verify specific resources exist with the right properties; snapshot tests (template.toJSON()) lock the entire synthesized CloudFormation template as a Jest snapshot — a one-line catch-all that fails when anything changes; Capture matchers extract dynamic values (like CDK-generated logical IDs or token-resolved ARNs) from the template so you can assert on them structurally. The critical limitation: CDK tests verify that your code generates the right template, not that the template actually works in AWS — always combine with real integration tests for production infrastructure.

TL;DR

Use Template.fromStack(stack) to synthesize. Call template.hasResourceProperties('AWS::ECS::TaskDefinition', { ... }) for specific assertions. Use Match.objectLike for partial matching and Match.arrayWith for arrays that may contain additional items. Use Capture to extract CDK-generated values. Use template.toJSON() as a Jest snapshot for full regression coverage.

Setup and basic assertions

Import Template and Match from aws-cdk-lib/assertions. Create a test CDK app, instantiate your stack under test, then call Template.fromStack(stack) to synthesize without an AWS connection.

import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { McpServerStack } from '../lib/mcp-server-stack';

describe('McpServerStack', () => {
  let template: Template;

  beforeEach(() => {
    const app = new cdk.App();
    const stack = new McpServerStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'us-east-1' },
    });
    template = Template.fromStack(stack);
  });

  test('creates ECS task definition with correct CPU and memory', () => {
    template.hasResourceProperties('AWS::ECS::TaskDefinition', {
      Cpu: '512',
      Memory: '1024',
      NetworkMode: 'awsvpc',
      RequiresCompatibilities: ['FARGATE'],
    });
  });

  test('creates exactly one ECS service', () => {
    template.resourceCountIs('AWS::ECS::Service', 1);
  });

  test('ECS service has at least 1 desired count', () => {
    template.hasResourceProperties('AWS::ECS::Service', {
      DesiredCount: Match.not(0),
    });
  });
});

hasResourceProperties performs a partial match by default — the resource must have all the specified properties but may have additional properties. This is intentional: CDK adds many derived properties to resources and you rarely want to assert on all of them.

Match matchers for partial and structural assertions

The Match class provides matchers that can be nested anywhere in an assertion's property object:

MatcherBehavior
Match.exact(value)Exact deep equality — no extra keys allowed
Match.objectLike(subset)Object must contain all keys in subset (default for hasResourceProperties)
Match.objectEquals(obj)Alias for Match.exact
Match.arrayWith(items)Array must contain all items in items (order-independent, may have extras)
Match.arrayEquals(items)Array must equal items exactly (length and order)
Match.stringLikeRegexp(pattern)String must match the regex pattern
Match.anyValue()Matches any non-undefined value (useful as a placeholder)
Match.absent()Key must not be present in the object
Match.not(pattern)Negates any matcher
test('task definition has MCP_PORT env var', () => {
  template.hasResourceProperties('AWS::ECS::TaskDefinition', {
    ContainerDefinitions: Match.arrayWith([
      Match.objectLike({
        Name: 'mcp-server',
        Environment: Match.arrayWith([
          { Name: 'MCP_PORT', Value: '3000' },
        ]),
        // Port mappings must include port 3000
        PortMappings: Match.arrayWith([
          Match.objectLike({ ContainerPort: 3000, Protocol: 'tcp' }),
        ]),
      }),
    ]),
  });
});

test('task role has DynamoDB permissions', () => {
  template.hasResourceProperties('AWS::IAM::Policy', {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Action: Match.arrayWith(['dynamodb:GetItem', 'dynamodb:PutItem']),
          Effect: 'Allow',
        }),
      ]),
    },
  });
});

test('no S3 bucket has public read access', () => {
  // Ensure no bucket has public ACL — catches accidental misconfigurations
  template.allResourcesProperties('AWS::S3::Bucket', {
    AccessControl: Match.absent(),  // absence of AccessControl means private
  });
});

Capture — extracting dynamic values

Capture acts as a "capture group" inside a matcher — it matches any value and stores what it matched so you can assert on it after the fact. This is essential for asserting on CDK-generated logical IDs, token-resolved ARNs, or any value that CDK generates dynamically.

import { Capture } from 'aws-cdk-lib/assertions';

test('ECS service references the correct task definition', () => {
  // Capture the task definition ARN from the ECS service resource
  const taskDefCapture = new Capture();

  template.hasResourceProperties('AWS::ECS::Service', {
    TaskDefinition: taskDefCapture,
  });

  // taskDefCapture.asString() is now the CloudFormation ref/ARN value
  // We can assert it references our expected task definition
  expect(taskDefCapture.asString()).toMatch(/McpServerTask/);
});

test('IAM role trust policy allows ECS tasks', () => {
  const principalCapture = new Capture();

  template.hasResourceProperties('AWS::IAM::Role', {
    AssumeRolePolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Principal: { Service: principalCapture },
        }),
      ]),
    },
  });

  // ecs-tasks.amazonaws.com could appear as a string or in an array
  const principal = principalCapture.asString();
  expect(principal).toContain('ecs-tasks.amazonaws.com');
});

test('log group retention matches expected value', () => {
  const retentionCapture = new Capture();
  template.hasResourceProperties('AWS::Logs::LogGroup', {
    RetentionInDays: retentionCapture,
  });
  // Verify retention is at least 30 days
  expect(retentionCapture.asNumber()).toBeGreaterThanOrEqual(30);
});

Capture stores the first match when used with hasResourceProperties and multiple resources match. Use findResources to iterate over all matching resources when you need to assert across all instances.

Snapshot testing

Snapshot testing with template.toJSON() captures the entire synthesized CloudFormation template and compares it against a stored snapshot file. This is a cheap regression guard — any change to the generated CloudFormation (new resource, changed property, different logical ID) will fail the snapshot test, forcing a deliberate snapshot update.

test('snapshot matches expected template', () => {
  expect(template.toJSON()).toMatchSnapshot();
});

Update snapshots intentionally when you've made a deliberate change: npx jest --updateSnapshot. Never blindly accept all snapshot changes — review the diff to confirm only expected resources changed. Snapshot tests fail often on CDK upgrades that change logical ID generation, IAM policy formatting, or default property values.

For large stacks where full snapshots are noisy, snapshot a subset of resources:

test('ECS resources match snapshot', () => {
  // Only snapshot ECS-related resources, not the entire template
  const ecsResources = template.findResources('AWS::ECS::TaskDefinition');
  expect(ecsResources).toMatchSnapshot();

  const ecsServices = template.findResources('AWS::ECS::Service');
  expect(ecsServices).toMatchSnapshot();
});

Testing conditional logic and stack parameters

When a stack has conditional resources (different behavior based on props), test both branches by instantiating the stack twice with different props.

describe('when enablePrivateLink is true', () => {
  let template: Template;

  beforeEach(() => {
    const app = new cdk.App();
    const stack = new McpServerStack(app, 'TestStack', {
      enablePrivateLink: true,
      env: { account: '123456789012', region: 'us-east-1' },
    });
    template = Template.fromStack(stack);
  });

  test('creates VPC endpoint', () => {
    template.resourceCountIs('AWS::EC2::VPCEndpoint', 1);
    template.hasResourceProperties('AWS::EC2::VPCEndpoint', {
      VpcEndpointType: 'Interface',
    });
  });
});

describe('when enablePrivateLink is false (default)', () => {
  let template: Template;

  beforeEach(() => {
    const app = new cdk.App();
    const stack = new McpServerStack(app, 'TestStack', {
      enablePrivateLink: false,
      env: { account: '123456789012', region: 'us-east-1' },
    });
    template = Template.fromStack(stack);
  });

  test('does not create VPC endpoint', () => {
    template.resourceCountIs('AWS::EC2::VPCEndpoint', 0);
  });
});

Asserting on CloudFormation outputs and parameters

Template provides specific methods for Outputs and Parameters — use hasOutput and findOutputs to assert on CfnOutput values your stack exports.

test('exports MCP service URL', () => {
  template.hasOutput('McpServiceUrl', {
    Description: Match.stringLikeRegexp(/MCP server.*URL/i),
    // Value is a CloudFormation intrinsic; use Match.objectLike to avoid exact matching
    Value: Match.anyValue(),
    Export: {
      Name: Match.stringLikeRegexp(/McpServiceUrl/),
    },
  });
});

test('exports load balancer DNS name', () => {
  const outputs = template.findOutputs('*', {
    Description: Match.stringLikeRegexp(/load balancer/i),
  });
  expect(Object.keys(outputs)).toHaveLength(1);
});

Common testing failures

SymptomCauseFix
hasResourceProperties throws "No resource with type X found"Resource wasn't created — prop or conditional blocked it, or wrong resource type stringUse template.findResources('AWS::...') to list what's actually in the template
Assertion fails on CDK version bumpCDK upgrade changed logical ID generation, default property values, or IAM policy formattingRun jest --updateSnapshot for snapshots; for fine-grained assertions, use Match.objectLike not Match.exact to tolerate new default properties
Capture.asString() returns a CDK token (${Token[...]}) instead of a real valueCDK couldn't resolve the token at synth time — it's still a lazy referenceUse Match.anyValue() and check the token pattern, or move the assertion to an integration test where real values are available
Snapshot update on every runStack uses Date.now() or random values in resource namesRemove non-deterministic values from CDK constructs — resource names should be deterministic given the same CDK app inputs
template.resourceCountIs fails with wrong countCDK creates helper resources (IAM roles, Lambda functions for custom resources, log groups) automaticallyUse template.findResources to see all resources of that type and understand why the count differs