luizmachado.dev

PT EN

Session 008 — CDK: Testing with assertions — fine-grained and snapshot tests

Estimated duration: 60 minutes
Prerequisites: session-007 — CDK: Assets — bundling Lambda, Docker images and local files


Objective

By the end, you will be able to write infrastructure unit tests with aws-cdk-lib/assertions, verify that resources exist with specific properties (hasResourceProperties), create snapshot tests, and interpret snapshot failures after infrastructure changes.


Context

[CONSENSUS] Testing infrastructure as code is less about finding logic bugs and more about three things: detecting regressions (something that worked stopped working after a change), documenting intentions (the test describes what the construct must guarantee), and gaining confidence to refactor (you can reorganize code without fear of breaking behavior).

[FACT] CDK tests with aws-cdk-lib/assertions run locally and completely offline — they synthesize the CloudFormation template in memory and make assertions on the generated JSON. No AWS calls happen. A typical npm test runs in seconds.

[CONSENSUS] The official AWS documentation classifies CDK tests into three categories: fine-grained assertions (most commonly used), snapshot tests (useful for refactoring), and validation tests (verify that invalid configurations throw exceptions). This session covers all three.


Key concepts

1. Setup — Template.fromStack()

The entry point for any CDK test is Template.fromStack(), which synthesizes the stack in memory and returns a Template object with assertion methods.

// test/meu-stack.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template, Match, Capture } from 'aws-cdk-lib/assertions';
import { MeuStack } from '../lib/meu-stack';

describe('MeuStack', () => {
  let app: cdk.App;
  let stack: MeuStack;
  let template: Template;

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

  // testes aqui
});

Alternative: Template.fromJSON() — to test existing CloudFormation templates:

import * as fs from 'fs';
const templateJson = JSON.parse(fs.readFileSync('path/to/template.json', 'utf8'));
const template = Template.fromJSON(templateJson);

2. Fine-grained assertions — the main methods

hasResourceProperties — verify properties of a resource

// Verifica que existe um bucket S3 com versioning habilitado
template.hasResourceProperties('AWS::S3::Bucket', {
  VersioningConfiguration: {
    Status: 'Enabled',
  },
});

// Por padrão usa Match.objectLike — matching PARCIAL
// O bucket pode ter outras propriedades além das verificadas
template.hasResourceProperties('AWS::S3::Bucket', {
  BucketEncryption: {
    ServerSideEncryptionConfiguration: [
      {
        ServerSideEncryptionByDefault: {
          SSEAlgorithm: 'AES256',
        },
      },
    ],
  },
});

hasResource — verify the complete resource (beyond Properties)

// Verifica DeletionPolicy, DependsOn, Metadata, além de Properties
template.hasResource('AWS::S3::Bucket', {
  DeletionPolicy: 'Retain',
  Properties: {
    VersioningConfiguration: { Status: 'Enabled' },
  },
});

resourceCountIs — verify resource count

// Deve existir exatamente 1 bucket
template.resourceCountIs('AWS::S3::Bucket', 1);

// Deve existir exatamente 2 funções Lambda
template.resourceCountIs('AWS::Lambda::Function', 2);

// Não deve existir nenhum recurso EC2
template.resourceCountIs('AWS::EC2::Instance', 0);

findResources — inspect found resources

// Retorna um objeto com todos os recursos do tipo encontrados
const buckets = template.findResources('AWS::S3::Bucket');
console.log(Object.keys(buckets));  // ['MeuBucketXXXXXX']
console.log(Object.values(buckets)[0].Properties);  // propriedades do bucket

// Com filtro
const encryptedBuckets = template.findResources('AWS::S3::Bucket', {
  Properties: {
    BucketEncryption: Match.anyValue(),
  },
});

hasOutput — verify stack outputs

template.hasOutput('BucketName', {
  Value: { Ref: Match.stringLikeRegexp('MeuBucket') },
  Export: { Name: Match.anyValue() },
});

3. Matchers — fine control over matching

The Match module provides matchers to control how comparisons are made.

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

// Match.objectLike — matching parcial (padrão do hasResourceProperties)
// O objeto real pode ter mais chaves do que o esperado
template.hasResourceProperties('AWS::IAM::Role', {
  AssumeRolePolicyDocument: Match.objectLike({
    Statement: Match.arrayWith([
      Match.objectLike({
        Effect: 'Allow',
        Principal: { Service: 'lambda.amazonaws.com' },
      }),
    ]),
  }),
});

