reactjsreact-testing-libraryvitestshadcnuiradix-ui

How can I write a test for selecting an item from a Select component that uses Portal?


I am writing a test for a Select component using the shadcn/ui package. This component has some parts (such as selectable items) that are rendered inside a Portal. Because of this, I’m having trouble accessing and selecting items from the Portal in my test. My expectation is that the test should click on the component, select an item with the text "Item 3", and update the associated state correctly.

my Component:


const SelectComponent = forwardRef<ElementRef<typeof Root>, SelectProps>(
  ({ disabled, ...props }) => {
    return (
      <SelectContext.Provider value={{ disabled: disabled }}>
        <Root {...props} disabled={disabled} />
      </SelectContext.Provider>
    );
  }
);
SelectComponent.displayName = "SelectComponent";

const SelectTrigger = forwardRef<
  ElementRef<typeof Trigger>,
  ComponentPropsWithoutRef<typeof Trigger>
>(({ className, children, ...props }, ref) => {
  const context = useContext(SelectContext);
  return (
    <Trigger
      ref={ref}
      className={cn(
        "flex w-full items-center justify-between whitespace-nowrap p-4 pl-6 rounded-xl border border-input text-base focus:outline-none ",
        context?.disabled
          ? "bg-gray-10 cursor-not-allowed opacity-50"
          : "bg-transparent",
        className
      )}
      dir="rtl"
      disabled={context?.disabled}
      data-testid="select"
      {...props}
    >
      {children}
      <Icon>
        <ArrowDownIcon />
      </Icon>
    </Trigger>
  );
});
SelectTrigger.displayName = Trigger.displayName;

const SelectScrollUpButton = forwardRef<
  ElementRef<typeof ScrollUpButton>,
  ComponentPropsWithoutRef<typeof ScrollUpButton>
>(({ className, ...props }, ref) => (
  <ScrollUpButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1 ",
      className
    )}
    {...props}
  >
    <ChevronUpIcon />
  </ScrollUpButton>
));
SelectScrollUpButton.displayName = ScrollUpButton.displayName;

const SelectScrollDownButton = forwardRef<
  ElementRef<typeof ScrollDownButton>,
  ComponentPropsWithoutRef<typeof ScrollDownButton>
>(({ className, ...props }, ref) => (
  <ScrollDownButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronDownIcon />
  </ScrollDownButton>
));
SelectScrollDownButton.displayName = ScrollDownButton.displayName;

const SelectContent = forwardRef<
  ElementRef<typeof Content>,
  ComponentPropsWithoutRef<typeof Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <Portal data-testid="select-portal">
    <Content
      ref={ref}
      className={cn(
        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      data-testid="select-content"
      {...props}
    >
      <SelectScrollUpButton />
      <Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </Viewport>
      <SelectScrollDownButton />
    </Content>
  </Portal>
));
SelectContent.displayName = Content.displayName;

const SelectLabel = forwardRef<
  ElementRef<typeof Label>,
  ComponentPropsWithoutRef<typeof Label>
>(({ className, ...props }, ref) => (
  <Label
    ref={ref}
    className={cn("px-2 py-1.5 text-sm font-semibold", className)}
    {...props}
  />
));
SelectLabel.displayName = Label.displayName;

const SelectItem = forwardRef<
  ElementRef<typeof Item>,
  ComponentPropsWithoutRef<typeof Item>
>(({ className, children, ...props }, ref) => (
  <Item
    ref={ref}
    className={cn(
      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      className
    )}
    dir="rtl"
    data-testid="select-item"
    {...props}
  >
    <span className="absolute left-5 flex h-3.5 w-3.5 items-center justify-center">
      <ItemIndicator>
        <CheckIcon className="h-4 w-4" />
      </ItemIndicator>
    </span>
    <ItemText>{children}</ItemText>
  </Item>
));
SelectItem.displayName = Item.displayName;

const SelectSeparator = forwardRef<
  ElementRef<typeof Separator>,
  ComponentPropsWithoutRef<typeof Separator>
>(({ className, ...props }, ref) => (
  <Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
));
SelectSeparator.displayName = Separator.displayName;

type SelectComponentType = typeof SelectComponent & {
  Item: typeof SelectItem;
  Group: typeof SelectGroup;
  Content: typeof SelectContent;
  Trigger: typeof SelectTrigger;
  Label: typeof SelectLabel;
  Value: typeof SelectValue;
  Separator: typeof SelectSeparator;
};

