Testing Netlify TypeScript Functions with Jest
Tags: netlify, typescript, testing, serverless
Published: 2022-02-09 22:32:15

Building [Netlify functions with TypeScript](https://docs.netlify.com/functions/build-with-typescript/) is fantastic!

Testing them is more difficult…

Building off of Jeff Knox's blog on using [Jest with Netlify functions](https://www.jeffreyknox.dev/blog/jest-tests-for-netlify-functions/), I'll walk through testing Netlify TypeScript functions with Jest.

Download the example repo [here](https://github.com/charlesLoder/netlify-functions-with-jest) 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](https://www.weatherapi.com/)

## WeatherAPI

The first step is simple — signup for an account on [WeatherAPI](https://www.weatherapi.com/) 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.

```typescript
// src/function.ts
import { Handler, HandlerEvent } from "@netlify/functions";
import fetch from "node-fetch";
import { config } from "dotenv";
config();
```
These are some helpers.

```typescript
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`:

```typescript
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`](https://www.npmjs.com/package/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!

```json
{
  "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`](https://www.npmjs.com/package/lambda-tester), we'll also install [`aws-lambda`](https://www.npmjs.com/package/aws-lambda) :

```typescript
// 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:

```typescript
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

```typescript
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:

```typescript
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`](https://jestjs.io/docs/expect#expectobjectcontainingobject) 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.

```typescript
sunrise: expect.any(String),
```

## Testing failures

Using our `NetlifyEvent` class makes it easy to test our errors.

```typescript
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.