I recently read the Designing Testable Lambda Functions tutorial from Claudia.js. I firmly believe in thorough testing, including both testing Lambda function code in isolation, and end-to-end system testing. However, I think this tutorial goes about it in the wrong way.

Lambda functions are ideally small — a few hundred lines of code at the most — taken up mostly by error handling; the happy path should be very short, or at least relatively straightforward. Thus, introducing abstractions can create a lot of code bloat. I hope the tutorial is tongue-in-cheek when it speaks of “breaking apart this monolith” of 39 lines.

The tutorial speaks of integration tests and integrated tests, but I think it’s more useful with Lambda to think in terms of unit tests that are performed against a Lambda function in isolation, and integration/integrated tests that test the system as a whole. Unit tests can be performed locally, because they just require the code for the Lambda function, but integration tests involving SaaS can really only be performed on the deployed system (see update below). So what should Lambda unit tests look like?

A Lambda function, by definition, can only have side effects by using other services. Unlike in a traditional, non-serverless system, I don’t think it’s necessary to abstract out the service invocations, for two reasons. One, in a serverless/SaaS-based system, abstraction isn’t worth it for getting around the level of vendor lock-in that comes with these designs; a lowest-common denominator interface that can talk to both, say, AWS DynamoDB and Google Cloud Bigtable is going to have limited functionality with basically no opportunities to take advantage of either service’s optimization techniques. Two, abstracting the service invocations for the purposes of testing is unnecessary, and, in fact, creates extra work! The AWS SDK provides mechanisms for stubbing out SDK calls. There are options for the JavaScript SDK in aws-sdk-mock and mock-aws, but as we code primarily in Python, we use placebo (though there is also moto). With placebo, you passively record SDK calls on a real session, and then for testing you can instruct boto3 to use the recorded response instead of actually making the call. So your Lambda function can happily proceed to directly use the SDK (without abstractions), and it’ll never know that it’s inside a testing environment, not actually talking to the outside world.

There’s one caveat here: the way boto3 works, to set up the intercept, you have to call a method on the session that is being used by a given client or resource, and there is no hook provided at the package level to inject this into the Session constructor. An unrelated detail: boto3 sessions, clients, and resources are relatively expensive to create. For these two reasons, we use one minor abstraction across all our Lambdas: we have an class that provides factory methods for sessions, clients, and resources. This class provides that hook for injecting placebo into the session, and also caches the sessions, clients, and resources, which we use to reduce the overhead across successive Lambda invocations. It looks more or less like this:

# in package boto3wrapper import boto3 class Boto3Wrapper(object):

_SESSION_CACHE = {}

SESSION_CREATION_HOOK = None @classmethod

def get_session(cls, **kwargs):

key = tuple(sorted(kwargs.items()))

if key in cls._SESSION_CACHE:

return cls._SESSION_CACHE[key]

session = boto3.Session(**kwargs)

if cls.SESSION_CREATION_HOOK:

session = cls.SESSION_CREATION_HOOK(session)

cls._SESSION_CACHE[key] = session

return session # similar for client and resource, using get_session to obtain

# a session, and also caching the objects

In a Lambda function, you use it in place of directly creating Session, Client, and Resource objects:

from boto3wrapper import Boto3Wrapper def handler(event, context):

# replacing dynamodb = boto3.resource('dynamodb')

dynamodb = Boto3Wrapper.get_resource('dynamodb')

# use as normal

table = dynamodb.Table('MyTable')

Note that since the caching is done at the class level, it persists inside a given Lambda container between invocations.

Our unit tests then use this functionality:

import unittest2, os.path

from boto3wrapper import Boto3Wrapper class MyTest(unittest2.TestCase):

def setUp(self):

def attach_placebo(session):

path = os.path.join(

os.path.dirname(__file__),

'placebo')

pill = placebo.attach(session, data_path=path)

return session

Boto3Wrapper.SESSION_CREATE_HOOK = attach_placebo def test_function_requirement_1(self):

# perform test, Lambda function will automatically get

# placebo injected on its sessions

This approach allows us to write our Lambda functions as concisely as possible, focusing on business logic, and letting abstraction take place at the architecture level, in the separation of code and APIs between Lambda functions.

Update 2016–10–06: You may disagree with me on the value of local integration testing, and want to use local mock services like DynamoDB Local or Dynalite. You can still use the SDK’s hooks to intercept calls and direct them to your local services, without introducing the overhead of an abstraction layer.