const Select = SelectComponent as SelectComponentType;

Select.Item = SelectItem;
Select.Group = SelectGroup;
Select.Content = SelectContent;
Select.Trigger = SelectTrigger;
Select.Label = SelectLabel;
Select.Value = SelectValue;
Select.Separator = SelectSeparator;

export default Select;

Here are the tests I’ve written so far:

const container = document.createElement("div");
    document.body.appendChild(container);

    const ComponentSample = () => {
      const [selectedOption, setSelectedOption] = useState("1");

      return (
        <Component value={selectedOption} onValueChange={setSelectedOption}>
          <Component.Trigger>{selectedOption}</Component.Trigger>
          <Component.Content>
            <Component.Item value="1">item 1</Component.Item>
            <Component.Item value="2">item 2</Component.Item>
            <Component.Item value="3">item 3</Component.Item>
            <Component.Item value="4">item 4</Component.Item>
          </Component.Content>
        </Component>
      );
    };
    const { getByTestId, getByText } = render(<ComponentSample />, {
      container,
    });
  1. In my first test, I used fireEvent to simulate clicking on the trigger and selecting an item. However, getByText is unable to find "Item 3" because it is rendered inside a Portal.

    fireEvent.click(getByTestId("select"));
    fireEvent.click(getByText("item 3"));
    
  2. In my second attempt, I used waitFor to wait for the items to render in the DOM, but still, "Item 3" is not accessible within the test scope.

    fireEvent.click(getByTestId("select"));
    await waitFor(() => getByText("item 3").click());
    

How can I properly find and select items rendered inside a Portal in my tests? Is there a specific configuration or method in React Testing Library or Vitest that I need to apply?

Thank you in advance for your help! 🙏


Solution

  • The issue I faced while testing Select components that use portals was that PointerEvent wasn't properly simulated in the test environment. Additionally, I needed to mock methods like scrollIntoView, hasPointerCapture, and releasePointerCapture. To solve this, I created a custom MockPointerEvent and mocked the necessary browser methods. Below is the solution:

    Solution:

    Step 1: Mock PointerEvent and other methods

    export class MockPointerEvent extends Event {
      button: number | undefined;
      ctrlKey: boolean | undefined;
    
      constructor(type: string, props: PointerEventInit | undefined) {
        super(type, props);
        if (props) {
          if (props.button != null) {
            this.button = props.button;
          }
          if (props.ctrlKey != null) {
            this.ctrlKey = props.ctrlKey;
          }
        }
      }
    }
    
    // Replace the native PointerEvent with MockPointerEvent
    window.PointerEvent = MockPointerEvent as any;
    
    // Mock methods scrollIntoView, hasPointerCapture, and releasePointerCapture
    window.HTMLElement.prototype.scrollIntoView = vi.fn();
    window.HTMLElement.prototype.hasPointerCapture = vi.fn();
    window.HTMLElement.prototype.releasePointerCapture = vi.fn();
    

    Step 2: Write the test for the Select component

    import { render, fireEvent, waitFor, screen } from "@testing-library/react";
    import { useState } from "react";
    import Component from "./SelectComponent"; // Adjust the import path
    
    test("select item", async () => {
      // Arrange
      const ComponentSample = () => {
        const [selectedOption, setSelectedOption] = useState("1");
    
        return (
          <Component value={selectedOption} onValueChange={setSelectedOption}>
            <Component.Trigger>{selectedOption}</Component.Trigger>
            <Component.Content>
              <Component.Item value="1">item 1</Component.Item>
              <Component.Item value="2">item 2</Component.Item>
              <Component.Item value="3">item 3</Component.Item>
              <Component.Item value="4">item 4</Component.Item>
            </Component.Content>
          </Component>
        );
      };
    
      // Act
      render(<ComponentSample />);
    
      // Open the dropdown
      const selectElementTrigger = screen.getByTestId("select");
      fireEvent.pointerDown(
        selectElementTrigger,
        new MockPointerEvent("pointerdown", {
          ctrlKey: false,
          button: 0,
        })
      );
    
      // Select the item
      const selectedOption = await waitFor(() => screen.findByText("item 2"));
      fireEvent.click(selectedOption);
    });
    

    Explanation:

    With these changes, I was able to run my tests successfully.