React-testing-library: How to test React.Portal

Created on 20 Apr 2018  路  29Comments  路  Source: testing-library/react-testing-library

react-testing-library: 2.1.1
node: 8.9.3
yarn: 1.6.0

import React from "react";
import { render, Simulate } from "react-testing-library";

import Button from "material-ui/Button";
import Dialog, {
  DialogActions,
  DialogContent,
  DialogTitle
} from "material-ui/Dialog";

export const CommonDialog = props => {
  const { body, children, hideModal, isVisible, title } = props;
  return (
    <Dialog open={isVisible}>
      <DialogTitle>{title}</DialogTitle>
      <DialogContent>
        {body}
        {children}
      </DialogContent>
      <DialogActions>
        <Button id="close" onClick={hideModal}>
          Close
        </Button>
      </DialogActions>
    </Dialog>
  );
};

test("render dialog", () => {
  const mockCallback = jest.fn();
  const { getByText, getByTestId, container } = render(
    <CommonDialog title="test" isVisible={true} hideModal={mockCallback} />
  );
  Simulate.click(getByText("Close"));
  expect(mockCallback).toBeCalled();
  expect(container).toMatchSnapshot();
});

in the snapshot, it is just a simple div, and the Close button could not be found. It is not immediately not obvious what's went wrong here.
I was using enzyme and it is working fine.

Most helpful comment

The problem is that when the code let portalRoot = document.getElementById("portal") is run, there is no element in the document with the ID of portal so portalRoot is null.

You can create it with:

