Testing a Node.js + AWS Lambda + API Gateway App - Serverless Testing Strategies

nikl

Nik L.

Posted on November 14, 2023

Testing a Node.js + AWS Lambda + API Gateway App - Serverless Testing Strategies

Thought of delving more serverless testing, after these articles:


Introduction:
Serverless computing and Function as a Service (FaaS) are anticipated to experience significant growth over the next few years. With major cloud providers like AWS Lambda, Google Cloud Functions, and Azure Functions offering serverless solutions, web developers must adapt their development workflows. This article focuses on exploring the testing side of transitioning from traditional server-based applications to serverless architecture.

The Testing Dilemma:
As serverless computing gains prominence, the need for effective testing becomes a crucial consideration. Automated testing plays a vital role in ensuring the maintainability and reliability of software projects. This article delves into testing strategies for serverless applications, specifically those built using Node.js, AWS Lambda, and API Gateway.

Building a Simple Lambda Function:
To illustrate serverless testing patterns, we will build a basic lambda function named "asyncConcat." This function takes two string arguments, concatenates them, and returns the result. The Serverless Framework and mocha.js will be used for writing and running tests.

Image description

Code Implementation:
Following a top-down approach, we start by defining the HTTP GET /asyncConcat endpoint in the serverless.yml file.



functions:
  asyncConcat:
    handler: functions/asyncConcat.handler
    events:
      - http:
          path: asyncConcat
          method: get
          cors: true


Enter fullscreen mode Exit fullscreen mode

This configuration instructs API Gateway to handle HTTP calls to the GET /asyncConcat endpoint, triggering the asyncConcat lambda function. Next, we define the asyncConcat lambda function in functions/asyncConcat.js:



const jsonResponse = require("../lib/jsonResponse");
const asyncConcatService = require("../lib/asyncConcatService");

module.exports.handler = async (event, context) => {
  let { a, b } = event.queryStringParameters;

  if (!a || !b) {
    return jsonResponse.error({
      message: "Please specify 2 strings a and b to concatenate"
    });
  }

  let result = await asyncConcatService.concat(a, b);

  return jsonResponse.ok({ result });
};


Enter fullscreen mode Exit fullscreen mode

The handler function is a simple JavaScript async function that checks query parameters, calls asyncConcatService.concat, and returns the result. The actual concatenation is delegated to a service method for testability and clarity. The asyncConcatService.concat is defined under lib/asyncConcatService.js:



function concat(a, b) {
  return new Promise((resolve, reject) => {
    setImmediate(() => resolve(`${a} ${b}`));
  });
}

module.exports = {
  concat
};


Enter fullscreen mode Exit fullscreen mode

The concat method returns results asynchronously, showcasing testing approaches for async methods/handlers.

Writing Tests:

We define two types of tests using Mocha as the test framework.

Unit Tests:

Unit tests for the lambda handler are defined in test/unit/functions/asyncConcat.test.js:



const expect = require('chai').expect;
const sinon = require('sinon');

const asyncConcatService = require("../../../lib/asyncConcatService");

let asyncConcat = require("../../../functions/asyncConcat");

describe('asyncConcat', function asyncConcatTest() {
  let concatStub;

  context('input ok', function () {
    let queryStringParameters = { a: "a string", b: "b string" };
    let result = "result stub";

    before(function beforeTest() {
      concatStub = sinon.stub(asyncConcatService, "concat");
      concatStub.callsFake(function (a, b) {
        expect(a).to.eq(queryStringParameters.a);
        expect(b).to.eq(queryStringParameters.b);

        return Promise.resolve(result);
      });
    });

    it('success', async function () {
      let event = { queryStringParameters };
      let context = {};

      let response = await asyncConcat.handler(event, context);

      expect(response.statusCode).to.eq(200);
      expect(response.body).to.eq(`{"result":"${result}"}`);
    });

    after(function afterTest() {
      concatStub.restore();
    });
  });

  context('input missing', function () {
    let queryStringParameters = { a: "a string" };

    it('failure', async function () {
      let event = { queryStringParameters };
      let context = {};

      let response = await asyncConcat.handler(event, context);

      expect(response.statusCode).to.eq(400);
      expect(response.body).to.eq('{"message":"Please specify 2 strings a and b to concatenate"}');
    });

    after(function afterTest() {
      concatStub.restore();
    });
  });

});


Enter fullscreen mode Exit fullscreen mode

These tests verify that the handler function correctly handles missing inputs and returns appropriate HTTP responses. Sinon.js is used to mock the call to asyncConcatService.concat.

The next unit test, defined in test/unit/lib/asyncConcatService.test.js, examines the business logic of joining two strings:



const expect = require('chai').expect;

const asyncConcatService = require('../../../lib/asyncConcatService');

describe('asyncConcatService', function () {
  it('concats', async () => {
    let a = "Serverless";
    let b = "is awesome";

    let result = await asyncConcatService.concat(a, b);

    expect(result).to.eq("Serverless is awesome");
  });

});


Enter fullscreen mode Exit fullscreen mode

