๐Ÿ‚ AWS CDK 101 ๐ŸŒบ - Jest testing with TDD approach for our construct

๐Ÿ‚ AWS CDK 101 ๐ŸŒบ - Jest testing with TDD approach for our construct

ยท

12 min read

๐Ÿ”ฐ Beginners new to AWS CDK, please do look at my previous articles one by one in this series.

If in case missed the previous article, do find it with the below links.

๐Ÿ” Original previous post at ๐Ÿ”— Dev Post

๐Ÿ” Reposted previous post at ๐Ÿ”—dev to @aravindvcyber

In this article, let us introduce writing jest test cases that would help us in testing our construct which we have created in our previous article above.

For simplicity, we will be only creating test cases for the construct which we have introduced. And so this does not limit you, hence you are free to extend this throughout your project.

Jest setup ๐Ÿ”…

We start by creating a new folder called test at the root of our current project.

Add a new file like event-counter.test.ts

Make sure you also have to create a jest config file as shown below jest.config.js in the root of your project.

module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/test'],
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  }
};

Also, I use the below script in package JSON so that I could build and run the test case using npm run test.

"test": "npm run build && jest --coverage",

Testing advantages in CDK project ๐ŸŽˆ

  • The one advantage of testing is that we could develop our stack and make use of test suites to validate our stack even before deploying to dev environments.

  • Also certain trivial things which we are very sure, could be overridden and so always writing the test cases ahead of time, make sure that the simplest of things are always validated before deployment.

We could cover most of the feature testing in the test cases themselves and we will be able to make sure it is very close to our expectations before we deploy.

Since I have already composed my test cases, I will be using jest.only to make sure we try one at a time.

Some helper functions to remove code duplication ๐ŸŽป

Also, I have defined some reusable helper functions to create the handler function and initialize the construct we have created as follows.

To initialize the event-counter lambda function โ™ฆ๏ธ

const initHandler = (stack: cdk.Stack): lambda.Function => {
  return new lambda.Function(stack, 'TestFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'event-counter.counter',
      code: lambda.Code.fromAsset('lambda')
    });
}

To initialize our construct for testing ๐Ÿ’Ž

const initEventCounter = (stack: cdk.Stack, handler: lambda.Function): EventCounter => {
  return new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name'
  });
}

Also find the imported modules which include our construct to test, an assertions library, and the standard CDK libraries as required.

import { Template, Capture,  } from 'aws-cdk-lib/assertions';
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { EventCounter }  from '../constructs/event-counter'

1. Lambda has Environment variables ๐Ÿ’

Let us start with the first test case as shown below, a simple way to check whether the resources will be provisioned are not.

test.only('1. Lambda Has Environment Variables', () => {
  const stack = new cdk.Stack();
  //WHEN
  let handler = initHandler(stack);

  let eventCounter = initEventCounter(stack, handler);

  //THEN
  const template = Template.fromStack(stack);

  console.log(template);
  console.log(JSON.stringify(template));


});

In this test case, we have initialized a stack and initialized the event counter with a new handler function.

Testing strategy for CDK ๐Ÿ“

Before discussing the assertions, I have logged the console output to show you what will be our testing strategy here.

console.log
    Template {
      template: {
        Resources: {
          TestFunctionServiceRole6ABD93C7: [Object],
          TestFunction22AD90FC: [Object],
          MyTestConstructEventCountersF7DBB021: [Object],
          MyTestConstructEventCounterHandlerServiceRole7ABC2462: [Object],
          MyTestConstructEventCounterHandlerServiceRoleDefaultPolicy46018C23: [Object],
          MyTestConstructEventCounterHandler383414CF: [Object],
          MyTestConstructEventCounterHandlerLogRetention2D503F76: [Object],
          LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB: [Object],
          LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB: [Object],
          LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A: [Object]
        },
        Parameters: { BootstrapVersion: [Object] },
        Rules: { CheckBootstrapVersion: [Object] }
      }
    }

      at Object.<anonymous> (test/event-counter.test.ts:33:11)

You can see that this is the actual CDK synthesized template and we could make our test cases targeting this output referenced objects.

Those of you who didn't understand what I mean here can see the details template log, by doing a console.log(JSON.stringify(template));

JSON stringify template

This has the snapshot of the resources that we are about to create, we could now capture parts of this and perform our assertions to resolve the test cases.

 template.hasResourceProperties("AWS::Lambda::Function", {
    Environment: envCapture,
  });

  expect(envCapture.asObject()).toEqual(
    {
      Variables: {
        BACKEND_FUNCTION_NAME: {
          Ref: "TestFunction22AD90FC",
        },
        EVENT_COUNTER_TABLE_NAME: {
          Ref: "MyTestConstructEventCountersF7DBB021",
        },
      },
    }
  );

