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.
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,
});
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"));
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! 🙏
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:
PointerEvent
and other methodsexport 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();
Select
componentimport { 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);
});
MockPointerEvent
: This class simulates pointer events (like clicks) and resolves the issue of the missing PointerEvent
in the test environment.scrollIntoView
, hasPointerCapture
, and releasePointerCapture
are mocked to prevent any runtime errors during testing.Select
component using portals.With these changes, I was able to run my tests successfully.