How to Test React Portals in React
React Portals provide a way to render children into a DOM node that exists outside the DOM hierarchy of the parent component, which is commonly used for modals, tooltips, and dropdowns. Testing these portals can seem challenging because they break the traditional component tree structure, but with the right testing strategy, it is straightforward. This article provides a clear, step-by-step guide on how to test React Portals using Jest and React Testing Library (RTL), covering DOM setup, rendering assertions, and clean-up practices.
Understanding the Test Setup
When testing components that use ReactDOM.createPortal,
the rendered output is injected into a DOM node outside of the main test
container. React Testing Library renders components into a dynamically
created div appended to document.body. Because
React Testing Library’s queries (like screen.getByText)
search the entire document.body by default, finding
elements rendered inside a portal is highly intuitive.
If your portal targets a specific container, such as
<div id="portal-root"></div>, you must ensure
this element exists in the virtual DOM environment (JSDOM) before your
tests run.
Code Example: The Portal Component
Consider a simple modal component that renders its content into a portal root:
import React from 'react';
import ReactDOM from 'react-dom';
const Modal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
const portalRoot = document.getElementById('portal-root') || document.body;
return ReactDOM.createPortal(
<div role="dialog">
<button onClick={onClose}>Close</button>
{children}
</div>,
portalRoot
);
};
export default Modal;Writing the Test
To test this component, you must create the target container in your
test setup if your component relies on a specific element ID like
portal-root.
Here is how to write the test suite using React Testing Library and Jest:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Modal from './Modal';
describe('Modal Portal Component', () => {
let portalRoot;
beforeEach(() => {
// Create the portal container and append it to the body
portalRoot = document.createElement('div');
portalRoot.setAttribute('id', 'portal-root');
document.body.appendChild(portalRoot);
});
afterEach(() => {
// Clean up the DOM to prevent test cross-contamination
document.body.removeChild(portalRoot);
});
test('renders modal content inside the portal when open', () => {
render(
<Modal isOpen={true} onClose={jest.fn()}>
<div>Modal Content</div>
</Modal>
);
// Verify the portal content is accessible in the document
const modalContent = screen.getByText('Modal Content');
expect(modalContent).toBeInTheDocument();
// Verify it is rendered inside the correct container
expect(portalRoot).toContainElement(modalContent);
});
test('calls onClose when the close button is clicked', () => {
const handleClose = jest.fn();
render(
<Modal isOpen={true} onClose={handleClose}>
<div>Modal Content</div>
</Modal>
);
const closeButton = screen.getByRole('button', { name: /close/i });
fireEvent.click(closeButton);
expect(handleClose).toHaveBeenCalledTimes(1);
});
test('does not render anything when isOpen is false', () => {
render(
<Modal isOpen={false} onClose={jest.fn()}>
<div>Modal Content</div>
</Modal>
);
const modalContent = screen.queryByText('Modal Content');
expect(modalContent).not.toBeInTheDocument();
});
});Key Testing Practices
- Manage the DOM Lifecycle: Always clean up
dynamically created DOM elements in the
afterEachblock. Leaving elements in thedocument.bodycan leak into subsequent tests, causing unexpected failures and false positives. - Use Global Queries: Use
screenqueries to locate elements. Since portals are mounted outside the default component mount wrapper, scoping queries to the render container (e.g.,container.querySelector) will fail to find the portal content. - Fallback Containers: In your component code,
provide a fallback like
document.bodyif the target element does not exist. This keeps your component resilient and makes basic rendering tests easier to run without heavy DOM setup.