Testing React Portals: A Real-Life Example for testing a modal
Tags: react, testing, webdev
Published: 2023-07-20 17:16:53

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rz4e2srn1w9pqo9qin7r.png)

When modal is visible:

![browser element tab](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qafqd6lq7wmm8nzbkui8.png)

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

If you want to explore more, visit [this page](https://react.dev/reference/react-dom/createPortal) 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
  );
};
```
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;
```

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();

  });
});
```
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: 

> 5. 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](https://github.com/testing-library/react-testing-library/issues/62) 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();
  });
});

```

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

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6qu2rc9b556wkr8fd77r.png)

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!