Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to test React.Portal #62

Closed
bugzpodder opened this issue Apr 20, 2018 · 41 comments
Closed

How to test React.Portal #62

bugzpodder opened this issue Apr 20, 2018 · 41 comments
Labels
question Further information is requested

Comments

@bugzpodder
Copy link

bugzpodder commented Apr 20, 2018

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.

@bugzpodder
Copy link
Author

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

@bugzpodder
Copy link
Author

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

@bugzpodder bugzpodder changed the title Empty component snapshot How to test React.Portal Apr 20, 2018
@kentcdodds
Copy link
Member

Glad you found a workaround 👍

@kentcdodds
Copy link
Member

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

@ericmaicon
Copy link

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

@bugzpodder
Copy link
Author

bugzpodder commented May 11, 2018

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

@ericmaicon
Copy link

Thanks @bugzpodder. Worked!

@kentcdodds
Copy link
Member

kentcdodds commented May 11, 2018

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.

@ericmaicon
Copy link

ericmaicon commented May 11, 2018

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

@ivan-kleshnin
Copy link

ivan-kleshnin commented Nov 14, 2018

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?

@kentcdodds
Copy link
Member

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!

julienw pushed a commit to julienw/react-testing-library that referenced this issue Dec 20, 2018
@KagamiChan
Copy link

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?

@marekdano
Copy link

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!!!

@kentcdodds
Copy link
Member

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

@Michael-M-Judd
Copy link

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

@ahacop
Copy link

ahacop commented Jun 20, 2019

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

@ynotdraw
Copy link

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>

@kentcdodds
Copy link
Member

Here's how I recommend writing/testing modals/portals: https://codesandbox.io/s/github/kentcdodds/react-testing-library-examples/tree/master/?fontsize=14&module=%2Fsrc%2F__tests__%2Fportals.js

@lorem--ipsum
Copy link

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

@kentcdodds
Copy link
Member

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.

@AlanFoster
Copy link

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

@aemc
Copy link

aemc commented Jul 20, 2019

@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>

@ArtemAstakhov
Copy link

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

@eps1lon
Copy link
Member

eps1lon commented Nov 12, 2019

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.

@anlawyer
Copy link

@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.

@trevorglick
Copy link

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.

@jefferson-william
Copy link

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

@RobinWijnant
Copy link

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>

mkraenz added a commit to mkraenz/you-are-awesome-app that referenced this issue Sep 17, 2020
Reason: the portal results in render to return null . Compare testing-library/react-testing-library#62
@smhmd
Copy link

smhmd commented Nov 20, 2020

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 />.

@kyymichelle
Copy link

You can also access it via screen

import { screen } from '@testing-library/dom';

const modal = screen.getByTestId('test-modal');
expect(modal).toBeTruthy();

@TrejGun
Copy link

TrejGun commented Mar 11, 2021

@eps1lon what do you think of this solution

import React from "react";
import {render, cleanup} from "@testing-library/react";
import {Dialog} from "@material-ui/core";


afterEach(cleanup);

describe("<Dialog />", () => {
  it("renders component", () => {
    const container = document.createElement("div");
    document.body.append(container)

    const {asFragment} = render(
      <Dialog open={true} container={container} />,
      {container}
    );

    expect(asFragment()).toMatchSnapshot();
  });
});

@codepath2019
Copy link

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!

Thank you for this suggestion. This solved my issue. 🎸

@nickserv nickserv added the question Further information is requested label Mar 17, 2021
@minevala
Copy link

This thread has helped me a lot 👍 I decided to:

  • compare the snapshot with baseElement (including the document.body was useful in the end)
  • access the dialog with screen.queryByRole('dialog') whenever needed

@pavellishin
Copy link

These questions are best answered on our spectrum.chat.

I disagree; that page is now a 404, and all of the questions and answers that were presumably there are gone, and not in Google's index. Apparently the new home is Discord, which faces a similar problem - this issue has solved my problem today pretty quickly, and presumably many other people's - but if it only existed in Discord or Slack or something else, it would be completely undiscoverable, and would take much longer for any given person to find their answer, and would require people to actively take time out to help.

@paulo-campos
Copy link

paulo-campos commented Jun 25, 2021

In my case, I solved it in a much simpler way:

Component:
ReactDOM.createPortal(<Modal />, document.body);

Spec:

render(<Modal opened />);
expect(document.body.lastElementChild).toMatchSnapshot();

@gabyperezg
Copy link

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.

Is this still recommended solution, because in another thread i saw this was deprecated.

@asifsaho
Copy link

asifsaho commented Dec 14, 2021

Tried so many ways from this thread unfortunately Mui Dialog is not being rendered!

@JackUait
Copy link

JackUait commented Feb 1, 2022

Here's how I managed to test portals:

import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';

it('shows hint on hover', async () => {
	const children = 'children';
    const hintBody = 'hint body';

	render(<Hint text={hintBody}>{children}</Hint>)

    const hint = screen.getByText('your text goes here')
    await userEvent.hover(hint)

    await screen.findByText(hintBody)
})

P.S.
I've used userEvent in the example but I tested this technique with fireEvent and it works too.

@samboylett
Copy link

This worked for me and was the most simple way I could find:

import {
  getQueriesForElement,
  render,
  RenderResult,
  screen,
} from "@testing-library/react";

describe('tests', () => {
  let result: RenderResult;

  const base = async () => {
    const { baseElement } = result;

    return baseElement instanceof HTMLElement
      ? await getQueriesForElement(baseElement)
      : screen;
  };

  beforeEach(() => {
    result = render(...);
  });
});

then use (await base()) instead of screen

@ToomeyDamien
Copy link

I ran into issues when trying to test the Chakra UI toast which uses a Portal.

I realized that I was missing the <ChakraProvider theme={theme}>...</ChakraProvider> arround my component.

  • code
// TestComponent.tsx
import { useEffect } from "react";
import { useToast } from "@chakra-ui/react";

interface Props {}

export const TestComponent: React.FC<Props> = () => {
  const toast = useToast();

  useEffect(() => {
    toast({
      title: "hello",
      duration: 3000,
      isClosable: true,
      status: "success",
      position: "top-right",
    });
  });

  return <></>;
};
  • test that works
// TestComponent.spec.tsx
import { render, screen } from "@testing-library/react";
import { SaveButton } from "./SaveButton";
import { ChakraProvider, theme } from "@chakra-ui/react";


it("should display toast", () => {
  render(
    <ChakraProvider theme={theme}>
      <TestComponent />
    </ChakraProvider>
  );
  screen.getByText("hello");
});

@jvanbaarsen
Copy link

The thing that worked for us was to use this:

const { baseElement: container }  = render(<YourComponent/>)

You can now access container the same way you would normally, otherwise it tries to grab the first element which is an empty div in the case of a portal element.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests