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 RequestHandler:

public interface RequestHandler<I, O> { public O handleRequest(I input, Context context);
}

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 String.

public class Handler implements RequestHandler<Map<String,String>, String> { @Override public String handleRequest(Map<String,String> event, Context context) { String message = event.get("message"); return message; }
}

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!”):

{ "author": "Jerome", "message": "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.

For example, ScheduledEvent represents a Amazon Cloudwatch Event or Amazon EventBridge event:

public class ScheduledEvent implements Serializable, Cloneable { private String account; private String region; private Map<String, Object> detail; private String detailType; private String source; private String id; private DateTime time; private List<String> resources;
}

And here is an example of JSON event that can be deserialized with the ScheduleEvent class:

{ "version": "0", "id": "fae0433b-7a0e-e383-7849-7e10153eaa47", "detail-type": "Scheduled Event", "source": "aws.events", "account": "123456789012", "time": "2020-09-30T15:58:34Z", "region": "eu-central-1", "resources": [ "arn:aws:events:eu-central-1:123456789012:rule/demoschedule" ], "detail": { }
}

Let’s test

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).

You can imagine loading a JSON file from your test resources, deserializing it with a JSON library such as Jackson or Gson, and then invoking your handler with this object:

@Test public void testLoadEventBridgeEvent() throws IOException { // Given ObjectMapper objectMapper = new ObjectMapper(); InputStream eventStream = this.getClass().getResourceAsStream("event.json"); ScheduledEvent event = objectMapper.readValue(eventStream, ScheduledEvent.class); EventHandler<ScheduledEvent, String> handler = new EventHandler<>(); // When String response = handler.handleRequest(event, contextMock); // Then assertThat(response).isEqualTo("something");
}

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 SceduledEventMixin class:

public abstract class ScheduledEventMixin { // needed because Jackson expects "detailType" instead of "detail-type" @JsonProperty("detail-type") abstract String getDetailType(); @JsonProperty("detail-type") abstract void setDetailType(String detailType); }

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:

@Test public void testLoadEventBridgeEvent() throws IOException { // Given PojoSerializer<ScheduledEvent> serializer = LambdaEventSerializers.serializerFor(ScheduledEvent.class, ClassLoader.getSystemClassLoader()); InputStream eventStream = this.getClass().getResourceAsStream("event.json"); ScheduledEvent event = serializer.fromJson(eventStream); EventHandler<ScheduledEvent, String> handler = new EventHandler<>(); // When String response = handler.handleRequest(event, contextMock); // Then assertThat(response).isEqualTo("something");
}

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.

Introducing aws-lambda-java-tests

This new library provides tools to seamlessly load and deserialize JSON events and inject them in your tests.

Setup

To use it, add the following dependency to your project. Note that it’s a test dependency.

<dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-lambda-java-tests</artifactId> <version>1.0.0</version> <scope>test</scope>
</dependency>

Also have surefire in your plugins:

<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>2.22.2</version> </plugin> </plugins>
</build>

Load events

With the help of the library, we can now simplify our previous test:

@Test public void testLoadEventBridgeEvent() throws IOException { // Given ScheduledEvent event = EventLoader.loadScheduledEvent("event.json"); EventHandler<ScheduledEvent, String> handler = new EventHandler<>(); // When String response = handler.handleRequest(event, contextMock); // Then assertThat(response).isEqualTo("something");
}

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:

MyEvent myEvent = EventLoader.loadEvent("my_event.json", MyEvent.class);

JSON files should be in the classpath, generally in src/test/resources folder.

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.

With the @Event annotation, we can rewrite our previous test in that way:

@ParameterizedTest
@Event(value = "event.json", type = ScheduledEvent.class)
public void testLoadEventBridgeEvent(ScheduledEvent event) { // Given EventHandler<ScheduledEvent, String> handler = new EventHandler<>(); // When String response = handler.handleRequest(event, contextMock); // Then assertThat(response).isEqualTo("something");
}

Ideally, we would like to test multiple events. The @Events annotation allows this:

/** * This test will be invoked for each JSON file available in the folder "events". */
@ParameterizedTest
@Events(folder = "events", type = ScheduledEvent.class)
public void testInjectEventsFromFolder(ScheduledEvent event) { // Given EventHandler<ScheduledEvent, String> handler = new EventHandler<>(); // When String response = handler.handleRequest(event, contextMock); // Then assertThat(response).isEqualTo("something");
}

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:

@ParameterizedTest
@HandlerParams( events = @Events(folder = "apigw/events/", type = APIGatewayProxyRequestEvent.class), responses = @Responses(folder = "apigw/responses/", type = APIGatewayProxyResponseEvent.class)
)
public void testMultipleEventsResponsesInFolder(APIGatewayProxyRequestEvent event, APIGatewayProxyResponseEvent response) { MyAPIHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> handler = new MyAPIHandler<>(); APIGatewayProxyResponseEvent result = handler.handleRequest(event, contextMock); assertThat(result.getStatusCode()).isEqualTo(response.getStatusCode()); assertThat(result.getBody()).isEqualTo(response.getBody());
}

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.

Conclusion

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.

Find the full documentation and source code for aws-lambda-java-tests on GitHub. Feel free to contribute via pull requests and also to raise an issue if you have feedback or discover a problem.

To learn more, check out the hexagonal architecture and how it can help you write more testable functions and simpler tests.