All Articles

TDD for AWS Lambda with Serverless framework and Jest

Introduction

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:

  • have a working development environment with modern JavaScript (ES2005 and up)
  • be able to use Jest effectively
  • know how to test your library code - the helpers used by lambda functions
  • be able to test lambda functions without killing yourself with abstractions
  • learn how to use test doubles for AWS services

Project setup

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.

Tools

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

Optimizations

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.

Configurations

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.

Jest

To learn about the test framework, read the official documentation site.

My high-level impressions:

  • Working with promises is natural.
  • There’s the watch mode.
  • There’s also an integrated code coverage reporting.
  • Snapshot testing for comparing and asserting differences in structures.

In general, Jest is a full-fledged framework with all necessary features for testing. It’s easy to learn and it has good documentation.

Unit testing

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.

Testing a library used by a lambda function

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.

Testing a lambda function

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.

Mocking AWS services

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.

Snapshot testing

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.

Further resources

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:

  • Clone the repository.
  • Install the dependencies.
  • Reconfigure the serverless settings.
  • Make a deployment to your AWS account.
  • Run 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.