// Match.exact — matching EXATO — o objeto deve ser idêntico
template.hasResourceProperties('AWS::SQS::Queue', Match.exact({
  VisibilityTimeout: 300,
  MessageRetentionPeriod: 86400,
}));
// Vai falhar se o recurso tiver qualquer prop além dessas duas

// Match.arrayWith — array contém esses elementos (pode ter outros)
template.hasResourceProperties('AWS::IAM::Policy', {
  PolicyDocument: {
    Statement: Match.arrayWith([
      {
        Effect: 'Allow',
        Action: 's3:GetObject',
        Resource: Match.anyValue(),
      },
    ]),
  },
});

// Match.arrayEquals — array é EXATAMENTE esses elementos, nessa ordem
template.hasResourceProperties('AWS::Lambda::Function', {
  Layers: Match.arrayEquals([
    { Ref: 'MinhaLayerXXXXXX' },
  ]),
});

// Match.stringLikeRegexp — string que bate com o regex
template.hasResourceProperties('AWS::Lambda::Function', {
  FunctionName: Match.stringLikeRegexp('^meu-app-.*-handler$'),
});

// Match.anyValue — qualquer valor não-null/undefined
template.hasResourceProperties('AWS::S3::Bucket', {
  BucketName: Match.anyValue(),
});

// Match.absent — a propriedade NÃO deve existir
template.hasResourceProperties('AWS::S3::Bucket', {
  WebsiteConfiguration: Match.absent(),
  PublicAccessBlockConfiguration: Match.absent(),   // confirma que public access está bloqueado
});

// Match.not — nega qualquer matcher
template.hasResourceProperties('AWS::Lambda::Function', {
  Runtime: Match.not(Match.stringLikeRegexp('nodejs14')),  // não usa Node 14
});

// Match.serializedJson — para propriedades que são JSON serializado como string
// (comum em policies inline e environment variables que guardam JSON)
template.hasResourceProperties('AWS::Lambda::Function', {
  Environment: {
    Variables: {
      CONFIG: Match.serializedJson({
        timeout: 30,
        retries: 3,
      }),
    },
  },
});

4. Capture — capture values for additional assertions

Capture allows you to extract a value from the template to use in subsequent assertions — useful when the value is dynamic (hash, generated ARN) but you want to verify its structure.

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

// Capturar o ARN da role para verificar em outro recurso
const roleArnCapture = new Capture();

template.hasResourceProperties('AWS::Lambda::Function', {
  Role: roleArnCapture,
});

// Agora roleArnCapture.asString() contém o valor capturado
// Ex: {"Fn::GetAtt": ["MeuRoleXXXXXX", "Arn"]}
console.log(roleArnCapture.asString());

// Capturar e verificar uma política serializada como JSON
const policyCapture = new Capture();

template.hasResourceProperties('AWS::SQS::Queue', {
  RedrivePolicy: policyCapture,
});

// O valor pode ser um objeto — use asObject()
const redrive = policyCapture.asObject();
expect(redrive.maxReceiveCount).toEqual(3);

// Capture com matcher — garante que o valor capturado tem a estrutura certa
const bucketCapture = new Capture(Match.objectLike({ 'Fn::GetAtt': Match.anyValue() }));

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

5. Validation tests — expected errors

Beyond verifying what is created, you should test that invalid configurations throw exceptions:

test('deve lançar erro quando porta fora do range', () => {
  const app = new cdk.App();
  const stack = new cdk.Stack(app, 'TestStack');

  expect(() => {
    new MeuWebServer(stack, 'Server', {
      port: 70000,  // porta inválida
    });
  }).toThrow('Port must be between 1024 and 65535');
});

test('deve lançar erro quando bucket name tem maiúsculas', () => {
  const app = new cdk.App();
  const stack = new cdk.Stack(app, 'TestStack');

  expect(() => {
    new s3.Bucket(stack, 'Bucket', {
      bucketName: 'MeuBucketInvalido',  // maiúsculas não permitidas
    });
  }).toThrow(/bucket name/i);
});

6. Snapshot tests — the template as baseline

Snapshot tests store the complete CloudFormation template in a .snap file and compare against it on each future run.

test('snapshot do stack completo', () => {
  const app = new cdk.App();
  const stack = new MeuStack(app, 'SnapshotStack');
  const template = Template.fromStack(stack);

  // Na primeira execução: cria o arquivo .snap
  // Nas execuções seguintes: compara com o snapshot salvo
  expect(template.toJSON()).toMatchSnapshot();
});

Snapshot file structure (Jest):

test/
├── meu-stack.test.ts
└── __snapshots__/
    └── meu-stack.test.ts.snap   ← gerado automaticamente, comitar no git

