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:
- Stores JSON items to AWS DynamoDB
- Retrieves stored items from DynamoDB
… using HTTP for client communication. All the application code is contained in index.js
.
1// index.js
2
3const doc = require('dynamodb-doc');
4const dynamo = new doc.DynamoDB();
5
6const response = (statusCode, body, additionalHeaders) => ({
7 statusCode,
8 body: JSON.stringify(body),
9 headers: { 'Content-Type': 'application/json', ...additionalHeaders },
10});
11
12// we don't use the context parameter, thus omitted
13module.exports.handler = async (event) => {
14 try {
15 switch (event.httpMethod) {
16 case 'GET': return response('200', await dynamo.scan({
17 TableName: event.queryStringParameters.TableName }).promise());
18
19 case 'POST': return response('204', await dynamo.putItem(
20 JSON.parse(event.body)).promise());
21
22 default: return response('405',
23 { message: `Unsupported method: ${event.httpMethod}` },
24 { Allow: 'GET, POST' });
25 }
26 } catch (err) {
27 console.error(err);
28 return response('400', { message: err.message });
29 }
30};
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 off
f
references those parameters in its bodyouter
creates and returnsf
1function outer(dependencies) {
2 return function f(...) {
3 // uses dependencies
4 }
5}
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:
1// test code 2const isolatedF = outer(testDoubles); 3// production code 4const 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 viadeps
, sodynamo.[...]
becomesdeps.dynamo.[...]
. Using a single parameter object nameddeps
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 tohandler.js
. Helper functions that exist for stylistic reasons, likeresponse
, can remain hard-wired to its caller.
1// handler.js
2
3const response = (statusCode, body, additionalHeaders) => ({
4 statusCode,
5 body: JSON.stringify(body),
6 headers: { 'Content-Type': 'application/json', ...additionalHeaders },
7});
8
9// Factory function creating and returning the handler function
10module.exports = deps => async (event) => {
11 try {
12 switch (event.httpMethod) {
13 case 'GET': return response('200', await deps.dynamo.scan(
14 { TableName: event.queryStringParameters.TableName }).promise());
15
16 case 'POST': return response('204', await deps.dynamo.putItem(
17 JSON.parse(event.body)).promise());
18
19 default: return response('405',
20 { message: `Unsupported method: ${event.httpMethod}` },
21 { Allow: 'GET, POST' });
22 }
23 } catch (err) {
24 console.error(err);
25 return response('400', { message: err.message });
26 }
27};
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:
1// index.js 2 3const doc = require('dynamodb-doc'); 4 5module.exports.handler = require('./handler')({ 6 dynamo: new doc.DynamoDB(), 7});
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.
1// test/handler.js
2
3const { expect } = require('chai');
4const sinon = require('sinon');
5const doc = require('dynamodb-doc');
6
7const deps = {
8 // Use sinon.stub(..) to prevent any calls to DynamoDB and
9 // enable faking of methods
10 dynamo: sinon.stub(new doc.DynamoDB()),
11};
12
13const myHandler = require('../handler')(deps);
14
15// (Optional) Keep test output free of
16// error messages printed by our lambda function
17sinon.stub(console, 'error');
18
19describe('handler', () => {
20 // Reset test doubles for isolating individual test cases
21 afterEach(sinon.reset);
22
23 it('should call dynamo db scan(...) in case of HTTP GET and return the result', async () => {
24 const event = {
25 httpMethod: 'GET',
26 queryStringParameters: {
27 TableName: 'MyTable',
28 },
29 body: '{}',
30 };
31 // Fake DynamoDB client behavior
32 deps.dynamo.scan.returns({ promise: sinon.fake.resolves('some content') });
33
34 const { headers, statusCode, body } = await myHandler(event);
35
36 sinon.assert.calledWith(deps.dynamo.scan, { TableName: 'MyTable' });
37 expect(headers['Content-Type']).to.equal('application/json');
38 expect(statusCode).to.equal('200');
39 expect(body).to.equal('"some content"');
40 });
41
42 // More tests ...
43});
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
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
René Galle
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.