Simplifying Serverless Application Testing
The Importance of Testing
When it comes to writing high-quality code, testing is crucial. However, with the rise of serverless architecture, new challenges have emerged. Since we don’t control the environment in which our functions run, simulating that environment can be unreliable.
Understanding Unit, Integration, and End-to-End Testing
There are three primary types of tests: unit, integration, and end-to-end.
- Unit tests: focus on isolated pieces of logic
- Integration tests: examine contracts between two or more units
- End-to-end tests: cover everything from start to finish
Each type of test has its own strengths and weaknesses, and understanding their differences is key to effective testing.
Designing Testable Code
To write good tests, you must first write testable code and functions. This means detecting all the places where your function communicates with the outside world and abstracting them away. We’ll call these abstractions adapters.
By doing so, we can test these occurrences in isolation using cheap unit tests.
Implementing Adapters and Core Logic
Let’s take a simple Lambda function as an example. Our function receives parameters from an SQS queue, fetches an image from an S3 bucket, reduces its size, and uploads it back to the same S3 bucket.
const AWS = require('aws-sdk');
const sqs = new AWS.SQS({ region: 'us-east-1' });
const s3 = new AWS.S3({ region: 'us-east-1' });
exports.handler = async (event) => {
// fetch image from S3 bucket
const image = await s3.getObject({ Bucket: 'y-bucket', Key: event.imageKey }).promise();
// reduce image size
const reducedImage = await reduceImageSize(image);
// upload reduced image back to S3 bucket
await s3.putObject({ Bucket: 'y-bucket', Key: event.imageKey, Body: reducedImage }).promise();
};
We’ll create adapters for EventParser
and FileService
, as well as a core logic function for image reduction.
Unit Testing Adapters and Core Logic
We’ll start by writing unit tests for our adapters and core logic.
const { EventParser } = require('./EventParser');
describe('EventParser', () => {
it('should receive an event and sanitize it', () => {
// test implementation
});
});
const { FileService } = require('./FileService');
describe('FileService', () => {
it('should fetch an image from S3 bucket', () => {
// test implementation
});
it('should upload an image to S3 bucket', () => {
// test implementation
});
});
const { reduceImageSize } = require('./reduceImageSize');
describe('reduceImageSize', () => {
it('should reduce image size', () => {
// test implementation
});
});
Integration Testing
Integration testing is all about testing contracts and integrations between two or more code components that are already unit tested.
We’ll connect our adapters and core logic according to our business needs and test the resulting integration.
End-to-End Testing
Finally, we’ll test our function in real-life conditions using real AWS infrastructure.
const AWS = require('aws-sdk');
const sqs = new AWS.SQS({ region: 'us-east-1' });
const s3 = new AWS.S3({ region: 'us-east-1' });
describe('End-to-end testing', () => {
it('should upload an image to S3 bucket after function execution', async () => {
// insert a message into the SQS queue connected to our function
await sqs.sendMessage({ QueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/my-queue', MessageBody: JSON.stringify({ imageKey: 'image.jpg' }) }).promise();
// wait for the function to finish executing
await wait(5000);
// check if a new image exists on the given S3 bucket
const reducedImage = await s3.getObject({ Bucket: 'y-bucket', Key: 'educed-image.jpg' }).promise();
expect(reducedImage).toBeTruthy();
});
});
Putting it All Together
Writing tests for Lambda functions requires careful planning and design from the very beginning. By following these principles and using the right tools, we can ensure that our serverless applications are reliable, efficient, and scalable.
Check out the complete code from this article on GitHub to see everything in action.