In the above block of close, we are finding a property of type AWS::Lambda::Function and capturing the value of the object Environment.

Then we subject this to our asserts, here the first time we validate, we expect it to fail and once we are sure of the resources provisioned, we can update the .toEqual to the right value for the Ref: "********", which is generated based on your test case and the current environment bootstrap template.

Lambda has environment variables

2. DynamoDB table created โšฝ

Let us write another test case where we would verify that only one dynamodb table has is present as follows.

resourceCountIs method is used to get the count of similar resources provisioned.

test.only('2. DynamoDB Table Created', () => {
  const stack = new cdk.Stack();
  // WHEN

  let handler = initHandler(stack);

  let eventCounter = initEventCounter(stack, handler);

  // THEN

  const template = Template.fromStack(stack);
  template.resourceCountIs("AWS::DynamoDB::Table", 1);
});
 PASS  test/event-counter.test.ts (15.434 s)
  โœ“ 1. Lambda Has Environment Variables (427 ms)
  โœ“ 2. DynamoDB Table Created (92 ms)

3. DynamoDB table created with Encryption ๐Ÿ„

Now let us do some TDD based development, by creating our test case first and fixing it.

test('3. DynamoDB Table Created With Encryption', () => {
  const stack = new cdk.Stack();
  // WHEN
  let handler = initHandler(stack);

  let eventCounter = initEventCounter(stack, handler);
  // THEN
  const template = Template.fromStack(stack);
  template.hasResourceProperties('AWS::DynamoDB::Table', {
    SSESpecification: {
      SSEEnabled: true
    }
  });
});

Here we were expecting a Dynamodb table to be provisioned with some specifications mentioned in the assertion. It is nothing but we are expecting the encryption feature to be turned on for the table.

 FAIL  test/event-counter.test.ts (15.953 s)
  โœ“ 1. Lambda Has Environment Variables (143 ms)
  โœ“ 2. DynamoDB Table Created (89 ms)
  โœ• 3. DynamoDB Table Created With Encryption (90 ms)


  โ— 3. DynamoDB Table Created With Encryption

    The template has 1 resource with the type AWS::DynamoDB::Table, but none match as expected.
    The closest result is:
      {
        "Type": "AWS::DynamoDB::Table",
        "Properties": {
          "KeySchema": [
            {
              "AttributeName": "Counter Name",
              "KeyType": "HASH"
            }
          ],
          "AttributeDefinitions": [
            {
              "AttributeName": "Counter Name",
              "AttributeType": "S"
            }
          ],
          "ProvisionedThroughput": {
            "ReadCapacityUnits": 5,
            "WriteCapacityUnits": 5
          }
        },
        "UpdateReplacePolicy": "Delete",
        "DeletionPolicy": "Delete"
      }
    with the following mismatches:
      Missing key at /Properties/SSESpecification (using objectLike matcher)

      74 |   // THEN
      75 |   const template = Template.fromStack(stack);
    > 76 |   template.hasResourceProperties('AWS::DynamoDB::Table', {
         |            ^
      77 |     SSESpecification: {
      78 |       SSEEnabled: true
      79 |     }

      at Template.hasResourceProperties (node_modules/aws-cdk-lib/assertions/lib/template.ts:62:36)
      at Object.<anonymous> (test/event-counter.test.ts:76:12)

As expected the test case failed, we could add the necessary feature right away, by updating our construct\event-counter.ts as follows while we are defining the dynamodb.


 const Counters = new dynamodb.Table(this, tableName, {
        partitionKey: { name: partitionKeyName, type: dynamodb.AttributeType.STRING },
       encryption: dynamodb.TableEncryption.AWS_MANAGED, //added for TDD

    });

Yes, we have got that right now.

 PASS  test/event-counter.test.ts (5.077 s)
  โœ“ 1. Lambda Has Environment Variables (138 ms)
  โœ“ 2. DynamoDB Table Created (93 ms)
  โœ“ 3. DynamoDB Table Created With Encryption (84 ms)

By default, the Dynamodb constructor will provision resources with default specifications like 5 read units and 5 write units. But we may need to have it tweak a bit based on environments and specific peak load expectations of the read and write patterns we expect.

Let us add one more test case as follows:

4. read capacity can be configured ๐ŸŽผ

The first couple of failure causes where we want the capacity to be restricted are as follows.

test('4. read capacity can be configured', () => {
  const stack = new cdk.Stack();

  expect(() => {
    let handler = 
    new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name',
      readCapacity: 30,
      writeCapacity: 15
  });

  }).toThrowError(/readCapacity must be greater than 5 and less than 20/);
});