const portalRoot = document.createElement('div')
portalRoot.setAttribute('id, 'portal')
document.body.appendChild(portalRoot)

There are various ways you can do this (in a test setup file, or in a test beforeAll which would require a slight modification to your component to query for that element within the lifecycles).

What I would do personally that would solve this is in your component file, do this:

let portalRoot = document.getElementById("portal")
if (!portalRoot) {
  portalRoot = document.createElement('div')
  portalRoot.setAttribute('id, 'portal')
  document.body.appendChild(portalRoot)
}

This would also mean that you don't have to have the HTML in your app with the portal element ahead of time which reduces the chance of this error happening in production 馃憣

Good luck!

All 29 comments

checked source code, Dialog uses Portal so maybe thats why?

was able to workaround with { container: document.body } passed into render

Glad you found a workaround 馃憤

Make sure to unmount at the end of your test to clean up the document.body

Hey, this solve my problem while test a Component using reactstrap modal. I am wondering if there is a way to supress warnings, though.

image

test("render dialog", () => {
    //  Disable Warning: render(): Rendering components directly into document.body is discouraged.
    const console = global.console;
    global.console = { error: jest.fn() };
    const { container, unmount } = render(
                 <Component />,
        { container: document.body },
    );
    unmount();
    global.console = console;
});

Thanks @bugzpodder. Worked!

I would not recommend that solution. That's just hiding the problem and it's kinda annoying boilerplate. Here's another example of how to test a portal: https://github.com/kentcdodds/react-testing-library-course/blob/8069bf725dc0dd3774c39f7b8c5a3b226d2f06d0/src/__tests__/06.js

Rather than using render with the container as document.body, just use renderIntoDocument and it'll add a new div into the document.body which will avoid the error.

ahmmm...I can see now why I had never got renderIntoDocument to work..
I was trying to do something like this:

...

const { getByTestId, unmount } = renderIntoDocument(<App /);

But, as I can see in your example, the getByTestId came from bindElementToQueries function:

import { bindElementToQueries } from 'dom-testing-library';

...

const { getByTestId } = bindElementToQueries(document.body);
const { unmount } = renderIntoDocument(<App /);
....
unmount();

This works as well.
Thanks

I struggle to find an accessible example on testing portals with react-testing-library. Can you guys help me?

I have the following code:

let portalRoot = document.getElementById("portal")

export default class Modal extends React.Component {
  constructor(props) {
    super(props)
    this.el = document.createElement("div")
  }

  componentDidMount() {
    portalRoot.appendChild(this.el)
  }

  componentWillUnmount() {
    portalRoot.removeChild(this.el)
  }

  render() {
    let {children, toggle, on, modalBgClass = "", modalWindowClass = ""} = this.props
    return on && ReactDOM.createPortal(
      <Background modalBgClass={modalBgClass} toggle={toggle}>
        <div className={cc("window", modalWindowClass)}>
          <div className="close">
            <i className="icon fa fa-times" aria-label="Close" onClick={toggle}></i>
          </div>
          {children}
        </div>
      </Background>
    , this.el)
  }
}

which breaks with TypeError: Cannot read property 'appendChild' of null on portalRoot.appendChild(this.el). In original HTML I had:

<div id="root"></div>
<div id="portal"></div>

How to emulate the same structure or workaround it alternatively with this library?

The problem is that when the code let portalRoot = document.getElementById("portal") is run, there is no element in the document with the ID of portal so portalRoot is null.

You can create it with:

const portalRoot = document.createElement('div')
portalRoot.setAttribute('id, 'portal')
document.body.appendChild(portalRoot)

There are various ways you can do this (in a test setup file, or in a test beforeAll which would require a slight modification to your component to query for that element within the lifecycles).

What I would do personally that would solve this is in your component file, do this:

let portalRoot = document.getElementById("portal")
if (!portalRoot) {
  portalRoot = document.createElement('div')
  portalRoot.setAttribute('id, 'portal')
  document.body.appendChild(portalRoot)
}

This would also mean that you don't have to have the HTML in your app with the portal element ahead of time which reduces the chance of this error happening in production 馃憣

Good luck!

For my case, I can set my portal component's mount point different than document.body via component props, but I don't know how to specify the container before calling render.

Is it possible to expect the created root div has some special properties for querying?

I would not recommend that solution. That's just hiding the problem and it's kinda annoying boilerplate. Here's another example of how to test a portal: https://github.com/kentcdodds/react-testing-library-course/blob/8069bf725dc0dd3774c39f7b8c5a3b226d2f06d0/src/__tests__/06.js

@kentcdodds Thank you very much for the link and sample code how 'createPortal' can be tested. I'd like to ask how we can test functional component which contains 'createPortal'. Thanks!!!

The test would be exactly the same. That's the beauty of react-testing-library being free of implementation details.

was able to workaround with { container: document.body } passed into render

For anyone curious: Specifically with material-ui, this fixed my problem.

For my working example:

const { container, getByText } = render(
      <I18nTestProvider>
        <LanguageSelect />
      </I18nTestProvider>,
      { container: document.body },
    );
    const selectComponent = container.querySelector('#select-language');

    act(() => {
      fireEvent.click(selectComponent);
    });

    await wait(() => getByText('French'));

    expect(container).toMatchSnapshot();

What is the recommended way to test portals now? Passing { container: document.body } to render options triggers a warning, and renderIntoDocument is deprecated.

I'm wondering the same as above. I'm using material-ui v4 and the { container: document.body } does not appear to be working as expected. To be more specific, I'm rendering a Popover component, and when it is open, I just get the following with root.debug():

<body>
  <div />
</body>

Why do we render into document.body? To tell react-testing-library to give back document.body. IMHO it's easier to render wherever and _then_ look for the portalled component in the body. Here's an example with a snapshot:

import { render } from 'react-testing-library';

import { MyPortalledComponent } from '..';

describe('MyPortalledComponent', () => {
  it('snapshot', () => {
    render(<MyPortalledComponent/>);
    expect(document.body.lastChild).toMatchSnapshot();
  });
});

Why do we render into document.body?

I'm guessing you're asking "why does React Testing Library append the container to the body?" And the answer is because otherwise React's event delegation system would not work and you wouldn't be able to fire DOM events at any of your elements.

You don't need to bother with telling React Testing Library where to find the element. All queries are pre-bound to document.body (because that's where the user is going to look for things) so you can query for stuff that's inside the portal just like you do anything else. There's basically no change with how you test things when you put stuff in a portal.

I was able to get this working the way I expected by snapshotting the baseElement rather than the container. By default only the trigger button appeared in the container snapshot, but I wanted the dialog's contents to be snapshotted too.

  // Note: You can't use expect(container).toMatchSnapshot() if you
  // wish to include the dialog's contents in your snapshot
  expect(baseElement).toMatchSnapshot()

Full example with working code sandbox:

https://codesandbox.io/s/react-testing-library-examples-pxmj7?fontsize=14&module=%2Fsrc%2F__tests__%2Fmaterial-dialog.js

import React from 'react'
import {
  Button,
  Dialog,
  DialogContent,
  DialogContentText,
  DialogActions,
} from '@material-ui/core'
import {render, fireEvent} from '@testing-library/react'
import '@testing-library/react/cleanup-after-each'
import 'jest-dom/extend-expect'

const MaterialDialog = () => {
  const [open, setOpen] = React.useState(false)

  function handleClickOpen() {
    setOpen(true)
  }

  function handleClose() {
    setOpen(false)
  }

  return (
    <React.Fragment>
      <Button
        variant="contained"
        color="primary"
        onClick={handleClickOpen}
        data-testid="open-dialog"
      >
        Open dialog
      </Button>
      <Dialog open={open} onClose={handleClose} fullWidth={true} maxWidth="md">
        <DialogContent>
          <DialogContentText data-testid="dialog-message">
            Hello from inside the dialog!
          </DialogContentText>
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary" autoFocus>
            OK
          </Button>
        </DialogActions>
      </Dialog>
    </React.Fragment>
  )
}

test('material dialog button can be interacted with to show a message', () => {
  const {baseElement, getByTestId} = render(<MaterialDialog />)

  fireEvent.click(getByTestId('open-dialog'))

  // Asserting content within the dialog after being opened
  expect(getByTestId('dialog-message')).toHaveTextContent(
    'Hello from inside the dialog!',
  )

  // Note: You can't use expect(container).toMatchSnapshot() if you
  // wish to include the dialog's contents in your snapshot
  expect(baseElement).toMatchSnapshot()
})

@ynotdraw did you find a solution to this? I am also having this issue with Material UI using React hooks and the latest version of @testing-library/react

  it('renders UI common correctly', () => {
    const { getByText, debug } = render(withDependencies(<CustomDialog {...defaultProps} />));
    debug();
  });

debug shows an empty div

    <body>
      <div />
    </body>

We had a wrapper over the Material Dialog that used withMobileDialog and that was the reason why snapshots did't display custom dialog content

These questions are best answered on our spectrum.chat.

I think the Select + Dialog integration test and Dialog unit test on the Material-UI repository are some good ressources if you want a deep dive into Material-UI + testing-library testing
(Material-UI wrapper around render for reference). Except testing backdrop clicks only byRole queries are used in those tests.

I don't use snapshot testing for React trees or DOM trees anymore so I can't give any advice on that.

@ArtemAstakhov - thanks for that insight! Our team had the same problem that @aemc noted above, where debug was printing only an empty div inside body.

We spent a good amount of time researching why we weren't able to access content inside our Dialog components (built on top of the MUI Dialog and wrapped with withMobileDialog()) with Jest and RTL. Suggestions for testing React.Portals and such didn't help, but only after looking specifically into the MUI withMobileDialog issue did we find some solutions.

This issue lead us to this page in the MUI docs, and implementing that workaround allowed us to fully test our Dialogs.

What I would do personally that would solve this is in your component file, do this:

let portalRoot = document.getElementById("portal")
if (!portalRoot) {
  portalRoot = document.createElement('div')
  portalRoot.setAttribute('id, 'portal')
  document.body.appendChild(portalRoot)
}

Awww yeah this worked for me.

import React from 'react'
import { RenderResult, render, waitFor } from '@testing-library/react'
import ButtonBeAPartner from '~/components/ButtonBeAPartner'
import Design, { Modal } from '~/components/Design'

describe('components/ButtonBeAPartner', () => {
  let wrapper: RenderResult

  beforeEach(() => {
    wrapper = render(
      <Design>
        <ButtonBeAPartner />
        <Modal>
          <span>Hi!</span>
        </Modal>
      </Design>
    )

    waitFor(() => null)
  })

  describe('when rendering', () => {
    it('the modal does not exist in the document', () => {
      expect(wrapper.queryAllByTestId('modal')).toHaveLength(0)
    })

    describe('when click the button', () => {
      it('the modal appears', () => {
        wrapper.getByTestId('button-be-a-partner').click()

        expect(wrapper.getByTestId('modal')).toBeInTheDocument()
      })
    })
  })
})

I have found following solution:

const { baseElement } = render(<Modal />);

// Snapshot
expect(baseElement).toMatchSnapshot();

// Query element
const modal = getQueriesForElement(baseElement).queryByTestId('modal');
expect(modal.innerText).toBe('Modal content');

Resulting snapshot:

<body>
 <div>
    <div data-testid='modal'>
      Modal content
    </div>
  </div>
</body>

For people getting

<body>
  <div />
</body>

For me, It had nothing to do with portal and everything to do with <Route />. Make sure you are on the url your component needs to render by passing initialEntries={['/route']} to <MemoryRouter />.

Was this page helpful?
0 / 5 - 0 ratings