Testing in serverless

alt text

Creating a simplest test case for serverless using mocha

First, create a new project and generate a new package.json file for it, running the following commands:

mkdir testing
cd testing
npm init

 

 

Install dependencies needed for the project.

npm install -D serverless-offline serverless-mocha-plugin

 

Create a function that will multiply two numbers and include it in a file called logic.js.

module.exports.multiplyNumbers = (x, y) => {
  return x * y;
}

 

Create a handler function that will multiply two numbers and include it in a file called handler.js.

const logic = require('./logic');

module.exports.multiplyNumbersForTest = (event, context, callback) => {
  // event.body will be set in case of post request and event will be set in case of payload passed by other lambda
  const vars = event.query || event;
  const response = {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': '*', // Required for CORS support to work
    },
    body: JSON.stringify({
      result: logic.multiplyNumbers(vars.x, vars.y) || 0,
      input: event,
    }),
  };

  callback(null, response);
};

We intentionally separated logic from the actual lambda handler as this allows you to create both unit tests for business logic and integration tests on actual lambda functions.

In this tutorial, we are gonna use serverless-mocha-plugin to create and run serverless tests.

 

Let's create a serverless.yml file.

service: testing

provider:
  name: aws
  runtime: nodejs8.10
  region: eu-west-1
  profile: default
  memorySize: 256 # optional, in MB, default is 1024
  stage: dev

plugins:
  - serverless-offline
  - serverless-mocha-plugin

functions:
  multiplyNumbersForTest:
    handler: handler.multiplyNumbersForTest
    events:
      - http:
          integration: lambda
          path: multiply-number-for-test
          method: get
          cors: true

Now, if you run serverless you should see three additional commands available from the serverless-mocha-plugin - create test, create function and invoke test.

 

Let's create a test for our multiplyNumbersForTest function. Simply run:

sls create test --function multiplyNumbersForTest

This will create multiplyNumbersForTest.js file in test folder. Now, you can try to run an empty with with sls invoke test command. Tests should pass.

 

Now, we need to write the actual test. Add this test instead of generated test:

it('should fail if the result is wrong', () => {
  const x = 4234;
  const y = 4334;
  const result = 18350156;
  return wrapped.run({
    query: { x, y }
  }).then((response) => {
    expect(response).to.not.be.empty;
    expect(response.body).to.not.be.empty;
    expect(JSON.parse(response.body).result).to.be.eql(result);
  });
});

Note that we use pre-calculated values to assert if our lambda calculates it correctly. The test we just wrote is an e2e test that tests the "contract" between the front-end and our lambda. The object that is passed in wrapper.run is essentially our event that we access in lambda. The approach that is taken by serverless-mocha-plugin is to invoke the exported lambda directly in the code. It doesn't do the actual HTTP request to our lambda. It is reasonable approach as not all lambdas listen to HTTP events. There are many other events the lambda can listen to.

Note the mocha before in the test file. You can connect to database, Redis or any other private datasource there. In case you would like to share the connection between different files, you can use beforeAll handler to open the connection and afterAll to close the connection.

 

Best practices

Above you could see a simple example on how to create the simplest test that will make sure our lambdas work as expected. In real projects, unless you are the God-mode programmer, you have to write hundreds of tests and follow certain best practices for your code to be reliable and maintainable. In Reason, we try to follow these principles:

Write integration end-to-end tests to test the "contracts" between front-end and backend. One could argue that integration tests by itself cannot be reliable indicator that the code wouldn't break. We agree with that completely. However, it allows us to be confident that the front-end that relies on lambda APIs or any other components of the cloud ecosystem that use lambdas will not break after new updates to the business logic inside any of the lambdas. The main problems with integration tests:

  • they can be quite time consuming if the piece of logic your are testing is big.
  • as e2e tests test the function as a black box, it's hard or impossible to inject mock modules (for example modules accessing database, providing logic from other functions)

 

Therefore, the next principle:

Write unit tests for whatever cannot be covered with integration tests. You should break your logic into smaller pieces that can be easily tested separately.

 

In the ideal scenario of unlimited resources one would write both integration and unit test with 100% coverage. For real-world projects under heavy time constraints it might be reasonable to balance between integration and unit tests.

What makes AWS Lambda special compared to traditional node.js development is that you can't simulate the exact AWS Lambda environment on your local machine. Good thing about Serverless framework is that in most cases you can deploy the copy of your Lambda stack to another account and just launch tests in the cloud.

 

Discussions

HTTP e2e tests versus module.exports invocation tests

For some of the projects where most lambdas are HTTP based, instead of using serverless-mocha-plugin, you can just write tests that invoke lambdas using HTTP protocol (for example with request library). In that case, you can be sure that if you ever decide to migrate logic in your lambdas to an express server on EC2 or to a private to VPS due-to cost reasons or regulators requirements, you can just change the configuration of the endpoint in tests and it will just work. With module.exports invocation testing style like in the example above, you are bound to the lambda stack and will have to rewrite code in case you migrate.

Why it is a good idea to abstract the logic from actual lambda handlers

First of all, it is easier to unit test separate parts of the logic in that way. Secondly, the logic can be re-used in other services. Third, the service endpoints can be changed at any time using the same logic. Imagine, in version 1.0 of API you had all the endpoint /orderAdd, orderEdit and orderComplete. In case you separate the logic, you can easily migrate to API version 2.0 with order/add, order/edit and order/complete endpoints.