5. write capacity should be in the range of 5 to 10 ๐ŸŽบ

test('5. write capacity should be in the range of 5 to 10', () => {
  const stack = new cdk.Stack();

  expect(() => {
    let handler = initHandler(stack);
    new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name',
      readCapacity: 5,
      writeCapacity: 15
  });

  }).toThrowError(/writeCapacity must be greater than 5 and less than 10/);
});

To enable this we have to add some code to the constructs\event-counter.ts to throw necessary exceptions.


export interface EventCounterProps {
  /** the function for which we want to count Event messages**/
  backend: lambda.IFunction,
  tableName: string,
  partitionKeyName: string,
  readCapacity?: number,
  writeCapacity?: number

}
    constructor(scope: Construct, id: string, props: EventCounterProps) {

    if (props.readCapacity !== undefined && (props.readCapacity < 5 || props.readCapacity > 20)) {
      throw new Error('readCapacity must be greater than 5 and less than 20');
    }

    if (props.writeCapacity !== undefined && (props.writeCapacity < 5 || props.writeCapacity > 10)) {
      throw new Error('writeCapacity must be greater than 5 and less than 10');
    }

    super(scope, id);

    .......
const Counters = new dynamodb.Table(this, tableName, {
        partitionKey: { name: partitionKeyName, type: dynamodb.AttributeType.STRING },
        encryption: dynamodb.TableEncryption.AWS_MANAGED,
        readCapacity: props.readCapacity ?? 5,
        writeCapacity: props.writeCapacity ?? 5
    });

You could identify now that we have added logic to throw an exception when our expected range is not met for the read and right capacity in the assertions statements with toThrowError.

 PASS  test/event-counter.test.ts (14.978 s)
  โœ“ 1. Lambda Has Environment Variables (145 ms)
  โœ“ 2. DynamoDB Table Created (87 ms)
  โœ“ 3. DynamoDB Table Created With Encryption (94 ms)
  โœ“ 4. read capacity can be configured (34 ms)
  โœ“ 5. write capacity should be in the range of 5 to 10 (4 ms)

6. DynamoDB Table Created With sample read and write units as well ๐ŸŽฏ

Similarly, we can add a test case without exception to simulate the positive test case as well for read and write capacity units.


test.only('6. DynamoDB Table Created With sample read and write units as well', () => {
  const stack = new cdk.Stack();
  // WHEN
   let handler = initHandler(stack);
  new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name',
      readCapacity: 10,
      writeCapacity: 5
  });
  // THEN
  const template = Template.fromStack(stack);
  template.hasResourceProperties('AWS::DynamoDB::Table', 
 {
          "KeySchema": [
            {
              "AttributeName": "Counter Name",
              "KeyType": "HASH"
            }
          ],
          "AttributeDefinitions": [
            {
              "AttributeName": "Counter Name",
              "AttributeType": "S"
            }
          ],
          "ProvisionedThroughput": {
            "ReadCapacityUnits": 10,
            "WriteCapacityUnits": 5
          },
          "SSESpecification": {
            "SSEEnabled": true
          }
        }
  );
});
 PASS  test/event-counter.test.ts (14.978 s)
  โœ“ 1. Lambda Has Environment Variables (145 ms)
  โœ“ 2. DynamoDB Table Created (87 ms)
  โœ“ 3. DynamoDB Table Created With Encryption (94 ms)
  โœ“ 4. read capacity can be configured (34 ms)
  โœ“ 5. write capacity should be in the range of 5 to 10 (4 ms)
  โœ“ 6. DynamoDB Table Created With sample read and write units as well(60 ms)

The 6th test case also helped our test case to reach 100 percent test coverage as follows.


------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|-------------------
All files         |   90.47 |       50 |     100 |   90.47 |
 event-counter.ts |   90.47 |       50 |     100 |   90.47 | 28,32
------------------|---------|----------|---------|---------|-------------------

------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|-------------------
All files         |     100 |      100 |     100 |     100 |
 event-counter.ts |     100 |      100 |     100 |     100 |
------------------|---------|----------|---------|---------|-------------------

Still we have right more test cases based on the functional expectations on the important aspects of the constructs.

7. Lambda has log retention specified with 30 days ๐ŸŽธ


