Articles by Sergio Xalambrí

Jest Matchers for Remix responses

When testing the logic of an action in Remix, or any function returning a Response, even a Fetch API Response, you need to run different assertions against the returned Response.

You can do some simple tests like ensure the Response is a redirect to a certain path doing a few expects like this:

expect(response.headers.get("Location")).toBe("/dashboard");
expect(response.status).toBe(302);

Let's do better, we can create some custom Jest assertions to test our Responses.

Types them

Create a jest.d.ts at the route of your project with the following code:

declare global {
  namespace jest {
    interface Matchers<R> {
      toBeOk(): R;
      toRedirect(path?: string): R;
      toHaveStatus(status: number): R;
      toHaveHeader(header: string, value?: string): R;
      toSetACookie(): R;
    }
  }
}

export {};

This way we will extend Jest types for the matchers to add the following assertions:

  • toBeOk will assert the response.ok is true
  • toRedirect will assert the Response is a Redirect, optionally we can expect a specific path in the Location
  • toHaveStatus will assert the status code of the Response
  • toHaveHeader will assert a specific header is defined in the Response, optionally we can expect a specific value for that header
  • toSetACookie will assert the header Set-Cookie is defined, useful to check if we are updating the session

Implementation

toBeOk

This is the simplest one, we check if response.ok is true.

function toBeOk(response: Response) {
  let pass = response.ok;
  return {
    pass,
    message() {
      if (pass) return "The response should not be ok.";
      return "The response should be ok.";
    },
  };
}

toRedirect

This one will check if the response is a string and matchs the expected or if it has the Location header with the expected path (if one was defined) and we need to also check the status code if it's 302.

function toRedirect(response: Response | string, path?: string) {
  // the response could be a string if we are only redirecting
  if (typeof response === "string") {
    return {
      pass: response === path,
      message() {
        if (response === path) {
          return `The response should not redirect to ${path}`;
        }
        return `The response should redirect to ${path}`;
      },
    };
  }

  let header = response.headers.get("Location");
  let status = response.status;

  let pass = status === 302 && header === path;

  return {
    pass,
    message() {
      if (pass) {
        return `The response should not redirect to ${path}`;
      }
      return `The response should redirect to ${path}`;
    },
  };
}

toHaveStatus

This will check the value of the status and see if it's the expected one.

function toHaveStatus(response: Response, expected: number) {
  let pass = response.status === expected;

  return {
    pass,
    message() {
      if (pass) {
        return `The status code of the response should not be ${expected}.`;
      }
      return `The status code of the response should be ${expected}, it was ${response.status}.`;
    },
  };
}

toHaveHeader

This is the most complex one, here we need to check if the header exists and, if the value is defined then we also need to check if the header value is the one we are expecting.

function toHaveHeader(response: Response, name: string, value?: string) {
  let pass = response.headers.has(name);

  if (!Boolean(value)) {
    return {
      pass,
      message() {
        if (pass) return `It should not have the header ${name}`;
        return `It should have the header ${name}`;
      },
    };
  }

  if (Boolean(value)) {
    pass = response.headers.get(name) === value;
  }

  return {
    pass,
    message() {
      if (pass) {
        return `It should not have the header ${name} with value ${value}, it was ${response.headers.get(
          name
        )}`;
      }
      return `It should have the header ${name} with value ${value}, it was ${response.headers.get(
        name
      )}`;
    },
  };
}

toSetACookie

Here we need to check if the header Set-Cookie exists.

function toSetACookie(response: Response) {
  let hasSetCookie = response.headers.has("Set-Cookie");

  return {
    pass: hasSetCookie,
    message() {
      if (hasSetCookie) return "Expected the response to not set a cookie.";
      return "Expected the response to set a cookie.";
    },
  };
}

Setup

Now we can add all our assertions setup file for Jest using the following line:

expect.extend({
  toBeOk,
  toRedirect,
  toHaveStatus,
  toHaveHeader,
  toSetACookie,
});

With this, we are extending Jest to support those assertions, the jest.d.ts fill only added them to the types so TS will not say they don't exists, the code above actually adds them to Jest.

Usage

Now we can write our tests and use them, for example we could test our authenticationForm function to ensure the response has the correct redirects and headers.

describe("Authentication Form", () => {
  test("redirects to /dashboard if it's a success", async () => {
    let request = new Request("/login", {
      method: "POST",
      body: new URLSearchParams({
        email: "[email protected]",
        password: "12345678",
      }),
    });
    let response = authenticationForm(request);

    expect(response).not.toBeOk(); // redirects are not ok
    expect(response).toRedirect("/dashboard");
    expect(response).toHaveStatus(302); // this is also tested in `toRedirect`
    expect(response).toHaveHeader("Location", "/dashboard"); // this is also tested in `toRedirect`
    expect(response).toSetACookie();
  });

  // more tests here
});