Updating snapshots after an intentional change:

# Atualiza todos os snapshots
npx jest --updateSnapshot

# Atualiza snapshot de um teste específico
npx jest --updateSnapshot --testNamePattern="snapshot do stack"

# Shorthand
npx jest -u

What to do when a snapshot fails:

1. Read the diff — Jest shows exactly what changed
2. Evaluate whether the change is:
   a) Intentional (you changed the code) → npx jest -u → commit the new snapshot
   b) A side effect of a CDK update → review whether the new behavior is ok
   c) An unexpected regression → find the cause and fix the code
3. NEVER run -u without reading the diff

7. Fine-grained vs snapshot — when to use each

[FACT, per official documentation] AWS recommends using fine-grained assertions as the test foundation and snapshot tests as a complement for refactoring.

Fine-grained assertions
  ✅ Detect regressions of specific behavior
  ✅ Are explicit about what is being verified
  ✅ Don't break due to unrelated changes in the template
  ✅ Document the construct's intentions
  ✅ Work as contract tests for reusable constructs
  ⚠️  More verbose to write
  ⚠️  Don't cover the entire template

Snapshot tests
  ✅ Cover the entire template with no effort
  ✅ Detect any change — including ones you didn't anticipate
  ✅ Useful during refactoring: ensures refactoring doesn't change the output
  ⚠️  Break with CDK updates that change the template for internal reasons
  ⚠️  Don't distinguish between intentional change and regression
  ⚠️  Can accumulate false positives if the team runs -u automatically
  ⚠️  Not useful for detecting regressions in new constructs

Recommended combined strategy:

For each construct:
  1. Fine-grained: verify critical invariants
     (correct IAM permissions, encryption enabled, correct deletion policy)
  2. Fine-grained: verify that invalid configurations throw errors
  3. Snapshot: capture the complete template as a baseline for refactoring

Practical example

Application stack with complete test coverage:

// lib/app-stack.ts
export class AppStack extends cdk.Stack {
  public readonly bucket: s3.Bucket;
  public readonly handler: lambda_nodejs.NodejsFunction;

  constructor(scope: Construct, id: string, props: AppStackProps) {
    super(scope, id, props);

    this.bucket = new s3.Bucket(this, 'AppBucket', {
      versioned: true,
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: props.isProd ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
    });

    this.handler = new lambda_nodejs.NodejsFunction(this, 'Handler', {
      entry: path.join(__dirname, '../src/handler.ts'),
      runtime: lambda.Runtime.NODEJS_22_X,
      environment: {
        BUCKET_NAME: this.bucket.bucketName,
        IS_PROD: props.isProd ? 'true' : 'false',
      },
    });

    this.bucket.grantReadWrite(this.handler);
  }
}

// test/app-stack.test.ts
import { Template, Match, Capture } from 'aws-cdk-lib/assertions';

describe('AppStack', () => {
  const makeStack = (isProd = false) => {
    const app = new cdk.App();
    const stack = new AppStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'us-east-1' },
      isProd,
    });
    return Template.fromStack(stack);
  };

  describe('S3 Bucket', () => {
    test('bucket tem versioning habilitado', () => {
      const template = makeStack();
      template.hasResourceProperties('AWS::S3::Bucket', {
        VersioningConfiguration: { Status: 'Enabled' },
      });
    });

    test('bucket tem encryption habilitada', () => {
      const template = makeStack();
      template.hasResourceProperties('AWS::S3::Bucket', {
        BucketEncryption: {
          ServerSideEncryptionConfiguration: Match.arrayWith([
            Match.objectLike({
              ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' },
            }),
          ]),
        },
      });
    });

    test('bucket em dev tem DeletionPolicy Delete', () => {
      const template = makeStack(false);
      template.hasResource('AWS::S3::Bucket', {
        DeletionPolicy: 'Delete',
      });
    });

    test('bucket em prod tem DeletionPolicy Retain', () => {
      const template = makeStack(true);
      template.hasResource('AWS::S3::Bucket', {
        DeletionPolicy: 'Retain',
      });
    });

    test('bucket NÃO tem acesso público', () => {
      const template = makeStack();
      template.hasResourceProperties('AWS::S3::Bucket', {
        PublicAccessBlockConfiguration: {
          BlockPublicAcls: true,
          BlockPublicPolicy: true,
          IgnorePublicAcls: true,
          RestrictPublicBuckets: true,
        },
      });
    });
  });

  describe('Lambda Function', () => {
    test('função tem runtime Node 22', () => {
      const template = makeStack();
      template.hasResourceProperties('AWS::Lambda::Function', {
        Runtime: 'nodejs22.x',
      });
    });

    test('função tem variável BUCKET_NAME no environment', () => {
      const template = makeStack();
      const bucketRef = new Capture();

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

      // O valor deve ser uma referência ao bucket, não hardcode
      expect(JSON.stringify(bucketRef.asObject())).toContain('Ref');
    });

    test('função tem permissão de leitura e escrita no bucket', () => {
      const template = makeStack();

      // Deve existir uma IAM Policy com as permissões S3
      template.hasResourceProperties('AWS::IAM::Policy', {
        PolicyDocument: {
          Statement: Match.arrayWith([
            Match.objectLike({
              Effect: 'Allow',
              Action: Match.arrayWith([
                's3:GetObject*',
                's3:PutObject*',
              ]),
            }),
          ]),
        },
      });
    });
  });

  describe('Contagem de recursos', () => {
    test('deve criar exatamente 1 bucket', () => {
      const template = makeStack();
      template.resourceCountIs('AWS::S3::Bucket', 1);
    });

    test('deve criar exatamente 1 função Lambda', () => {
      const template = makeStack();
      template.resourceCountIs('AWS::Lambda::Function', 1);
    });
  });

  describe('Snapshot', () => {
    test('template não muda inesperadamente', () => {
      const template = makeStack();
      expect(template.toJSON()).toMatchSnapshot();
    });
  });
});

