Using Fetch when Testing HTML with JSDOM

a tiny, brown slug crawls across a hand toward the camera, antennae extended

Background - JSDOM vs. Browsers

Most methods I know of for writing useful unit tests of web application functionality share the same major drawback - the tests don't run in a web browser . Theoretically, they should. If the environment where you test your application is different from the environment where users interact with it, the differences in how the code runs can cause bugs.

Instead of a web browser, these tests run in NodeJS, which can interpret JavaScript outside of a web browser. The tests run fast because you don't actually have to turn the HTML into pixels in a window. On the other hand, NodeJS lacks many of the features of a web browser, like a document where HTML is rendered.

Most testing frameworks deal with this by running the tests inside a harness that provides the parts of a browser NodeJS doesn't have. My vanilla web application uses a Javascript implementation of many web standards called JSDOM (JavaScript Document Object Model) . JSDOM provides a window, document, and other web-browser features. To render a page and run tests against it, all I have to do is instantiate a JSDOM object with the contents of the HTML file I'm testing. It can even load and run scripts that the HTML document calls for. Once I've done that, I can use DOM Testing Library the same way I would in a React project where most of this is set up for me.

What's the problem?

JSDOM doesn't fully emulate a browser, and I recently ran into a sticky problem. When my HTML document under test loaded a script that tried to use the fetch function, it would crash saying fetch wasn't defined. Fetch is a function you can use to make requests to another server. It belongs to the global window object, so it would always be defined in a web browser. JSDOM however, has never provided fetch because there isn't a spec-compliant implementation of the fetch algorithm for it to use in Node.

Below are several code snippets showing the test, HTML, and script as they were when I ran into this issue.

Here's my test asserting that rolling dice updates the dice to a new value. Notice that I set up a mock server to return a specific set of dice for a GET call to the dice endpoint. In a multi-player game, the server has to decide the results of the dice roll so the player can't cheat.

the following is a Typescript code sample

it("has a button to roll the dice", async () => {
  const rolledDice = [4, 4, 3, 1, 2];

  mockServer.use(http.get("/dice", () => {
    return HttpResponse.json({ dice: rolledDice });
  }));

  const { getByRole, user } = await render("heisldice.html");

  const button = getByRole("button", { name: /roll dice/i });

  await user.click(button);

  const dice = within(getByRole("list")).getAllByRole("listitem");

  expect(dice[0].textContent).toBe(`${rolledDice[0]}`);
  expect(dice[1].textContent).toBe(`${rolledDice[1]}`);
  expect(dice[2].textContent).toBe(`${rolledDice[2]}`);
  expect(dice[3].textContent).toBe(`${rolledDice[3]}`);
  expect(dice[4].textContent).toBe(`${rolledDice[4]}`);
});

And here's the HTML with the button and list of dice

the following is an HTML code sample

<!DOCTYPE html>
<html lang="en">
<head>
  <script src="dice.ts" defer></script>
</head>
<body>
  <ul id="dice-container">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>
    <button id="dice-roll-button">ROLL DICE</button>
</body>
</html>

Finally, here's the script dice.ts the button uses to make the API call to roll the dice:

the following is a Typescript code sample

const rollDice = async () => {
  const response = await fetch(`/dice`, { method: "GET" });
  const { dice } = await response.json();

  const diceElements = document.getElementById("dice-container")?.querySelectorAll("li");

  diceElements?.forEach((element, index) => {
    element.textContent = `${dice[index]}`;
  });
};

const button = document.getElementById("dice-roll-button");

button?.addEventListener("click", (event) => {
  const button = event.target;
  if (button && button.id === "dice-roll-button") {
    rollDice();
  }
});

Failed Attempt

I tried creating a custom Vitest environment and injecting Node's fetch into it. My thinking was that Vitest's JSDOM didn't have fetch, so adding Node's fetch to the "global" object of that JSDOM environment would expose it to my script.

the following is a Typescript code sample

import { Environment, builtinEnvironments} from "vitest/environments";

export default <Environment>{
  name: "jsdom-with-fetch",
  transformMode: "web",
  setup(global, ...args) {
    global.fetch = fetch;
    return builtinEnvironments.jsdom.setup(global, ...args);
  }
}

This didn't work. Maybe I did it wrong, but the documentation is not very clear or well fleshed out. It seems more likely that it just replaced fetch with itself in the NodeJS process that was running the tests, but not whatever process my script was running in. I went around in circles, understanding that I needed to provide fetch to the code running in JSDOM, not the code running in the main Node process, like my tests themselves.

The Solution

Finally, I found a way. Unlike React Testing Library with its render method, DOM Testing Library assumes you'll build a DOM yourself in some way . At least some of my tests need to test HTML files, so to "render" a DOM, I need to read those files into an object model. That's where JSDOM is useful. I created my own render method below to read those files into a JSDOM object, then wrap it in a Testing Library container that gives me all the query methods to get elements from that DOM. Note: I omitted the definition of the getCallerFilePath function for space and to avoid confusion. I use it to get the path to the test file that called render, which I'm assuming is the same directory where the HTML file I'm loading is.

the following is a Typescript code sample

import { JSDOM } from "jsdom";
import * as fs from "node:fs";
import { BoundFunctions, queries, within as testingLibraryContainer } from "@testing-library/dom";
import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup";
import userEvent from "@testing-library/user-event";

export type Container = BoundFunctions<typeof queries> & { user: UserEvent };

export const render = (filePath: string) => {

  const html = fs.readFileSync(filePath);

  return new Promise<Container>(resolve => {
    const dom = new JSDOM(html, {
      runScripts: "dangerously",
      resources: "usable",
      url: `file://${callingFilePath}/${fileName}`,
    });

    const user = userEvent.setup();

    dom.window.document.addEventListener("DOMContentLoaded", () => {
      const container = {
        ...testingLibraryContainer(dom.window.document.body),
        user
      }

      resolve(container);
    });
  });
};

This JSDOM object is the context in which scripts loaded by my HTML will run. dom.window is the global window object those scripts refer to. So if I need those scripts to have access to fetch (which is actually window.fetch), then I just need to pass NodeJS' fetch function to dom.window. Because I'm only doing this for my tests, and I have a mock server intercepting API calls during tests, this is sufficient and safe. In production, the scripts will have access to the real fetch function. Below is a snippet from the above file showing where I added this.

the following is a Typescript code sample

dom.window.document.addEventListener("DOMContentLoaded", () => {

  // this is the important new line
  dom.window.fetch = fetch;

  const container = testingLibraryContainer(dom.window.document.body)

  resolve(container);
});

After I added this, when the test clicks the button, the API call succeeds and the dice list items get updated correctly!

Back to the home page