Testing Lambda Functions (feat. AWS Amplify)
You are going to learn how to write unit tests for Lambda functions. We are going to write integration tests, too, and use test-driven development to write our tests. We will break our express app into small modules to make its units composable and testable.
Note: This article is a tutorial for intermediate developers who are familiar with the basics of Amplify or Lambda functions. Do you want to learn how to accelerate the creation of your projects using Amplify 🚀? I recommend beginners to check out Nader Dabit's free course on egghead, or Amplify's 'Getting Started'.
I saw Lambda functions without tests and spaghetti code. In fact, I'm guilty of having written several of those myself. It's part of the learning process as a developer to get better and write cleaner and more maintainable code as you gain experience.
You are going to learn the techniques I use to test and simplify Lambda functions. Simplifying means to isolate your program logic into separate, functional units, which makes it more composable and testable. We are going to use RITEway for unit tests, Supertest for integration tests and AWS Amplify to generate our Lambda functions.
Why these three tools? RITEway has a genius API that forces your tests to answer the five questions every unit test must answer. Supertest makes HTTP assertions in Node.js easy. And if you read this article, I won't have to tell you how amazing Amplify is to manage AWS resources. Just check out the other articles of my blog.
Unit Tests
I assume you have a project with Amplify initialized ready. Create a new Lambda function using Amplify.
Consequently, add RITEway and Supertest alongside tap-color. The ladder is for formatting our test output so that it looks pretty.
Make sure to add a testing script to your package.json
.
We will need some functional programming helpers to compose our code. Either install a functional library like Ramda or create a file src/fp/index.js
and add the following functions.
Note: If the following functions scare you, level up by reading "Professor Frisby's Mostly Adequate Guide to Functional Programming". I also explain the basics of currying in "Arrow Functions". Nevertheless, for the sake of this tutorial, it's okay if you don't get all of this article's code. Understanding the techniques is more important than the actual implementation.
I'm going to use Ramda. With the help of tap
and pipe
, we can write applyMiddleware
in src/middleware/index.js
, which lets us elegantly compose our middleware.
Note: If you want to learn how you can functionally write your own custom middleware, you might want to read last weeks article "How to Access the User in Lambda Functions (with Amplify)".
Using our test frameworks, we want to write some unit tests in src/routes/routes.test.js
. We will write one for our "/items"
GET route and one for the listener that logs out on which port the server is running on.
In true TDD fashion, we have to watch the tests fail. Write some dummy functions in src/routes/index.js
.
Watch the tests fail by running npm test
. Afterwards, make the tests pass.
Now we can use them in our function. Note how we also deleted the comments and routes generated by Amplify.
I love how clean this code is 👌🏻. You can locally run your Express server by invoking the Lambda function. But before that, we need to modify src/event.json
to ping the /items
route with a GET request.
Moreover, you can pass keys like "query"
to simulate URLs with "?"
in them and "body"
or "headers"
.
Now invoke the function.
We won't use these manual tests for the rest of the article. I just wanted to show them to you as another option to try out your functions on the fly. Sometimes you want to do that. However, it's usually better to have your tests automatically confirm that your code works instead of you doing it manually. Let's do that using ...
Integration Tests
Testing Lambda functions involves I/O. We mocked res
in our unit test for getItems
, but generally, you want to avoid mocking because it is a code smell. While I'm going to explain how and why I test what I test, you should read "Mocking Is a Code Smell" by Eric Elliott because he explains testing asynchronous code in more detail.
Let's rewrite "/items"
to be "/addresses"
which gets a list of users and returns their addresses. We are going to use the free JSONPlaceholder API for this.
To force us to write modular code, we want to write the GET route for /addresses
using asyncPipe
. A pipe always expects the data types to line up. The function that is passed to the route gets two arguments: request
and response
. We will lift them into a generic object context via a function we call liftReqRes
. Using an object has the advantage that we can pass values through the pipe by attaching them to keys. If you don't understand this yet, wait until you see the code. The code will clear things up for you.
We start with the functional test using Supertest in src/app.test.js
.
Rename our route in app.js
and routes/index.js
to "addresses"
and move handleListen
to src/index.js
. Otherwise, every test would cause our server to run, and the tests wouldn't stop.
Here is how app.js
looks now.
And here is src/index.js
with app.listen
.
Let's implement the functions needed to compose getAddresses
. First up is liftReqRes
.
Watch the test fail, then make it pass by implementing liftReqRes
.
We are going to execute our GET request with axios.get
. Install axios by running npm install axios
in your function's directory. We need to install a library for requests since Lambda functions don't have fetch
. You could also install node-fetch
or a polyfill if you prefer fetch
over axios.
If axios.get
is successful, the response has the following shape:
Here the data
key comes from axios and not from the placeholder API.
getAddressesFromData
expects an object which's data
key contains an array of objects. It returns a new array containing the values that belong to each of these objects' address
keys.
Subsequently, we are going to write the fetch function.
We chain getAddressesFromData
on our promise using .then
. Notice how we also perform a type lift again.
We'll also need to return a JSON response to our users. We can do that using res.json
.
Wait a minute! Did we write three functions without unit tests? 😱
Yes, we did. 😏
The trick about testing a Lambda function - and async code in general - is to choose what to test and how. Generally, you only need integration tests for functions that have side effects. BUT, if you compose an async function with pure functions, you want to write unit tests for the pure functions. This way, when the integration test fails, but the unit tests pass you at least know where the error is NOT located. If both the integration test and the unit tests fail, the unit tests will identify the error for you.
fetchAddresses
is the function containing the I/O, no unit tests needed here.
So why did we not test getAddressesFromData
and jsonAddresses
?
You can certainly make a solid argument for writing unit tests for them, too. But in this case, I chose not to. getAddressesFromData
is composed solely out of well-tested Ramda functions with little specialization. Therefore, there is almost nothing that could go wrong. jsonAddresses
, on the other hand, is also very simple and essential for our integration test. If that part fails, it will probably have a distinct error. In conclusion, not much is gained by writing unit tests for these two functions.
Notice how, depending on your function, you might not want to write any unit tests and just integration tests.
Now compose these functions to get our getAddresses
function.
Run npm test
.
Our integration tests and unit tests pass now! 🚀
If you liked this article, you might also like "How To Use AWS AppSync in Lambda Functions" because you are going to learn how to connect your Lambda functions to your AppSync API, making it more powerful.
Summary
We learned how to use function composition to make our code more modular. We wrote integration tests with Supertest and unit tests with RITEway for the Lambda function.