Declarative Mocking Of AWS Lambda Functions
How to write reliable tests for AWS Lambda functions by avoiding imperative mocking
One way to automate the processing of various business policy rules is to leverage the AWS Lambda Functions. Doing that relieves us from the chores of having to set up and manage the computing infrastructure. However, we need to learn how to implement AWS Lambda functions properly, and it’s not necessarily a simple thing to do. Before we tackle that challenge, let’s first have a quick overview of the Lambda functions.
Understanding the Fundamentals—What is AWS Lambda?
At its core, AWS Lambda is a serverless compute service. This means you don't have to provision or manage servers. You simply upload your code (the "lambda function"), and AWS Lambda runs it for you in response to events. These events can be anything from HTTP requests via API Gateway to changes in an S3 bucket.
Key Concepts:
Serverless: No server management. AWS takes care of the infrastructure.
Event-Driven: Lambda functions are triggered by events.
Pay-per-Use: You only pay for the compute time your function consumes.
Scalable: Lambda automatically scales to handle the incoming request volume.
Stateless: Each invocation of a Lambda function is independent and doesn't retain any state from previous invocations. This is crucial for deterministic behavior, which we'll discuss further.
In simple terms: Think of Lambda as a programmable cloud function that executes on demand. You write the code, configure the trigger, and let AWS handle the rest.
How to manage dependencies in Lambda functions?
We often see that the executable tests that verify that Lambda functions work as expected involve testing the dependencies. For example, a Lambda function may depend on some API, which often means that the test will make the call to that API before forwarding the results of that call to the Lambda function under test. That approach is definitely not recommended, for the reasons best discussed in a different article. And indeed, many teams avoid testing the real dependencies and instead use the mocking technique to provide reliable testing.
The problem with mocking is that we are introducing more overhead (i.e., more code to be maintained). And that is also not a recommended approach. The issue boils down to the imperative code—it is burdensome to develop, and it is burdensome to maintain. It is more trouble than it’s worth. Using declarative code is always a better choice.
Is there a way to mock the dependencies by avoiding imperative code and using only declarative code? One example would be creating imposters using service virtualization, where we simulate interactions between our function and outside dependencies by crafting some declarations in JSON.
That’s a very important point: managing the overhead of mocking and finding more declarative, maintainable ways to define those mocks. Handcrafted mocks can become verbose and difficult to manage.
Let's explore options for declarative mocking with a focus on JSON-based configurations, similar to what you'd do with service virtualization imposters, but tailored to our Lambda testing environment.
Challenges with Traditional Mocking
Imperative Code: Most mocking libraries encourage an imperative style of mocking, where we explicitly define the behavior of the mock objects in code. This can lead to verbose and hard-to-read test code.
Maintenance Overhead: As our application grows and our dependencies change, we need to update our mock objects accordingly. This can be a significant maintenance burden.
Lack of Reusability: Mock objects are often specific to a particular test case and cannot be easily reused across multiple tests.
Declarative Mocking Approaches
Here are a few approaches to declarative mocking for Lambda functions, inspired by the idea of using JSON-based configurations:
1. JSON-Based Mock Definitions with a Helper Function:
This approach involves defining mock responses in JSON files and using a helper function to load and apply those responses to mock objects. In these examples, we will use code written in Python (but it should be relatively easy to translate the example code into other programming languages).
a. Define Mock Responses in JSON:
Create JSON files that define the mock responses for your dependencies. For example:
mocks/get_user_response.json:
{
"user_id": "user123",
"name": "Test User",
"membership_status": "Gold"
}
mocks/get_total_spent_response.json:
{
"total_spent": 150.00
}
b. Create a Helper Function to Apply Mock Responses:
Create a helper function that loads the JSON files and applies the responses to mock objects.
test_utils.py:
import json
from unittest.mock import MagicMock
def load_mock_response(file_path):
with open(file_path, 'r') as f:
return json.load(f)
def apply_mock_response(mock_object, method_name, file_path):
response = load_mock_response(file_path)
getattr(mock_object, method_name).return_value = response
c. Use the Helper Function in Your Tests:
Use the helper function in your tests to apply the mock responses to the mock objects.
tests/test_discount_eligibility.py:
import pytest
from unittest.mock import MagicMock
from discount_eligibility import lambda_handler
from test_utils import apply_mock_response
def test_eligible_for_discount_minimum_purchase_amount():
# Arrange
event = {
'user_id': 'user123',
}
context = MagicMock()
mock_purchase_history_service = MagicMock()
apply_mock_response(mock_purchase_history_service, 'get_total_spent_in_last_30_days', 'mocks/get_total_spent_response.json')
# Act
response = lambda_handler(event, context, purchase_history_service=mock_purchase_history_service)
# Assert
assert response['eligible'] == True
assert response['reason'] == "Minimum purchase amount met"
def test_eligible_for_discount_gold_membership():
# Arrange
event = {
'user_id': 'user456',
}
context = MagicMock()
mock_purchase_history_service = MagicMock()
apply_mock_response(mock_purchase_history_service, 'get_total_spent_in_last_30_days', 'mocks/get_total_spent_response_50.json')
mock_membership_service = MagicMock()
apply_mock_response(mock_membership_service, 'get_membership_status', 'mocks/get_user_response.json')
# Act
response = lambda_handler(event, context, purchase_history_service=mock_purchase_history_service, membership_service=mock_membership_service)
# Assert
assert response['eligible'] == True
assert response['reason'] == "Gold membership"
Explanation:
We define reusable mock responses in JSON files.
The apply_mock_response function loads the JSON file and assigns it to the return_value of the specified method on the mock object.
This reduces the amount of imperative code in the tests and makes them more declarative.
2. Custom Mocking Library with JSON Configuration:
We could take this approach further and create our own custom mocking library that is specifically designed to work with JSON-based mock definitions. This would allow us to encapsulate the mocking logic and provide a more consistent and user-friendly API for our tests.
3. Using Existing Mocking Libraries with JSON Support:
Some existing mocking libraries might have built-in support for loading mock responses from JSON files. For example, some Python mocking libraries allow us to define mock responses using dictionaries, which can be easily loaded from JSON.
4. Contract Testing (Consumer-Driven Contracts):
While not strictly "mocking," contract testing offers another powerful approach to ensuring that our Lambda functions are compatible with their dependencies. Contract testing involves defining a "contract" that specifies the expected interactions between a consumer (e.g., our Lambda function) and a provider (e.g., an API or database). The consumer then writes tests that verify that the provider adheres to the contract.
Benefits of Declarative Mocking:
Reduced Code Duplication: Reusable mock definitions reduce code duplication and make our tests easier to maintain.
Improved Readability: Declarative mock definitions are always easier to read and understand than imperative code.
Increased Flexibility: JSON-based mock definitions can be easily modified and extended without changing the test code.