Integration Tests:

After independently testing our code components, the next step is to ensure the overall functionality. One effective approach is to conduct an integration test, simulating a complete request/response cycle as a black box: making an HTTP API call and then verifying the HTTP response. This process isn’t very different for Serverless apps but here are a couple of pointers.

  • Post-Deployment Testing: Following the deployment of your Serverless application, it is crucial to conduct a comprehensive test suite to validate the success of the deployment.

  • Test Suite Inclusions: The test suite may include various tests such as integration tests, end-to-end tests, or user acceptance tests. For API backends, testing is typically performed against the generated API endpoint.

  • Considerations for AWS Services: Serverless applications often leverage multiple AWS services. When testing, it is essential not to mock these AWS services to accurately assess their behavior.

  • Pay-Per-Use Model: Most Serverless services, including Lambda, API Gateway, and DynamoDB, operate on a pay-per-use model. This cost-effective approach facilitates easy replication of a stack at scale, ensuring realistic test scenarios.

  • Environment Similarity: To enhance the reliability of test results, it is imperative that test environments closely mirror the production environment.

  • Cost-Effective Test Environments: The cost-effectiveness of Serverless services allows for the creation of multiple test environments. This enables the execution of test suites in parallel, a particularly advantageous feature for integration tests with longer execution times.

  • DynamoDB Snapshot Feature: DynamoDB offers a useful backup snapshot feature for databases. This feature allows the creation of multiple test environments with specific database states. Note that this functionality is restricted to environments within the same AWS account.

A helpful tool that facilitates this process is serverless-offline. Described by its authors as a tool to emulate AWS λ and API Gateway locally during the development of your Serverless project, it proves to be instrumental. We will leverage mocha hooks to initiate serverless-offline during our tests and execute the tests against it.



const { spawn } = require('child_process');
const getSlsOfflinePort = require('./support/getSlsOfflinePort');

let slsOfflineProcess;

before(function (done) {
  // increase mocha timeout for this hook to allow sls offline to start
  this.timeout(30000);

  console.log("[Tests Bootstrap] Start");

  startSlsOffline(function (err) {
    if (err) {
      return done(err);
    }

    console.log("[Tests Bootstrap] Done");
    done();
  })
});

after(function () {
  console.log("[Tests Teardown] Start");

  stopSlsOffline();

  console.log("[Tests Teardown] Done");
});


// Helper functions

function startSlsOffline(done) {
  slsOfflineProcess = spawn("sls", ["offline", "start", "--port", getSlsOfflinePort()]);

  console.log(`Serverless: Offline started with PID : ${slsOfflineProcess.pid}`);

  slsOfflineProcess.stdout.on('data', (data) => {
    if (data.includes("Offline listening on")) {
      console.log(data.toString().trim());
      done();
    }
  });

  slsOfflineProcess.stderr.on('data', (errData) => {
    console.log(`Error starting Serverless Offline:\n${errData}`);
    done(errData);
  });
}


function stopSlsOffline() {
  slsOfflineProcess.kill();
  console.log("Serverless Offline stopped");
}


Enter fullscreen mode Exit fullscreen mode

The test is written in test/integration/get-asyncConcat.test.js:



const request = require('supertest');
const expect = require('chai').expect;
const getSlsOfflinePort = require('../support/getSlsOfflinePort');

describe('getAsyncConcat', function getAsyncConcatTest() {

  it('ok', function it(done) {
    request(`http://localhost:${getSlsOfflinePort()}`)
      .get(`/asyncConcat?a=it&b=works`)
      .expect(200)
      .end(function (error, result) {
        if (error) {
          return done(error);
        }

        expect(result.body.result).to.deep.eq("it works");
        done();
      });
  });

});


Enter fullscreen mode Exit fullscreen mode

This test sends an HTTP request with two strings to the endpoint and verifies that they are joined in the response body.

Ensuring Real-Time Feedback:
While the serverless development ecosystem continues to evolve, it's already possible to build reliable unit and integration tests. Testing becomes more intricate as additional services are introduced. However, leveraging creative testing patterns allows developers to write and run tests effectively. Integrating tools like Codeship into the CI/CD pipeline provides real-time feedback on test statuses.

Conclusion:
In conclusion, testing serverless applications is not only feasible but essential for maintaining robust and reliable systems. As the serverless ecosystem matures, developers will continue to refine and adapt testing strategies to address the evolving challenges posed by this innovative paradigm.


Similar to this, I run a developer-centric community on Slack. Where we discuss these kinds of topics, implementations, integrations, some truth bombs, lunatic chats, virtual meets, and everything that will help a developer remain sane ;) Afterall, too much knowledge can be dangerous too.

I'm inviting you to join our free community, take part in discussions, and share your freaking experience & expertise. You can fill out this form, and a Slack invite will ring your email in a few days. We have amazing folks from some of the great companies (Atlassian, Gong, Scaler etc), and you wouldn't wanna miss interacting with them. Invite Form

💖 💪 🙅 🚩
nikl
Nik L.

Posted on November 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related