Using React Testing Library with RadixUI

July 10, 2024

Chances are that if you are working with React today you are using two things: a headless UI library and component tests. I’ve lately enjoyed using the RadixUI headless component library for my UI and Testing Library for my tests, but there are a lot of other good options out there.

Unfortunately, headless UI libraries don't always play nice component testing libraries.

Why some components are harder to test

Testing Library is a suite of testing utilities that allow you to easily test components anywhere that exposes DOM APIs. It is framework agnostic, un-opinionated about the testing framework it’s used in and therefore very easy to use. This also means however that it makes assumptions about the APIs that are made available to your components.

Radix expects a specific suite of browser APIs to be available globally. Since Testing Library doesn’t provide the runtime, only utilities for interacting with the DOM, your test runner must have shimmed all the APIs that Radix relies on or things break. If you’re like me and have grown accustomed to relying on JSDom over the years, you’ll find that many of the interactions needed to make good tests don’t work.

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

Shimming Left Click

It sounds absurd, but this, believe it or not, is entirely necessary. The issue here is the way that Radix expects mouse click interactions to work isn’t compatible with what JSDom provides out of the box. 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 🤯
})

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.

To be clear, this 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.

Triggering more than just events

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 🚀
})

Rounding out the JSDom 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 shim I’ve been using to round out the Browser APIs, you should add more as needed.

window.HTMLElement.prototype.scrollIntoView = vi.fn();
window.HTMLElement.prototype.hasPointerCapture = vi.fn();
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
window.PointerEvent = MouseEvent as typeof PointerEvent;
class ResizeObserver {
  cb: any;
  constructor(cb: any) {
    this.cb = cb;
  }
  observe(cb: any) {
    this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]);
  }
  unobserve() {}
  disconnect() {}
}
// @ts-ignore
global.ResizeObserver = ResizeObserver;
global.DOMRect = {
  // @ts-ignore
  fromRect: () => ({
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    width: 0,
    height: 0,
  }),
};

You can try this setup out in this CodeSandbox.

React Aria

I haven’t had the privilege of using React Aria components heavily, but one of the very cool things they do in that space is offer 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.

In a perfect world, it would work with more than just Jest but with some minimal effort, you could probably port those mocks to be used in setup/teardown instead of globally polyfilled.