DEV Community

Charles Loder
Charles Loder

Posted on

Testing Netlify TypeScript Functions with Jest

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();
Enter fullscreen mode Exit fullscreen mode

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]!;
};
Enter fullscreen mode Exit fullscreen mode

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),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and you should see something like this:

◈ Static server listening to 3999

   ┌─────────────────────────────────────────────────┐
   │                                                 │
   │   ◈ Server now ready on http://localhost:8888   │
   │                                                 │
   └─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Now, go to

http://localhost:8888/api/astronomy?location=New York
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
    };
  };
};
Enter fullscreen mode Exit fullscreen mode

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),
            }),
          }),
        })
      );
    });
});
Enter fullscreen mode Exit fullscreen mode

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>'.
Enter fullscreen mode Exit fullscreen mode

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 ?? ""))
Enter fullscreen mode Exit fullscreen mode

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),
Enter fullscreen mode Exit fullscreen mode

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.");
    });
});
Enter fullscreen mode Exit fullscreen mode

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.

Latest comments (0)