Testing is an essential task when building software. Testing helps improve software quality by finding bugs before they reach production. The sooner we know there is a defect in code, the easier and cheaper it is to correct. Automated tests are a central piece in reducing this feedback loop. In association with a continuous integration and continuous deployment (CI/CD) pipeline, tests should reduce the number of issues discovered in production. Also, testing provides some confidence when updating an application, a sort of seat belt that will reduce the risk of introducing regressions.
AWS Lambda is a serverless compute service that lets you run code in response to events. Lambda natively supports several runtimes: Node.js, Python, Ruby, Go, PowerShell, C#, and Java. With Lambda, you can bring code and whatever libraries you need to perform any operation, be it a REST API backend, a scheduled operation, or any other logic that suits your business. And like any other piece of code, a Lambda function deserves to be tested.
We know that writing unit and integration tests for Lambda can be a challenge, especially in Java, and today we are happy to announce the release of aws-lambda-java-tests, an opinionated library that simplifies writing tests.
Anatomy of a Java Lambda function handler
The handler is the entry point of your Lambda function: This is the method that is executed when the Lambda function is invoked. It receives the event that triggered the invocation as a parameter along with the context. The aws-lambda-java-core library defines two interfaces to help you write this handler. Let’s focus on the
When you use this interface, the Java runtime deserializes the event into an object with the input type (
I) and serializes the the result with output type (
O) into text.
As an example, the following function takes a
Map (key/value) as an event and return a simple
If this function receives the following event (in JSON format), it will provide a
Map to your handler and will return the message value (
“Hello from Lambda!”):
Using advanced events
The previous example is quite simple, and most of the time you will need more complex things, such as an Amazon Simple Queue Service (Amazon SQS) or an Amazon API Gateway event. This is where the aws-lambda-java-events library comes into play. This library provides event definitions for most of the events Lambda natively supports.
And here is an example of JSON event that can be deserialized with the
Why am I telling you all this and how does this relate to tests? Because when you test your Lambda function (
EventHandler in the following examples), you want to inject some JSON events (input), and validate that it behaves as expected and returns the correct response (output).
Easy, isn’t it? Yes, until you discover that “detail-type” is not correctly deserialized in the
detailType attribute. And the same problem will happen with Amazon SQS, Amazon Kinesis, Amazon DynamoDB “Records” (vs. “records” in the code), and many others. Indeed, the Lambda Java Runtime is not just a simple Jackson ObjectMapper; the (de)serialization process takes care of these “specificities.”
Unfortunately, you don’t have access to this runtime for your tests. Or at least that was true until when we, during re:Invent, announced the support of container images to package your Lambda function. If you carefully read the announcement, you will see:
We have open-sourced a set of software packages, Runtime Interface Clients (RIC), that implement the Lambda Runtime API, allowing you to seamlessly extend your preferred base images to be Lambda compatible. The Lambda Runtime Interface Clients are available for popular programming language runtimes.
And Java was not forgotten! Looking at the aws-lambda-java-libs repository, you will see the appearance of several new libraries: aws-lambda-java-runtime-interface-client, and another, more discreet but just as useful, aws-lambda-java-serialization.
If you look at this last one, this is exactly what we need to correctly deserialize the special events. Taking the
ScheduledEvent as an example, we can see there is a
Note: Mix-ins are a powerful Jackson feature that allows to specify how to (de)serialize attributes in classes you cannot modify—in a third-party library, for example. And this library defines all the mix-ins you need (SQS, SNS, Kinesis, and so on). Now we can rewrite our previous test:
Thanks to this library, we are now able to inject JSON events in our tests and get them correctly deserialized, but it remains as verbose as before.
This new library provides tools to seamlessly load and deserialize JSON events and inject them in your tests.
To use it, add the following dependency to your project. Note that it’s a test dependency.
Also have surefire in your plugins:
With the help of the library, we can now simplify our previous test:
EventLoader provides loaders for the most common event types available in aws-lambda-java-events—Amazon Simple Notification Service (Amazon SNS), Amazon SQS, Amazon Kinesis, Amazon Simple Storage Service (Amazon S3), Amazon DynamoDB, and many others. We also can load our own event types:
JSON files should be in the classpath, generally in
That’s a great first step but we can go further.
Inject events in tests
A set of annotations can be used to inject events and/or validate handler responses against those events. All these annotations must be used in conjunction with the
@ParameterizedTest annotation from JUnit 5.
ParameterizedTest enables to inject arguments into a test, so we can run the same test one or more times with different parameters.
@Event annotation, we can rewrite our previous test in that way:
Ideally, we would like to test multiple events. The
@Events annotation allows this:
And ultimately, we would like to verify that when we inject a specific event (or set of events), we receive a specific response (or set of responses). The
@HandlerParams annotation provides this feature:
In this test, all events available in the
apigw/events/ folder and all responses in the folder
apigw/responses will be injected in the event and response parameters of the test method. So with one unique simple test, we are able to validate many use cases and thus significantly reduce the amount of code to write. To get it working correctly, we should have the same number of files in each folder and correctly ordered so that a response matches an event.
If at some point, we discover a bug in a Lambda function, we can retrieve the JSON event in the CloudWatch logs and copy it into the events folder. Create the expected JSON response, put it in the response folder, and we’re done. The next time we will push the function code, the CI/CD pipeline will execute this test and ensures no regression is introduced.
Testing Lambda functions is as important as any other piece of software, it will provide you more confidence when deploying to production and reduce the number of issues you will meet at this level. Thanks to this new library, testing functions written in Java becomes easier. With a few annotations and lines of code, you can validate the different behaviours of your function according to the events it receives.
To learn more, check out the hexagonal architecture and how it can help you write more testable functions and simpler tests.