Using React Testing Library with RadixUI Components

July 10, 2024

Headless UI libraries help you build UI components that are designed to work well in isolation and testing libraries help you test those components.

I've been using the RadixUI headless component library for my UI elements recently and I really like it. Unfortunately, writing tests for them with Testing Library doesn't work out of the box.

These components expect a specific suite of browser APIs to be available globally and some of those APIs are missing when running in a emulated testing environment like JSDom.

To fix this, you will need to play whack-a-mole with Testing Library errors to find the DOM APIs you need to shim yourself.

Let's go through the process of finding and shimming the missing APIs. We'll fix the most common one, PointerEvent, first and then make a test helper that will setup and teardown all the shims you need for you.

Why headless components don't work with JSDom out of the box

What JSDom provides out of the box isn't compatible with Radix expectations around mouse events. Specifically, the shim that is missing is the PointerEvent. This event triggers the pointerdown event type Radix relies on this in their <DropMenu> component. The component’s “trigger” button will only toggle the dropdown on onPointerDown events.

const DropdownMenuTrigger = React.forwardRefDropdownMenuTriggerElement, DropdownMenuTriggerProps>(
  (props: ScopedPropsDropdownMenuTriggerProps>, forwardedRef) => {
    // …
    return (
      MenuPrimitive.Anchor asChild {...menuScope}>
        Primitive.button
          type="button"
          {...triggerProps}
          {// a bunch of other stuff …
          onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
            // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
            // but not when the control key is pressed (avoiding MacOS right click)
            if (!disabled && event.button === 0 && event.ctrlKey === false) {
              context.onOpenToggle();
              // prevent trigger focusing when opening
              // this allows the content to be given focus without competition
              if (!context.open) event.preventDefault();
            }
          })}

Since JSDom does’t have PointerEvent defined, when we try to use the Testing Library to trigger a click on the button trigger, nothing happens.

describe('given a Radix DropMenu', () => {
  it('should open the content', () => {
    render(DropMenu />);
    fireEvent.pointerDown(screen.getByRole('button'));
    screen.debug() // the dropdown content won't render 🤯
})

How to make pointer events work with RadixUI components

The Radix maintainers recommend we use real browsers to test these components via tools like Cypress or Playwright. I'm not fond of adding yet another test runner to support PointerEvents, so we're not going to do this.

There are good reasons to use an end-to-end test runner

Radix's recommendation is good advice! It is especially good advice if you're already heavily invested in a tool like Storybook that will make unit testing components using a headless browser easier. But there's a lot I don't like about this solution as well, first and foremost how painfully slow CI runs for test runners like Cypress can be. I'd much rather unit test components using a light runner like Vitest or Jest, which takes seconds to complete my test suite runs.

So how do we shim left clicks? Surprisingly, all we need to do is set PointerEvent to MouseEvent in our test runners setupFiles.

// vitest-setup.ts
// …
window.PointerEvent = MouseEvent as typeof PointerEvent;

Since PointerEvent is based on MouseEvent most things should work, although your milage may vary.

How to trigger events Radix components need with UserEvent

Testing Library’s fireEvent utility has one key limitation, it only fires the event you ask it to fire. Since it simulates that event, other related events that are normally triggered by the browser don’t take place. This creates problems when testing components like Radix, which are carefully designed to wait for and react to this chain of events.

Thankfully, Testing Library came up with a neat solution to this, a companion library called UserEvent. UserEvent “simulates full interactions, which may fire multiple events… and manipulates the DOM just like a user interaction in the browser would.”

Now, we can update our previous test to use UserEvent and our API shim so that the drop menu is rendered as expected.

describe('given a Radix DropMenu', () => {
  it('should open the content', () => {
    const user = userEvent.setup({ skipHover: true });
    render(DropMenu />);
    // click on the dropdown
    await user.click(dropdown);
    screen.debug() // the dropdown content will render 🚀
})

Creating a reusable test helper to shim missing browser APIs

There are a lot of other DOM APIs aside from PointerEvent missing from JSDom but needed by Radix UI. You might need to shim them for your testing setup to work.

Below is a testing utility I’ve been using to round out the Browser APIs.

It will automatically shim the missing APIs before each test run and tear down the shims after your tests have finished. I use Vitest, so the example is written with that in mind, but the concepts should apply to other test runners as well.

You can try this setup out in this CodeSandbox.

import { afterAll, beforeAll } from "vitest";

import "@testing-library/jest-dom/vitest";
import "@testing-library/user-event";

/**
 * JSDOM does not support all the APIs that we need for testing
 * (see here: https://github.com/jsdom/jsdom/issues/2527).
 *
 * In this setup file, we mock the missing Browser APIs:
 * - PointerEvent
 * - ResizeObserver
 * - DOMRect
 * - HTMLElement.prototype.scrollIntoView
 * - HTMLElement.prototype.hasPointerCapture
 * - HTMLElement.prototype.releasePointerCapture
 *
 * This allows us to test components that rely on these APIs. The mocked APIs
 * are removed after all tests have run.
 */

const installMouseEvent = () => {
  beforeAll(() => {
    const oldMouseEvent = MouseEvent;
    // @ts-ignore
    global.MouseEvent = class FakeMouseEvent extends MouseEvent {
      _init: { pageX: number; pageY: number };
      constructor(name: string, init: { pageX: number; pageY: number }) {
        // @ts-ignore
        super(name, init);
        this._init = init;
      }
      get pageX() {
        return this._init.pageX;
      }
      get pageY() {
        return this._init.pageY;
      }
    };
    // @ts-ignore
    global.MouseEvent.oldMouseEvent = oldMouseEvent;
  });

  afterAll(() => {
    // @ts-ignore
    global.MouseEvent = global.MouseEvent.oldMouseEvent;
  });
};

const installPointerEvent = () => {
  beforeAll(() => {
    // @ts-ignore
    global.PointerEvent = class FakePointerEvent extends MouseEvent {
      _init: {
        pageX: number;
        pageY: number;
        pointerType: string;
        pointerId: number;
        width: number;
        height: number;
      };
      constructor(
        name: string,
        init: {
          pageX: number;
          pageY: number;
          pointerType: string;
          pointerId: number;
          width: number;
          height: number;
        }
      ) {
        // @ts-ignore
        super(name, init);
        this._init = init;
      }
      get pointerType() {
        return this._init.pointerType;
      }
      get pointerId() {
        return this._init.pointerId;
      }
      get pageX() {
        return this._init.pageX;
      }
      get pageY() {
        return this._init.pageY;
      }
      get width() {
        return this._init.width;
      }
      get height() {
        return this._init.height;
      }
    };
  });

  afterAll(() => {
    // @ts-ignore
    delete global.PointerEvent;
  });
};

const installResizeObserver = () => {
  beforeAll(() => {
    const oldResizeObserver = global.ResizeObserver;
    // @ts-ignore
    global.ResizeObserver = class FakeResizeObserver {
      cb: any;
      constructor(cb: any) {
        this.cb = cb;
      }
      observe() {
        this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]);
      }
      unobserve() {}
      disconnect() {}
      oldResizeObserver: any;
    };
    // @ts-ignore
    global.ResizeObserver.oldResizeObserver = oldResizeObserver;
  });

  afterAll(() => {
    // @ts-ignore
    global.ResizeObserver = global.ResizeObserver.oldResizeObserver;
  });
};

const installDOMRect = () => {
  beforeAll(() => {
    const oldDOMRect = DOMRect;
    // @ts-ignore
    global.DOMRect = class FakeDOMRect {
      static fromRect() {
        return {
          top: 0,
          left: 0,
          bottom: 0,
          right: 0,
          width: 0,
          height: 0,
        };
      }
      oldDOMRect: any;
    };
    // @ts-ignore
    global.DOMRect.oldDOMRect = oldDOMRect;
  });

  afterAll(() => {
    // @ts-ignore
    global.DOMRect = global.DOMRect.oldDOMRect;
  });
};

const installScrollIntoView = () => {
  beforeAll(() => {
    const oldScrollIntoView = HTMLElement.prototype.scrollIntoView;
    HTMLElement.prototype.scrollIntoView = function () {
      // Mock implementation
    };
    // @ts-ignore
    HTMLElement.prototype.scrollIntoView.oldScrollIntoView = oldScrollIntoView;
  });

  afterAll(() => {
    HTMLElement.prototype.scrollIntoView =
      // @ts-ignore
      HTMLElement.prototype.scrollIntoView.oldScrollIntoView;
  });
};

const installHasPointerCapture = () => {
  beforeAll(() => {
    const oldHasPointerCapture = HTMLElement.prototype.hasPointerCapture;
    HTMLElement.prototype.hasPointerCapture = function () {
      // Mock implementation
      return true;
    };
    // @ts-ignore
    HTMLElement.prototype.hasPointerCapture.oldHasPointerCapture =
      oldHasPointerCapture;
  });

  afterAll(() => {
    HTMLElement.prototype.hasPointerCapture =
      // @ts-ignore
      HTMLElement.prototype.hasPointerCapture.oldHasPointerCapture;
  });
};

const installReleasePointerCapture = () => {
  beforeAll(() => {
    const oldReleasePointerCapture =
      HTMLElement.prototype.releasePointerCapture;
    HTMLElement.prototype.releasePointerCapture = function () {
      // Mock implementation
    };
    // @ts-ignore
    HTMLElement.prototype.releasePointerCapture.oldReleasePointerCapture =
      oldReleasePointerCapture;
  });

  afterAll(() => {
    HTMLElement.prototype.releasePointerCapture =
      // @ts-ignore
      HTMLElement.prototype.releasePointerCapture.oldReleasePointerCapture;
  });
};

const eventsSetup = () => {
  installMouseEvent();
  installPointerEvent();
  installResizeObserver();
  installDOMRect();
  installScrollIntoView();
  installHasPointerCapture();
  installReleasePointerCapture();
};

export { eventsSetup };

This implementation was heavily inspired by the very cool things React Aria does to solve a similar problem, offering a events simulation helper utility for what they call their “virtualized components”. It does a lot of the shimming we did above for you and automatically tears the shim down when the test that needs it has finished running. Pretty neat stuff.