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