The testable Lambda – A lightweight approach with Dependency Injection

No Comments

With AWS Lambda, you can run code without having to maintain the runtime environment – hence the term “serverless” for this kind of deployment model. A Lambda application can be a component of a larger, distributed application, communicating with other, external components. Examples of such external components are DynamoDB databases, S3 file buckets or REST services.

In this article, we want to explore component testing of Lambda applications. Component tests should be executable from any environment, e.g. locally on your development machine or from a CI/CD build, and we will derive and demonstrate a technique to achieve the desired level of isolation to do so.

The complete example project is hosted at GitHub.

Our example Lambda

The following example shows a JavaScript Lambda application – in short, just “Lambda” – that:

  1. Stores JSON items to AWS DynamoDB
  2. Retrieves stored items from DynamoDB

… using HTTP for client communication. All the application code is contained in index.js.

// index.js
 
const doc = require('dynamodb-doc');
const dynamo = new doc.DynamoDB();
 
const response = (statusCode, body, additionalHeaders) => ({
  statusCode,
  body: JSON.stringify(body),
  headers: { 'Content-Type': 'application/json', ...additionalHeaders },
});
 
// we don't use the context parameter, thus omitted
module.exports.handler = async (event) => {
  try {
    switch (event.httpMethod) {
      case 'GET': return response('200', await dynamo.scan({
        TableName: event.queryStringParameters.TableName }).promise());
 
      case 'POST': return response('204', await dynamo.putItem(
        JSON.parse(event.body)).promise());
 
      default: return response('405',
        { message: `Unsupported method: ${event.httpMethod}` },
        { Allow: 'GET, POST' });
    }
  } catch (err) {
    console.error(err);
    return response('400', { message: err.message });
  }
};

AWS Lambda uses Node.js to execute JavaScript. Thanks to this, you can use npm to run automated tests and choose from a wide array of testing solutions from the Node.js ecosystem. A popular choice is the combination of the Mocha testing framework and the Chai assertion library, offering an overall lightweight testing setup.
So, can we just write an adequate set of test scenarios for our example Lambda and execute them locally or in a CI/CD pipeline via npm test?
Unfortunately, no. The DynamoDB client, accessible through the variable dynamo, will try to connect to DynamoDB when the Lambda is executed within a test scenario – and fail.
This lack of isolation makes it impossible to fully exercise our Lambda in tests without providing connectivity to the external service – an expensive requirement we don’t want to fulfill.

Instead, we want to find a way to fake the interaction to DynamoDB. With the faking approach, we are not only freed from the burden of providing a reachable database, but we can also conveniently simulate both successful and failing interactions to validate that our Lambda will handle both well.

Isolation through Dependency Injection and test doubles

Dependency injection (DI) and test doubles are patterns to define your system under test by isolating it from other parts of your application, including external services like REST services or databases. DI enables you to dynamically pass test doubles of dependencies (commonly referred to as “mocks”) to a system under test to achieve the desired isolation.

In our case, the DynamoDB client would need to be an injectable dependency to our Lambda application. This would give us the ability to provide a test double during automated tests while providing the actual DynamoDB client in production.

Dependency Injection with functions

A Lambda is callable by its “handler”, an exported function with the signature function(event, context, callback) or its promise-based counterpart async function(event, context).
Now, is it possible to do DI with those functions?

Take another look at the dynamo variable which encapsulates the external DynamoDB service. Why does dynamo remain accessible by our handler function despite being declared above it but not exported as well?
The answer: Functions in JavaScript, such as handler, are “lexically scoped closures”. A function literal (the place in source code creating a function) has access to variables declared in its outer scope, which includes local variables and parameters of all surrounding functions. The accessibility to those variables outlives the functions that declared them.
We can utilize this behavior to implement Dependency Injection for functions.

Generally, for a given function f we can have an additional function outer, where

  • outer declares a list of parameters to hold dependencies of f
  • f references those parameters in its body
  • outer creates and returns f
function outer(dependencies) {
  return function f(...) {
    // uses dependencies
  }
}

With this schema, test code can create an instance of f by calling outer and passing test doubles as dependencies. Production code does the same but passes actual production dependencies:

// test code
const isolatedF = outer(testDoubles);
// production code
const f = outer(productionDependencies);

Because outer is effectively a “factory function”, we can call this flavor of DI “factory function injection”.

In conclusion: Since we can inject dependencies into JavaScript functions we can use DI for Lambda handler functions.

Making our example Lambda testable

The previous section confirms that DI is possible for Lambda handler functions. As with any function in JavaScript, they are lexically scoped closures that will happily hold on to parameters of a surrounding function.

