🏂 AWS CDK 101 🌺 - Jest testing with a TDD approach for our construct
Tags: cdk, jest, aws, serverless
Published: 2022-03-18 03:40:14

:beginner: 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.

:repeat: Original previous post at :link: [Dev Post](https://devpost.hashnode.dev/aws-cdk-101-building-constructs-and-simple-counter-store-in-dynamodb)

:repeat: Reposted previous post at :link: [dev to @aravindvcyber](https://dev.to/aravindvcyber/aws-cdk-101-building-constructs-and-simple-counter-store-in-dynamodb-1hkg)

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 CDK article

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  :low_brightness:

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.


```js
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`.

```json
"test": "npm run build && jest --coverage",
```
## Testing advantages in CDK project :balloon:

* 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 :violin:

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 :diamonds:

```ts
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 :gem:

```ts
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.

```ts
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 :ring:

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

```ts
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 :pencil:

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

```log
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5nwl299iw5o7x9dtc2s0.png)

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.

```ts
 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l64vj87rdgd0u33isls7.png)

## 2. DynamoDB table created :soccer:

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.

```ts
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);
});
```

```log
 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  :surfer:

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

```ts
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.


```log
 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.

```ts

 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.

```log
 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  :musical_score:

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


  ```ts
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 :trumpet:

```ts
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.

```ts

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

    .......
```

```ts
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`.

```log
 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 :dart:

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

```ts

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.

```log

------------------|---------|----------|---------|---------|-------------------
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  :guitar:

```ts

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 :ribbon:

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`


```ts

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"
          ]
        }
      }
    ]
    
  );

});
```

```log
  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 :snowboarder:

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.

⏭ We have our next article in serverless, do check out

https://dev.to/aravindvcyber/aws-cdk-101-codecommit-codepipeline-and-codebuild-l77

:tada: Thanks for supporting! 🙏

Would be great if you like to [:coffee: Buy Me a Coffee](https://www.buymeacoffee.com/AravindVCyber), to help boost my efforts.


<a href='https://ko-fi.com/X8X0CITDJ' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

<a href="https://www.buymeacoffee.com/AravindVCyber"><img src="https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=AravindVCyber&button_colour=BD5FFF&font_colour=ffffff&font_family=Cookie&outline_colour=000000&coffee_colour=FFDD00" /></a>

:repeat: Original post at :link: [Dev Post](https://devpost.hashnode.dev/aws-cdk-101-jest-testing-with-tdd-approach-for-our-construct)

:repeat: Reposted at :link: [dev to @aravindvcyber](https://dev.to/aravindvcyber/aws-cdk-101-jest-testing-with-a-tdd-approach-for-our-construct-2aeh)