Building Netlify functions with TypeScript is fantastic!
Testing them is more difficult…
Building off of Jeff Knox's blog on using Jest with Netlify functions, I'll walk through testing Netlify TypeScript functions with Jest.
Download the example repo here to follow along.
p.s. this is my first post, all (useful) feedback is appreciated!
Context
This example is loosely based off of a real world issue.
On a customer's Shopify account page, we have an Elm app that needed to fetch data from an AirTable base and display it to the customer.
We couldn't just expose the AirTable api credentials to the client, but we didn't want to worry about maintaining a server for a Shopify app. Our solution — Netlify functions!
The Elm app calls out to the Netlify functions which serve as a proxy to hide our AirTable credentials.
In this example I won't be using AirTable but rather WeatherAPI
WeatherAPI
The first step is simple — signup for an account on WeatherAPI and get an API key.
Making a function
I won't go through setting up all the config files (see the repo for that), but let's take a look at the function itself.
The top is pretty straightforward, most notably, we're using the Handler
and HandlerEvent
types.
// src/function.ts
import { Handler, HandlerEvent } from "@netlify/functions";
import fetch from "node-fetch";
import { config } from "dotenv";
config();
These are some helpers.
type WeatherAPIError = {
error: {
code: number;
message: string;
};
};
const getError = (error: unknown): { mssg: string } => {
if ((error as WeatherAPIError).error) {
return {
mssg: (error as WeatherAPIError).error.message,
};
}
if ((error as Error).message) {
return {
mssg: (error as Error).message,
};
}
return {
mssg: "An unknown error occurred",
};
};
const getParameter = (event: HandlerEvent, parameter: string): string => {
if (!event.queryStringParameters) {
throw new Error("No parameters passed!");
}
if (!event.queryStringParameters[parameter]) {
throw new Error(`No ${parameter} passed!`);
}
return event.queryStringParameters[parameter]!;
};
WeatherAPIError
is the shape of the WeatherAPI response when there's an error. The code
is not an HTTP code, so I'm just ignoring it.
Because in TypeScript an error is unknown
, the getError
function ensures that our error response is standardized.
The getParameter
function isn't necessary, but it helps to ensure that parameters are passed. In testing, we can also be sure we're getting the right errors back.
Now the main event (poor pun intended), the handler
:
const handler: Handler = async (event: HandlerEvent, context) => {
try {
if (!event) throw new Error("No event!");
const location = getParameter(event, "location");
const date = event?.queryStringParameters?.date || new Date().toLocaleDateString("en-CA");
const resp = await fetch(
`http://api.weatherapi.com/v1/astronomy.json?key=${process.env.API_KEY}&q=${location}&dt=${date}`
);
if (!resp.ok) throw await resp.json();
const data = await resp.json();
return {
statusCode: 200,
headers: {
"access-control-allow-origin": "*",
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
} catch (error) {
const e = getError(error);
return {
statusCode: 400,
headers: {
"access-control-allow-origin": "*",
"Content-Type": "application/json",
},
body: JSON.stringify(e.mssg),
};
}
};
The try
block reaches out to the WeatherAPI astronomy
route, and supplies today's date if one is not passed as a query parameter.
Running a function
Netlify makes is really simple to try out functions locally.
We installed netlify-cli
so we can just run
npm run start
and you should see something like this:
◈ Static server listening to 3999
┌─────────────────────────────────────────────────┐
│ │
│ ◈ Server now ready on http://localhost:8888 │
│ │
└─────────────────────────────────────────────────┘
Now, go to
http://localhost:8888/api/astronomy?location=New York
Fyi, the Netlify redirect is defined in the netlify.toml
config.
And you should see a response!
{
"location": {
// only really interested in the astronomy section
},
"astronomy": {
"astro": {
"sunrise": "06:59 AM",
"sunset": "05:23 PM",
"moonrise": "10:53 AM",
"moonset": "12:31 AM",
"moon_phase": "Waxing Crescent",
"moon_illumination": "47"
}
}
}
Now we can set up Jest to ensure that our responses match the same shape every time, regardless of what location or date is sent.
Testing
Following Knox's example using lambda-tester
, we'll also install aws-lambda
:
// test/function.test.ts
import { HandlerEvent } from "@netlify/functions";
import type { HandlerResponse } from "@netlify/functions";
import lambdaTester from "lambda-tester";
import type { Handler as AWSHandler } from "aws-lambda";
import { handler as myFunction } from "../src/function";
This will ensure that we have all the right types at our disposal.
Next, a few helpers:
class NetlifyEvent {
event: HandlerEvent;
constructor(event?: Partial<HandlerEvent>) {
this.event = {
rawUrl: event?.rawUrl || "",
rawQuery: event?.rawQuery || "",
path: event?.path || "",
httpMethod: event?.httpMethod || "GET",
headers: event?.headers || {},
multiValueHeaders: event?.multiValueHeaders || {},
queryStringParameters: event?.queryStringParameters || null,
multiValueQueryStringParameters: event?.multiValueQueryStringParameters || null,
body: event?.body || "",
isBase64Encoded: event?.isBase64Encoded || false,
};
}
}
type AstronomyResp = {
astronomy: {
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: string;
};
};
};
NetlifyEvent
allows us to create an event easily.
AstronomyResp
is the shape of the response we'll see from the WeatherAPI.
Now let's create an actual test
test("success", async () => {
const netlifyEvent = new NetlifyEvent({
queryStringParameters: {
location: "New York",
},
});
await lambdaTester(myFunction as AWSHandler)
.event(netlifyEvent.event)
.expectResolve((res: HandlerResponse) => {
expect(JSON.parse(res.body ?? "")).toEqual(
expect.objectContaining<AstronomyResp>({
astronomy: expect.objectContaining({
astro: expect.objectContaining({
sunrise: expect.any(String),
sunset: expect.any(String),
moonrise: expect.any(String),
moonset: expect.any(String),
moon_phase: expect.any(String),
moon_illumination: expect.any(String),
}),
}),
})
);
});
});
If we try to pass in our function to lambdaTester()
, we'll get the error:
Argument of type 'Handler' is not assignable to parameter of type 'Handler<any, any>'.
So we need to cast our function as a Handler from aws-lambda
.
For the event()
, we pass in the event
property of the NetlifyEvent
we created, having set our query parameter.
expect()
is where the real fun is.
Our function returns a json response, which we first parse:
expect(JSON.parse(res.body ?? ""))
Then we are expecting it to equal an object containing the properties that we defined in AstronomyResp
.
In Jest expect.objectContaining
can take a generic. This way, as we define what the object should contain, we get type checking!
Once we get down to the actual responses, we don't know what time each sunrise
, sunset
, etc. will be, but we do know that we can expect a string.
sunrise: expect.any(String),
Testing failures
Using our NetlifyEvent
class makes it easy to test our errors.
test("error: no params", async () => {
const netlifyEvent = new NetlifyEvent();
await lambdaTester(myFunction as AWSHandler)
.event(netlifyEvent.event)
.expectResolve((res: HandlerResponse) => {
expect(JSON.parse(res.body ?? "")).toEqual("No parameters passed!");
});
});
test("error: no location", async () => {
const netlifyEvent = new NetlifyEvent({ queryStringParameters: { location: "" } });
await lambdaTester(myFunction as AWSHandler)
.event(netlifyEvent.event)
.expectResolve((res: HandlerResponse) => {
expect(JSON.parse(res.body ?? "")).toEqual("No location passed!");
});
});
test("error: wrong api key", async () => {
process.env.API_KEY = "123456";
const netlifyEvent = new NetlifyEvent({ queryStringParameters: { location: "New York" } });
await lambdaTester(myFunction as AWSHandler)
.event(netlifyEvent.event)
.expectResolve((res: HandlerResponse) => {
expect(JSON.parse(res.body ?? "")).toEqual("API key is invalid.");
});
});
Conclusion
Using Jest, lambda-tester
, and the Handler
type from aws-lambda
, we can test our Netlify TypeScript functions and ensure that the response has the same shape every time, even with different values.
Let me know in the comments if you found this helpful, noticed some errors or areas of improvement, or have your own solution.
Top comments (0)