Exploring the Future of Serverless with Sheen Brisals: Why It's Becoming the New Norm
We have worked on a couple of projects, where we focused on writing the application code, and writing test cases was always last on the priority list. This was the common procedure we followed until we heard about TDD from a colleague. This completely changed, how I approached building applications. What’s TDD? you ask me. Let us take you through how we can build serverless application with TDD.
So, what is Test Driven Development?
Test Driven Development (TDD) is a software development practice that focuses on creating unit test cases before writing the actual code. It is an iterative approach that combines programming, the creation of unit tests, and refactoring
Before any new code is written, the programmer must first create a failing unit test. The next step is to create just enough code to pass the failing unit test case. Finally, refactor the code, i.e improvise the existing code and Repeat.
Here are the steps to be followed :
- Express Intent in the form of a test
- Test the test case by running it and seeing it FAIL
- Create the minimum code to meet the test requirement
- Run it and See it Pass
- Now in a stable passing state refactor test and code for quality
- And repeat
TDD is more about design. The foundations of TDD are focused on using small tests to design systems from the ground up in an emergent manner and to rapidly get value while building confidence in the system. Here are a few things that you need to follow,
Do not write a line of code without a failing test that demands it. Always start from the test, make your test force you to create the classes that you need, and make it force you to create a method you need.
Why use TDD?
Test Driven Development provides many advantages
- Software design becomes modular
- Easy Maintenance
- Developers have less debugging to do, hence helps in the timely delivery of the project
How can we leverage TDD approach to build applications using AWS SAM ?
I will be building a backend with node.js for a simple e-commerce serverless application using AWS SAM by using AWS services like API gateway, lambda, and DynamoDB.
What is AWS SAM?
The AWS Serverless Application Model (SAM) is an open-source framework for building serverless applications. It provides shorthand syntax to express functions, APIs, databases, and event source mappings. With just a few lines per resource, you can define the application you want and model it using YAML
Let’s understand what are the requirements of our application:
A backend for e-commerce application that will facilitate customers to view products, add products to carts, and place orders. Also, facilitate sellers to list their products.
Dynamo DB will be used as a database where we will have user, seller, product, and cart tables. And a few lambdas functions with the names productService , userService , cartService and sellerService. And API gateway which will provide us endpoints through which user/seller can communicate with the application to place orders, add products, etc.
On a higher level, we will have the following endpoint
POST /product - to add a new product
GET /product - to get existing products
etc
Prerequisites
- node.js
- Jest
- Lambda
- AWS SAM
Let’s get started
Here, I will demonstrate how to write unit tests for lambda functions for a single scenario, i.e where the seller will add a new product to the catalog. Using the API endpoint, the seller will add necessary product details, API gateway invokes the Lambda function, which handles adding products to DB and then returns a response to the user.
First, create a sample AWS SAM application by running the command:
$ sam init
refer aws doc to create a sample aws sam project
This will create a boilerplate for the sam application. We have the following project structure -
We will clear the template.yaml file, delete files in events folder, delete hello-world function.
Define the infrastructure for our application according to the requirements, and create a folder structure for our lambda code. Also, create a package.json file using the following command
$ npm init
Now, with the help of @aws-sdk/client-dynamodb write functions that interact with dynamo DB to perform CRUD operations-
run the following command in the product directory
$ npm init @aws-sdk/client-dynamodb
Here is the code snippet -
product.service.js
const { ddbClient } = require('../libs/ddbClient');
const { PutItemCommand } = require("@aws-sdk/client-dynamodb");
const addProductToDb = (params) => {
return ddbClient.send(new PutItemCommand(params));
}
module.exports = {addProductToDb};
In file product/libs/ddbClient.js
// Create a service client module using ES6 syntax.
const {DynamoDBClient} = require("@aws-sdk/client-dynamodb");
// Set the AWS Region.
const REGION = 'us-east-1'; //e.g. "us-east-1"
// Create an Amazon DynamoDB service client object.
const ddbClient = new DynamoDBClient({ region: REGION });
module.exports = {ddbClient}
As we are following TDD, before defining our lambda function, let’s write the unit tests that check the behavior of the lambda function. There are various npm packages available to write unit tests. Here we will be writing unit tests using jest. Write tests keeping in mind how your lambda function should behave. During unit testing, we can use mock functions for dependent functions (like DB function, API calls, etc) present in the lambda.
You need jest package, install jest at the root directory of your project by running
$ npm install jest
Refer to jest documentation to learn more
Now we can start writing unit tests in accordance with the expected lambda behavior. Let’s start by creating a test folder at the root directory, with a product.test.js
file that holds all the unit tests for the productService lambda
Assuming that function with name productLambdaHandler has been exported from products app.js
file , require productLambdaHandler in product.test.js
const app = require('./../product/app.js');
var event, context;
describe(' product lambda function unit tests', () => {
test('Add Product Functionality test', async () => {
const result = await app.productLambdaHandler(event, context);
expect(typeof result).toBe("object");
expect(result).toEqual({
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: '{"message":"Product successfully added"}'
});
});
});
We need to pass an event to productLambdaHandler. For this, we can make use of sam local. We can generate sample payloads from different event sources, such as Amazon S3, Amazon API Gateway, and Amazon SNS. These payloads contain the information that the event sources send to your Lambda functions using
sam local generate-event
Go through aws doc to learn how to use sam local to generate an event for your lambda
Store these generated events in the event folder and make changes according to your use case to use them in the product.test.js
file.
Here in our case, we have made a few changes to the generated event ( for the body key’s value)
"body": "{\"productId\":\"prod2\",\"productName\":\"nestle munch\",\"price\":\"10\",\"category\":\"food\",\"inventory\":\"100\"}"
As we know lambda functions may depend on many other functions like DB functions, API calls, etc, as our objective is to run unit tests on lambda functions, so instead of calling actual dependent functions, we should mock the functions on which lambda function is dependent on. In our use case, productLambdaHandler will be dependent on DB functions present in the product.service.js
file to achieve its objective. Hence, we have to mock these functions. This can be done with the help of jest package.
Define functions to be mocked in product.service.js
under the mocks folder. Write code to mock the behavior of actual functions
Code snippet for our use case
const addProductToDb = (params) => {
return "success";
}
module.exports = { addProductToDb }
In the product.test.js
file, we should call jest.mock(“with a path to file which contains functions that we want to mock”)
product.test.js
looks like below
const app = require('./../product/app.js');
var event, context;
// telling jest to mock functions in the product.service.js file
jest.mock('./../product/src/product.service.js');
describe(' product lambda function unit tests', () => {
test('Add Product Functionality test', async () => {
event = require("../events/add-product.json");
const result = await app.productLambdaHandler(event, context);
expect(typeof result).toBe("object");
expect(result).toEqual({
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: '{"message":"Product successfully added"});
});
});
});
Here, we are expecting our productLambdaHandler function to return the below object after invoking it with an event and empty context
{
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: '{"message":"Product successfully added"}'
}
At the root package.json file add script “test”: “jest test/” i.e test (path to the folder where tests are defined). Now we are good at running the tests, using the command
npm test
On the first run, our tests are supposed to fail due to functionality not being present. We get the below error when we run the test It says app.productLambdaHandler is not a function. Because we haven’t defined productLambdaHandler in the products app.js file.
Hurray ! 🎉 🎉 🎉 We have successfully run the first iteration of unit tests for our lambda function.
Now, our job is to make changes in the app.js file until all the tests defined pass. The above tests are failing as there is no lambda function with the name productLambdaHandler in app.js file, I will start by creating a lambda function and adding code to it.
const { ddbClient } = require("./libs/ddbClient");
const { addProductToDb } = require("./src/product.service");
exports.productLambdaHandler = async (event) => {
};
I will run the test cases and the tests will fail again. I refactor and change the code until test cases pass.
In the end, after multiple test runs and refactoring of the code to make it modular, code will look like the below in app.js
const { ddbClient } = require("./libs/ddbClient");
const { addProductToDb } = require("./src/product.service");
exports.productLambdaHandler = async (event) => {
switch (true) {
case event.requestContext.httpMethod === "POST":
return addProduct(JSON.parse(event.body));
default:
return buildResponse(400, { error: "Invalid resource access" });
}
};
const addProduct = async (data) => {
const params = {
TableName: "producttable-" + process.env.ENVIRONMENT_NAME + "-dev",
Item: {
ProductID: { S: data.productId },
ProductName: { S: data.productName },
Price: { N: data.price },
Category: { S: data.category },
Inventory: { N: data.inventory }
},
};
try {
data = await addProductToDb(params);
return buildResponse(200, { "message": "Product successfully added" }); ß
} catch (err) {
console.error(err);
return buildResponse(500, { error: "Some internal error occured" });
}
}
function buildResponse(statusCode, body) {
return {
statusCode: statusCode,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
}
Follow the same procedure for all the lambda functions present in your application. Here I have mocked DB functions, in a similar way we can mock any other function of any service. Make sure to write all tests based on different negative and positive scenarios for lambda functions before defining lambda functions.
Here we have considered just a single scenario to help you get started. This can be replicated across the whole application.
Conclusion
Using Test Driven Development has proven to be very effective in delivering applications with fewer bugs and on time. I would highly recommend learning TDD and leveraging it in your project. I hope this blog helps you in leveraging the Test Driven Development approach with AWS SAM.