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:
When modal is visible:
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
);
};
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
- Update Redux state necessary for the test.
- Render
<App/>
component and pass a particular route (in this case, a path to dashboard page). - Ensure the page is rendered properly.
- Click a button that opens a modal.
- 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:
- 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();
});
});
After this change, I could now see the modal correctly rendered in the test:
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)