We use AWS Lambda serverless functions combined with TypeScript and NodeJS which results in an extreme powerful developer toolset. Due to the fact that functions contain isolated logic they are ideal for automated unit testing in CI/CD pipelines. So eventually looking at our options we decided to use the features of mocha, chai and nock combined. This resulted in a very easy and powerful solution for unit testing.
I’m sharing this after a chat at a meetup where the use of EKS instead of Lambda even for really simple functions was advocated due to the fact that serverless was hard to isolate (run local) and was hard to setup any unit testing. I beg to differ because:
So let’s go …
Our example function
is a simple function for retrieving a single record from a AWS DynamoDB table
// Handler for serverless framework export async function handler(event: APIGatewayProxyEvent, _context: Context, callback: Callback) { try { callback(undefined, await getWerkgever(event)); } catch (err) { callback(err); } } // Main logic export async function getRecord(event: APIGatewayProxyEvent) { .... const id = '1'; // pointless, but good enough for this example const queryParams = { TableName: process.env.dynamotable, Key: { id } }; const result = await documentClient.get(queryParams).promise(); if (result.Item) { return { statusCode: 200, headers, body: JSON.stringify(result.Item) }; } else { return { statusCode: 404, headers, body: undefined }; } };
So what happens here ?
- We deliberately split the logic between handler (for the serverless framework) and the main logic
- We need to export the main logic for use in our unit tests (and local development)
Using Mocha & Nock
Since we are running node we can use both mochaJS and nock for our unit testing. Setting up the specification file (.spec) for our simple function we first run the test with.
import { APIGatewayProxyEvent } from 'aws-lambda'; import { expect } from 'chai'; import * as nock from 'nock'; import { getRecord } from './getRecord'; process.env.dynamotable = 'myTable'; describe('getRecord', () => { it('UT001 - getRecord with valid response', async() => { nock.recorder.rec(); const event: APIGatewayProxyEvent = { body: '', headers: {}, httpMethod: 'GET', isBase64Encoded: false, path: '', pathParameters: {}, queryStringParameters: undefined, stageVariables: {}, requestContext: {}, resource: '' }; const response = await getRecord(event); expect(response.statusCode).to.equal(200); }); });
So what happened ?
- We set the environment variables (like the DynamoDB table) which normally is done by AWS Lambda
- We configure nock.recorder for auditing the upcoming execution
- We define a dummy APIGatewayProxyEvent which has some mandatory elements which we leave mostly empty or undefined
- We define the call to our AWS Lambda function and since we isolated the main logic we can call this directly
- If your AWS profile used on your dev machine has enough IAM grants the code can execute against AWS DynamoDB (we use a special user for this to keep it clean)
By running mocha with the Nock recorder we can see the actual callout to AWS DynamoDB from our developer machine
<-- cut here --> nock('https://dynamodb.eu-west-1.amazonaws.com:443', {"encodedQueryParams":true}) .get('/', {"TableName":"myTable","Key":{"id":{"S":"1"}}}) .reply(200,{Item: {name: {S: 'myName'}, id: {S: '1'}}); ......... (much stuff)
So with nock we actually recorded the https call to DynamoDB. Which now, we can easily use with nock to mock the response during unit testing. So next change the code in our spec file with the info from nock recorder:
// nock.recorder.rec(); nock('https://dynamodb.eu-west-1.amazonaws.com:443') .get('/' ) .reply(200,{Item: {name: {S: 'myName'}, id: {S: '1'}});
So what happened ?
- Disabled the recorder, we don’t need it anymore
- Setup nock to catch the HTTPS GET call to the dynamoDB endpoint
- Configured nock to reply with a 200 and the specified Item record (you can also use reply with files)
Using Chai
With this basis setup we can execute unit tests in our pipeline with Mocha where nock will handle the mocking of the endpoints. With some little Chai magic we can define expectations in our specification file to make sure all message logic of our function is done properly and the HTTP reply is as expected.
expect(response.statusCode).to.equal(200); expect(response.headers).to.deep.include({ 'Content-Type': 'application/json' }); expect(JSON.parse(response.body)).to.deep.equal({ name: 'myName', id: '1'});
And there is more
With this it’s easy to catch all outbound HTTPS requests and mock different responses (0 records, multiple records, etc etc) for extensive unit testing. The possibilities are endless, so hope it helps …