luizmachado.dev

PT EN

Sessão 008 — CDK: Testing com assertions — fine-grained e snapshot tests

Duração estimada: 60 minutos
Pré-requisitos: session-007 — CDK: Assets — bundling de Lambda, Docker images e arquivos locais


Objetivo

Ao final, você conseguirá escrever testes unitários de infra com aws-cdk-lib/assertions, verificar que recursos existem com propriedades específicas (hasResourceProperties), criar snapshot tests e interpretar falhas de snapshot após mudanças de infra.


Contexto

[CONSENSO] Testar infraestrutura como código é menos sobre encontrar bugs de lógica e mais sobre três coisas: detectar regressões (algo que funcionava parou de funcionar após uma mudança), documentar intenções (o teste descreve o que o construct deve garantir), e ganhar confiança para refatorar (você pode reorganizar o código sem medo de quebrar o comportamento).

[FATO] Os testes de CDK com aws-cdk-lib/assertions rodam localmente e completamente offline — eles sintetizam o template CloudFormation em memória e fazem assertions sobre o JSON gerado. Nenhuma chamada à AWS acontece. Um npm test típico roda em segundos.

[CONSENSO] A documentação oficial da AWS classifica os testes CDK em três categorias: fine-grained assertions (mais usados), snapshot tests (úteis para refatoração) e validation tests (verificam que configurações inválidas lançam exceções). Esta sessão cobre as três.


Conceitos principais

1. Setup — Template.fromStack()

O ponto de entrada de qualquer teste CDK é Template.fromStack(), que sintetiza o stack em memória e retorna um objeto Template com os métodos de assertion.

// 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
});

Alternativa: Template.fromJSON() — para testar templates CloudFormation existentes:

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 — os métodos principais

hasResourceProperties — verificar propriedades de um recurso

// 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 — verificar o recurso completo (além de Properties)

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

resourceCountIs — verificar quantidade de recursos

// 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 — inspecionar recursos encontrados

// 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 — verificar outputs da stack

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

3. Matchers — controle fino sobre o matching

O módulo Match fornece matchers para controlar como as comparações são feitas.

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 — capturar valores para assertions adicionais

Capture permite extrair um valor do template para usá-lo em assertions subsequentes — útil quando o valor é dinâmico (hash, ARN gerado) mas você quer verificar sua estrutura.

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 — erros esperados

Além de verificar o que é criado, você deve testar que configurações inválidas lançam exceções:

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 — o template como baseline

Snapshot tests armazenam o template CloudFormation completo em um arquivo .snap e comparam em cada execução futura.

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();
});

Estrutura dos arquivos de snapshot (Jest):

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

Atualizando snapshots após mudança intencional:

# 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

O que fazer quando um snapshot falha:

1. Leia o diff — o Jest mostra exatamente o que mudou
2. Avalie se a mudança é:
   a) Intencional (você alterou o código) → npx jest -u → comita o novo snapshot
   b) Colateral de uma atualização do CDK → revise se o novo comportamento é ok
   c) Regressão inesperada → encontre a causa e corrija o código
3. NUNCA faça -u sem ler o diff

7. Fine-grained vs snapshot — quando usar cada um

[FATO, conforme documentação oficial] A AWS recomenda usar fine-grained assertions como base dos testes e snapshot tests como complemento para refatoração.

Fine-grained assertions
  ✅ Detectam regressões de comportamento específico
  ✅ São explícitos sobre o que está sendo verificado
  ✅ Não quebram por mudanças não relacionadas no template
  ✅ Documentam as intenções do construct
  ✅ Funcionam como testes de contrato para constructs reutilizáveis
  ⚠️  Mais verbosos para escrever
  ⚠️  Não cobrem o template inteiro

Snapshot tests
  ✅ Cobrem o template inteiro sem esforço
  ✅ Detectam qualquer mudança — inclusive as que você não antecipou
  ✅ Úteis durante refatoração: garante que a refatoração não muda o output
  ⚠️  Quebram com atualizações do CDK que mudam o template por razões internas
  ⚠️  Não distinguem entre mudança intencional e regressão
  ⚠️  Podem acumular falsos positivos se o time fizer -u automaticamente
  ⚠️  Não são úteis para detectar regressões em constructs novos

Estratégia combinada recomendada:

Para cada construct:
  1. Fine-grained: verifica as invariantes críticas
     (IAM permissions corretas, encryption habilitada, deletion policy correta)
  2. Fine-grained: verifica que configurações inválidas lançam erro
  3. Snapshot: captura o template completo como baseline para refatoração

Exemplo prático

Stack de aplicação com cobertura de testes completa:

// 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();
    });
  });
});

Rodando os testes:

# 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

Interpretando uma falha de snapshot:

● 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",

  # Interpretação:
  # Você atualizou o runtime de nodejs20 para nodejs22.
  # Mudança intencional → npx jest -u para aceitar o novo baseline.

Armadilhas comuns

1. toMatchSnapshot sem revisão do diff — o "snapshot debt"

Equipes que rodam npx jest -u automaticamente em CI (para evitar falhas de snapshot após atualizações do CDK) acumulam snapshots que nunca foram revisados. O snapshot deixa de ser um teste e vira documentação desatualizada. Regra: npx jest -u só em commits onde a mudança no snapshot é o objetivo explícito, e o diff deve ser revisado antes do merge.

2. Match.objectLike vs Match.exact — falsas aprovações

hasResourceProperties usa Match.objectLike por padrão — o template pode ter mais propriedades do que as verificadas. Se você quer garantir que uma propriedade não existe, use Match.absent(). Se quer que o objeto seja exatamente o que você declarou, use Match.exact(). Não assuma que passar no hasResourceProperties significa que o recurso não tem propriedades adicionais indesejadas.

3. Testar logical IDs em vez de propriedades

Evite escrever testes que dependem do logical ID gerado pelo CDK (ex: { Ref: 'MeuBucketA1B2C3D4' }). Logical IDs mudam quando você renomeia constructs ou reorganiza a hierarquia. Use Match.anyValue() ou Capture para isolar o teste do logical ID.


Exercício de reflexão

Você está escrevendo testes para um construct SecureBucket que deve garantir: bucket sempre criptografado com KMS (não SSE-S3), public access sempre bloqueado, versioning habilitado, e um Bucket Policy que nega operações sem TLS (aws:SecureTransport: false).

Escreva os testes fine-grained para cada um desses requisitos. Para cada teste, explique: o que exatamente está sendo verificado, qual matcher é mais apropriado e por que, e o que o teste não verifica (seus limites). Por fim, o snapshot test desse construct é útil ou redundante dado os fine-grained tests? Justifique.


Recursos para aprofundar

Testing guide:
- Test AWS CDK applications — cobre os três tipos de teste (fine-grained, snapshot, validation) com exemplos em TypeScript e Python.

Assertions API:
- aws-cdk-lib.assertions module — referência completa de todos os métodos do Template e de todos os Match.* matchers com exemplos de cada um.

Artigo AWS:
- Testing Infrastructure with the AWS CDK — walk-through completo com exemplos práticos dos três tipos de teste.