Running the tests:

# Rodar todos os testes
npx jest

# Rodar com cobertura
npx jest --coverage

# Rodar em modo watch (reexecuta ao salvar)
npx jest --watch

# Atualizar snapshots
npx jest --updateSnapshot

# Rodar apenas um arquivo
npx jest test/app-stack.test.ts

# Rodar um teste específico pelo nome
npx jest --testNamePattern="bucket tem versioning"

# Ver output detalhado de um teste que falhou
npx jest --verbose

Interpreting a snapshot failure:

● AppStack › Snapshot › template não muda inesperadamente

  expect(received).toMatchSnapshot()

  Snapshot name: `AppStack Snapshot template não muda inesperadamente 1`

  - Snapshot  - 5 lines
  + Received  + 5 lines

    "MeuHandlerXXXXXX": {
      "Type": "AWS::Lambda::Function",
      "Properties": {
  -     "Runtime": "nodejs20.x",    ← valor no snapshot salvo
  +     "Runtime": "nodejs22.x",    ← valor atual no código
        "Handler": "index.handler",

  # Interpretation:
  # You updated the runtime from nodejs20 to nodejs22.
  # Intentional change → npx jest -u to accept the new baseline.

Common pitfalls

1. toMatchSnapshot without reviewing the diff — "snapshot debt"

Teams that run npx jest -u automatically in CI (to avoid snapshot failures after CDK updates) accumulate snapshots that were never reviewed. The snapshot stops being a test and becomes outdated documentation. Rule: npx jest -u only in commits where the snapshot change is the explicit goal, and the diff must be reviewed before merge.

2. Match.objectLike vs Match.exact — false approvals

hasResourceProperties uses Match.objectLike by default — the template can have more properties than those being verified. If you want to ensure a property does not exist, use Match.absent(). If you want the object to be exactly what you declared, use Match.exact(). Don't assume that passing hasResourceProperties means the resource has no additional unwanted properties.

3. Testing logical IDs instead of properties

Avoid writing tests that depend on the logical ID generated by CDK (e.g., { Ref: 'MeuBucketA1B2C3D4' }). Logical IDs change when you rename constructs or reorganize the hierarchy. Use Match.anyValue() or Capture to isolate the test from the logical ID.


Reflection exercise

You are writing tests for a SecureBucket construct that must guarantee: bucket always encrypted with KMS (not SSE-S3), public access always blocked, versioning enabled, and a Bucket Policy that denies operations without TLS (aws:SecureTransport: false).

Write the fine-grained tests for each of these requirements. For each test, explain: what exactly is being verified, which matcher is most appropriate and why, and what the test does not verify (its limits). Finally, is the snapshot test for this construct useful or redundant given the fine-grained tests? Justify.


Resources for further study

Testing guide:
- Test AWS CDK applications — covers all three test types (fine-grained, snapshot, validation) with examples in TypeScript and Python.

Assertions API:
- aws-cdk-lib.assertions module — complete reference of all Template methods and all Match.* matchers with examples of each.

AWS article:
- Testing Infrastructure with the AWS CDK — complete walk-through with practical examples of all three test types.