test.only('7. Lambda Has logRetention specified with 30 days', () => {
  const stack = new cdk.Stack();
  // WHEN
   let handler = initHandler(stack);
  new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name',
      readCapacity: 10,
      writeCapacity: 5
  });
  // THEN
  const template = Template.fromStack(stack);
  const cp1 = new Capture();
  const cp2 = new Capture();
  template.hasResourceProperties("Custom::LogRetention", {
    LogGroupName: cp1,
    RetentionInDays: cp2

  });

  expect(cp1.asObject()).toEqual(
    {
          "Fn::Join": [
            "",
            [
              "/aws/lambda/",
              {
                "Ref": "MyTestConstructEventCounterHandler383414CF"
              }
            ]
          ]
        }
  );
  expect(cp2.asNumber()).toEqual(
    30
  );
});

The above test case shows how we can do numerical and object level assertions and also let us know how to capture a segment of the code into variables to perform assertions.

8. Lambda Has read-write access on dynamodb and can invoke backend function ๐ŸŽ€

The below test case is used to validate the IAM policy making sure the lambda function can perform write operations on dynamodb created and can also invoke the backend handler function.

Here an assertion is performed with a captured variable as asArray


test.only('8. Lambda Has read write access on dynamodb and can invoke backend function ', () => {
  const stack = new cdk.Stack();
  // WHEN
   let handler = new lambda.Function(stack, 'TestFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'event-counter.counter',
      code: lambda.Code.fromAsset('lambda')
    });
  let counter = new EventCounter(stack, 'MyTestConstruct', {
    backend: handler ,
     tableName: 'Event Counters',
      partitionKeyName: 'Counter Name',
      readCapacity: 10,
      writeCapacity: 5
  });


  // THEN
  const template = Template.fromStack(stack);
  const cp = new Capture();

  template.hasResourceProperties("AWS::IAM::Policy", {
    PolicyDocument: {Statement: cp}
  });

  expect(cp.asArray()).toEqual(
      [
      {
        "Action": [
          "dynamodb:BatchGetItem",
          "dynamodb:GetRecords",
          "dynamodb:GetShardIterator",
          "dynamodb:Query",
          "dynamodb:GetItem",
          "dynamodb:Scan",
          "dynamodb:ConditionCheckItem",
          "dynamodb:BatchWriteItem",
          "dynamodb:PutItem",
          "dynamodb:UpdateItem",
          "dynamodb:DeleteItem"
        ],
        "Effect": "Allow",
        "Resource": [
          {
            "Fn::GetAtt": [
              "MyTestConstructEventCountersF7DBB021",
              "Arn"
            ]
          },
          {
            "Ref": "AWS::NoValue"
          }
        ]
      },
      {
        "Action": "lambda:InvokeFunction",
        "Effect": "Allow",
        "Resource": {
          "Fn::GetAtt": [
            "TestFunction22AD90FC",
            "Arn"
          ]
        }
      }
    ]

  );

});
  PASS  test/event-counter.test.ts (14.928 s)
  โœ“ 1. Lambda Has Environment Variables (139 ms)
  โœ“ 2. DynamoDB Table Created (91 ms)
  โœ“ 3. DynamoDB Table Created With Encryption (76 ms)
  โœ“ 4. read capacity can be configured (29 ms)
  โœ“ 5. write capacity should be in the range of 5 to 10 (9 ms)
  โœ“ 6. DynamoDB Table Created With sample read and write units as well (61 ms)
  โœ“ 7. Lambda Has logRetention specified with 30 days (50 ms)
  โœ“ 8. Lambda Has read write access on dynamodb and can invoke backend function  (31 ms)

------------------|---------|----------|---------|---------|-------------------
File              | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|-------------------
All files         |     100 |      100 |     100 |     100 |
 event-counter.ts |     100 |      100 |     100 |     100 |
------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        15.068 s
Ran all test suites.

Conclusion for testing in CDK ๐Ÿ‚

Thus we have demonstrated how we can write extensive test cases for our construct and project modules, using jest.

You can also make use of the file ./cdk.out/CommonEventStack.template.json as a reference, which will have the full template for the stack to be provisioned to write more similar integrated test cases at the project level without deploying to the actual environment.

We will add more connections to this API gateway and lambda stack and make it more usable in the upcoming articles, so do consider following and subscribing to my newsletter.

๐ŸŽ‰ Thanks for supporting! ๐Ÿ™

Would be great if you like to โ˜• Buy Me a Coffee, to help boost my efforts.

๐Ÿ” Original post at ๐Ÿ”— Dev Post

๐Ÿ” Reposted post at ๐Ÿ”— dev to @aravindvcyber

Did you find this article valuable?

Support Aravind V by becoming a sponsor. Any amount is appreciated!

ย