The serverless framework makes it easy to develop and deploy cloud functions. In this article I’ll cover the AWS provider of the framework, although the principles should be very similar for the other providers, especially given the fact the serverless team works hard for a truly multi-provider framework.
There is a good document with guidelines for writing tests in serverless already. Also, there is another blog post about the basics. However, currently there’s no much information on using Jest which is trending in the community.
By the end of this article, you will:
Before going into the testing framework and the details about the testing itself, it’s worth spending some time configuring your environment so that you work effectively.
Here’s a high-level overview of the file structure for the tutorial:
├── config.example.json --> Copy and configure as config.json
├── package.json
├── README.md
├── serverless.yml --> Check if you want to tweak it
├── src --> You store your functions here, 1 file per each
│ └── upload.js --> the lambda function
├── test
│ └── upload.spec.js --> the test for the lambda function
├── webpack.config.js
└── yarn.lock
The full code for the tutorial can be seen in this repository.
Here’s a list of the package used in this tutorial:
serverless
with webpack
and serverless-webpack
babel
with some add-ons, mainly babel-preset-env
eslint
with more add-ons, and prettier
aws-sdk
and aws-sdk-mock
jest
Although this topic is not directly related to writing tests, it’s always good to consider any possible optimizations you can have in your stack.
babel-preset-env
with its babel-*
related packages. By using the env
preset you both gain in less configurations and less amount of code necessary after transpilations for a given target runtime platform. For example, delivering a bundle targeting node 6.x will be lighter than the one for earlier versions, because the runtime supports more features natively.
serverless-webpack
with its webpack settings can further optimize functions when they are bundled individually. Also, a configuration for external resources make the bundled upload lighter, excluding dependencies to aws-sdk
already available on AWS premises.
In the example project linked to this article you can have a look at the configurations necessary to have modern JavaScript running with serverless and Jest.
To learn about the test framework, read the official documentation site.
My high-level impressions:
In general, Jest is a full-fledged framework with all necessary features for testing. It’s easy to learn and it has good documentation.
Organizing code in testable chunks is the the most challenging and important step before anything else.
In the context of lambda functions and the serverless framework, unit testing is useful for covering mainly 2 types of code: library (helper) functions and the lambda functions in a given service. If you’re using the serverless framework only with serverless.yml
file in order to make your Cloud Formation templates more manageable, you don’t need unit testing. It’s only uesful when there is logic in the service.
Let’s imagine that our lambda function signature and beginning is the following:
export const handler = (event, context, callback) => {
const bucket = process.env.BUCKET;
const region = process.env.REGION;
...
}
Because we will most probably need to make checks about the input arguments of environment variables several times, we can make a simple helper which takes an object of the process.env
and returns a list of required keys for the function to work.
This scenario is easy, we can assert for various of useful edge cases like:
import checker from "../../src/lib/envVarsChecker";
describe(`Utility library envVarsChecker`, () => {
test(`The helper exists`, () => {
expect(checker).toBeTruthy();
});
test(`Asks for both BUCKET and REGION environment variables`, () => {
const input = {};
const result = checker(input);
expect(result).toEqual(["BUCKET", "REGION"]);
});
test(`Asks for a missing BUCKET environment variables`, () => {
const input = {
REGION: "foo",
};
const result = checker(input);
expect(result).toEqual(["BUCKET"]);
});
test(`Asks for a missing REGION environment variables`, () => {
const input = {
BUCKET: "foo",
};
const result = checker(input);
expect(result).toEqual(["REGION"]);
});
});
When functions are simple, but yet reusable for several lambda functions, we can test these helpers in a conventional way.
The lambda functions can be considered as a more complex piece of code to test.
Initially, I started by spawning processes and running the serverless CLI and asserting for results. This didn’t work efficiently because every unresolved promise in the serverless framework abstraction is impossible to handle in a convenient way in the test suite.
Since the original process of the lambda function was not easy to get done with, I also tried the serverless-jest-plugin
which was mentioned in the beginners article about TDD in serverless. As I already knew it’s ineffective to test against cli processes, I used the plugin programmatically to wrap the original lambda functions invocation. This also didn’t work well enough.
In the end of a long day I finally decided to treat lambda functions as normal functions and just wrap them in promises in order to make them more convenient for the Jest runner.
Like this:
import { promisify } from "util";
import lambda from "../src/upload";
const handler = promisify(lambda);
describe(`Service aws-node-singned-uploads`, () => {
test(`Require environment variables`, () => {
const event = {};
const context = {};
const result = handler(event, context);
result
.then((data) => {
expect(data).toBeFalsy();
})
.catch((e) => {
expect(e).toBe(
`Missing required environment variables: BUCKET, REGION`,
);
});
});
});
This approach does the job ok and keeps things relatively simple. It handles the lambda handler as a normal exported function which takes the arguments as described in the official signature of the function, and wraps it all in a promise, for Jest.
The syntax of promise assertions can be prettier, by the way.
Testing lambda functions with the assumption that they are just functions can take you long way if the logic inside these functions is relatively simple. However, the real reason for lambda functions to be, is that they are the glue between AWS services.
So, sooner or later you will have to find a way to mock AWS services in your tests :)
For us, the aws-sdk-mock
package works well so far. It supports mocking constructors and nested methods, it can restore originals. Documentation and support seem mature.
Together with mocking AWS services, we also take examples for events from the official AWS documentation. These can serve as a fast-track to creating stubs for the event
argument of a lambda function.
import AWS from "aws-sdk-mock";
import { promisify } from "util";
import lambda from "../src/upload";
import eventStub from "./stubs/eventHttpApiGateway.json";
const handler = promisify(lambda);
describe(`Service aws-node-singned-uploads: S3 mock for successful operations`, () => {
beforeAll(() => {
AWS.mock("S3", "getSignedUrl", (method, _, callback) => {
callback(null, {
data: "https://example.com",
});
});
});
afterEach(() => {
delete process.env.BUCKET;
delete process.env.REGION;
});
afterAll(() => {
AWS.restore("S3");
});
test(`Replies back with a JSON for a signed upload on success`, () => {
process.env.BUCKET = "foo";
process.env.REGION = "bar";
const event = eventStub;
const context = {};
const result = handler(event, context);
expect(result).resolves.toMatchSnapshot();
});
});
As you can see, the beforeAll
life cycle setups the AWS S3 mock for the getSignedUrl
method. afterEach
environment variables are reset and afterAll
the original S3 service is restored so that it operates to the AWS API after the test suite has finished.
Maybe you’ve noticed this line already expect(result).resolves.toMatchSnapshot();
.
This is how you use the Jest snapshot feature:
This feature helps you test structures in a simple way.
This tutorial covers mostly techniques with Jest on making unit tests. As you can see, to an extend we can say that testing lambda functions can be seen as a simple process.
However, mocking AWS services can get tricky and there are vocal opinions against this practice for a reason.
More specifically, take the aws-node-signed-uploads package as an example. The unit tests and the mocks are showing 100% test coverage for the code which gets executed by Jest and this is really encouraging.
Do the following for me as an exercise after this tutorial:
yarn start
.You will see a server running and waiting for your requests. You can make an example request with Postman which will show you the same issues as tested in the unit tests :) And if you manage to get your header key correctly, you’ll be even able to upload a large file to an S3 bucket.
Now make the same test on the deployed service. You will get an error message for access denied because there is a specific configuration on the upload endpoint:
functions:
upsert-objects:
handler: src/upload.handler
name: ${self:provider.stage}-${self:service}-upload
memorySize: 128
events:
- http:
path: upload
method: put
private: true
cors: true
Which is private: true
. When deployed on real AWS premises, the endpoint will require an API key in the header, which neither serverless
nor serverless-offline
, nor tests will warn you about.
Mocking AWS services however, will give you the basic safely net that your lambda functions are handling positive and negative scenarios and invoke the correct callbacks in the correct scenarios.
Also, using Jest for testing the independent logic and making snapshot make an excellent addition to secure the very vital behaviors of your cloud functions even when working independently from the AWS service.