For demonstration, let us now rewrite our example Lambda and make the variable dynamo a dependency injected in this manner:

  • We move the handler function in its own module – handler.js, so it is loadable by test and production code alike, however …
  • … instead of exporting the handler function directly, we will use a factory function to create and return it. We only export the factory function.
  • The factory function takes deps as a parameter object. The handler function accesses dependencies via deps, so dynamo.[...] becomes deps.dynamo.[...]. Using a single parameter object named deps brings us flexibility when passing dependencies. We don’t have to respect any ordering of dependencies and can omit individual dependencies easily.
  • We will also move the response function to handler.js. Helper functions that exist for stylistic reasons, like response, can remain hard-wired to its caller.
// handler.js
 
const response = (statusCode, body, additionalHeaders) => ({
  statusCode,
  body: JSON.stringify(body),
  headers: { 'Content-Type': 'application/json', ...additionalHeaders },
});
 
// Factory function creating and returning the handler function
module.exports = deps => async (event) => {
  try {
    switch (event.httpMethod) {
      case 'GET': return response('200', await deps.dynamo.scan(
        { TableName: event.queryStringParameters.TableName }).promise());
 
      case 'POST': return response('204', await deps.dynamo.putItem(
        JSON.parse(event.body)).promise());
 
      default: return response('405',
        { message: `Unsupported method: ${event.httpMethod}` },
        { Allow: 'GET, POST' });
    }
  } catch (err) {
    console.error(err);
    return response('400', { message: err.message });
  }
};

index.js still needs to export a handler function to AWS, but will now use the handler.js module to create it, while passing an instance of the actual DynamoDB client:

// index.js
 
const doc = require('dynamodb-doc');
 
module.exports.handler = require('./handler')({
  dynamo: new doc.DynamoDB(),
});

By now, we still have a deployable application that works as before, split into two separate modules.
Again: the advantage over the initial version is that we are now able to provide the handler’s dependencies via the deps parameter object.

Adding tests

After we have rewritten our Lambda to accept dependencies dynamically, we are ready to write our test scenarios. We will use Sinon.JS as a test double library to create a fake DynamoDB client, which we will pass to our unsuspecting Lambda handler via its factory function. Furthermore, our tests are using the Mocha testing framework and the Chai assertion library.

// test/handler.js
 
const { expect } = require('chai');
const sinon = require('sinon');
const doc = require('dynamodb-doc');
 
const deps = {
  // Use sinon.stub(..) to prevent any calls to DynamoDB and
  // enable faking of methods
  dynamo: sinon.stub(new doc.DynamoDB()),
};
 
const myHandler = require('../handler')(deps);
 
// (Optional) Keep test output free of
// error messages printed by our lambda function
sinon.stub(console, 'error');
 
describe('handler', () => {
  // Reset test doubles for isolating individual test cases
  afterEach(sinon.reset);
 
  it('should call dynamo db scan(...) in case of HTTP GET and return the result', async () => {
    const event = {
      httpMethod: 'GET',
      queryStringParameters: {
        TableName: 'MyTable',
      },
      body: '{}',
    };
    // Fake DynamoDB client behavior
    deps.dynamo.scan.returns({ promise: sinon.fake.resolves('some content') });
 
    const { headers, statusCode, body } = await myHandler(event);
 
    sinon.assert.calledWith(deps.dynamo.scan, { TableName: 'MyTable' });
    expect(headers['Content-Type']).to.equal('application/json');
    expect(statusCode).to.equal('200');
    expect(body).to.equal('"some content"');
  });
 
  // More tests ...
});

Since the above scenario tests the handler function directly, we can invoke it as AWS would do – by passing an event and context object (the context object is omitted in our test scenarios, since our Lambda is not using it).

Finally, lets run npm test, to see the following output:

  handler
    ✓ should call dynamo db scan(...) in case of HTTP GET and return the result
    ✓ should return an error message if a dynamo db call fails
    ✓ should call dynamo db putItem(...) in case of HTTP POST
    ✓ should reject unsupported HTTP methods


  4 passing (12ms)

Sure enough, we have working tests!

Conclusion

We examined Dependency Injection (DI) as an approach to automated testing of AWS Lambda applications in isolation from external components like DynamoDB, S3, REST services, etc. Although the Node.js ecosystem offers specialized module-mocking solutions to facilitate isolated testing, DI offers an intuitive way that works with any testing framework/library. If you like to, you can check out the example project and give it a try.

Resources

René Galle

René Galle is an IT-consultant at codecentric AG.

Comment

Your email address will not be published. Required fields are marked *