DEV Community

Cover image for Testing React Portals: A Real-Life Example for testing a modal
mihomihouk
mihomihouk

Posted on

Testing React Portals: A Real-Life Example for testing a modal

Introduction

In React development, a portal is like a secret passage that lets you render components outside the usual DOM setup.

In this article, we'll dive into portals and how to test it properly with Jest/react-testing-library with my real example.

What is portal and when to use it

A portal is a mechanism that allows you to render components outside of the normal DOM hierarchy.

Imagine you want to render components like modals, dialogs, and tooltips without letting them affected by the position or styling of their parent elements. Placing the component in a portal works perfectly in this scenario while still allowing the component to consume and update context shared in the normal DOM tree.

When modal is hidden:

browser element tab

When modal is visible:

browser element tab

As you can see, the modal rendered into a portal is added outside of root!!

If you want to explore more, visit this page on React's website.

Custom modal as an example

Before discussing testing a portal component, I'll share how I set up my modal component.

import classNames from 'classnames';
import React from 'react';
import ReactDOM from 'react-dom';

interface ModalProps {
  children?: React.ReactNode;
  onDismiss?: () => void;
  className?: string;
}

export const Modal: React.FC<ModalProps> = ({
  children,
  onDismiss,
  className
}) => {
  React.useEffect(() => {
    const handleEscKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        onDismiss && onDismiss();
      }
    };
    document.querySelector('body')?.classList.add('overflow-hidden');
    document.addEventListener('keydown', handleEscKey);

    return () => {
      document.querySelector('body')?.classList.remove('overflow-hidden');
      document.removeEventListener('keydown', handleEscKey);
    };
  }, [onDismiss]);

  return ReactDOM.createPortal(
    <div
      className="flex flex-col items-center fixed bottom-0 right-0 left-0 top-0 z-[1000] bg-[#1e1e1e99]"
      onClick={() => onDismiss?.()}
      data-testid="modal"
    >
      <div
        className={classNames(
          className,
          'sm:w-[600px] sm:h-[500px] md:w-[700px] md:h-[600px] absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-2xl bg-gray-800'
        )}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
};
Enter fullscreen mode Exit fullscreen mode

I used createPortal API provided by React. Following the syntax createPortal(children, domNode, key?), I pass my modal component as the first parameter and document.body as a place I want to render the modal to the API.

App.tsx

import React from 'react';
import './index.css';
import { ModalsContainer } from './container/modals-container';
import { AppRoutes } from './routes/AppRoutes';

function App() {
  return (
    <>
      <AppRoutes />
      <ModalsContainer />
    </>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

And I placed a group of modals (<ModalsContainer />) as well as other components (<AppRoutes />) in App component.

Test modal

Sorry to keep you waiting for so long. We can finally talk about how to test a portal.

If you want to skip ahead, please go straight into my final solution. But if you are curious enough, let's look at my testing strategies and the mistake I made first.

What I wanted to test

I wanted to verify that once a button is clicked, the modal opens properly.

Testing strategy

  1. Update Redux state necessary for the test.
  2. Render <App/> component and pass a particular route (in this case, a path to dashboard page).
  3. Ensure the page is rendered properly.
  4. Click a button that opens a modal.
  5. Ensure the expected modal is successfully rendered by checking specific content inside the modal.

Initial code (don't do this)

To test the above step, I initially created the test code below:

import { renderWithProviders } from '../../util/test';
import '@testing-library/jest-dom';
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { setupStore } from '../../store/store';
import { showModal } from '../../slices/modals-slice';
import { ModalType } from '../../interfaces/modal-type';
import App from '../../App';
import { setIsAuthenticated, setUser } from '../../slices/auth-slice';
import { mockUser } from '../../mocks/user';
import userEvent from '@testing-library/user-event';
import { server } from '../../mocks/server';
import { rest } from 'msw';
import config from '../../config';

const baseURL = config.apiUrl;

describe('Modal component', () => {
  it('should render modal when prompted', async () => {
    server.use(
      rest.get(`${baseURL}/post/get-posts`, (req, res, ctx) => {
        return res(ctx.json([]));
      })
    );
    const store = setupStore();
    store.dispatch(setIsAuthenticated(true));
    store.dispatch(setUser(mockUser));
    renderWithProviders(<App />, { store, initialRoutes: ['/dashboard'] });

    await waitForElementToBeRemoved(() => screen.queryByTestId('main loader'));
    expect(
      await screen.findByText('Create a new post to get started.')
    ).toBeInTheDocument();

    userEvent.click(screen.getByTestId('create-post-btn'));

    expect(screen.getByText('Create new post')).toBeInTheDocument();

  });
});
Enter fullscreen mode Exit fullscreen mode

After running this test, I encountered the following error:

TestingLibraryElementError: Unable to find an element with the text: Create new post. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

Oh nooooo!

I could ensure that all the testing steps were completed except for the last part:

  1. Ensure an expected modal is successfully rendered by getting a text inside the modal.

React testing library fails to find a text inside the modal after clicking a button to open it.

Final code (definitely try this!)

Don't worry, this article has a happy ending.

After searching for a workaround, I found this issue thread on the React-testing-library Github page, which gave me a clear solution.

I applied it in my testing code...and ta-da!

Now I could see my test pass successfully!

Here is the testing code:

import { renderWithProviders } from '../../util/test';
import '@testing-library/jest-dom';
import {
  getQueriesForElement,
  screen,
  waitForElementToBeRemoved
} from '@testing-library/react';
import { setupStore } from '../../store/store';
import App from '../../App';
import { setIsAuthenticated, setUser } from '../../slices/auth-slice';
import { mockUser } from '../../mocks/user';
import userEvent from '@testing-library/user-event';
import { server } from '../../mocks/server';
import { rest } from 'msw';
import config from '../../config';

const baseURL = config.apiUrl;

describe('Modal component', () => {
  it('should render modal when prompted', async () => {
    server.use(
      rest.get(`${baseURL}/post/get-posts`, (req, res, ctx) => {
        return res(ctx.json([]));
      })
    );
    const store = setupStore();
    store.dispatch(setIsAuthenticated(true));
    store.dispatch(setUser(mockUser));
    const { baseElement } = renderWithProviders(<App />, {
      store,
      initialRoutes: ['/dashboard']
    });

    await waitForElementToBeRemoved(() => screen.queryByTestId('main loader'));
    expect(
      await screen.findByText('Create a new post to get started.')
    ).toBeInTheDocument();

    userEvent.click(screen.getByTestId('create-post-btn'));

    const modal = getQueriesForElement(baseElement).queryByTestId('modal');
    expect(modal).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

After this change, I could now see the modal correctly rendered in the test:

Image description

Mr.portal, I wanted to see you for ages!

Why baseElement instead of screen

But why do we need to use baseElement?

When you use screen, it only looks inside the subcomponents of a component passed to render function. In my example, it looks into <App/> and its children but not what sits outside <App/> component.

On the other hand, baseElement points to the entire rendered components, which include <App/> and modals that are rendered in a portal.

That is why the test failed with screen but succeeded with baseElement.

Caution

There are other ways to render a modal in React applications and each method requires different approaches to test the modal component.

So it is always important to be aware of where and how the modal you are testing is rendered and tailor your test accordingly!

Conclusion

Knowing how to test components rendered in portals is definitely useful given that many applications have designs where important user interactions take place within these components.

Thanks for reading this article.

See ya!

Top comments (0)