24

So I'm having a hard time writing tests for a modal component using React fiber's portal. Because my modal mounts to a domNode on the root of the <body /> but because that domNode doesn't exist, the test fails.

Some code to give, context:

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
    <title>React App</title>
  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="modal-root"></div>
    <div id="root"></div>
  </body>
</html>

App.js

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import { Modal, ModalHeader } from './Modal';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = { show: false };
    this.toggleModal = this.toggleModal.bind(this);
  }

  toggleModal(show) {
    this.setState({ show: show !== undefined ? show : !this.state.show });
  }

  render() {
    return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h1 className="App-title">Welcome to React</h1>
        </header>
        <p className="App-intro">
          To get started, edit <code>src/App.js</code> and save to reload.
        </p>
        <button onClick={() => this.toggleModal()}>show modal</button>
        <Modal toggle={this.toggleModal} show={this.state.show}>
          <ModalHeader>
            <span>I'm a header</span>
            <button onClick={() => this.toggleModal(false)}>
              <span aria-hidden="true">&times;</span>
            </button>
          </ModalHeader>
          <p>Modal Body!!!</p>
        </Modal>
      </div>
    );
  }
}

export default App;

Modal.js

import React, { Fragment } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
// the next components are styled components, they are just for adding style no logic at all
import {
  ModalBackdrop,
  ModalContent,
  ModalDialog,
  ModalWrap,
} from './components';

class Modal extends React.Component {
  constructor(props) {
    super(props);
    this.el = document.createElement('div');
    this.modalRoot = document.getElementById('modal-root');
    this.outerClick = this.outerClick.bind(this);
  }

  componentDidMount() {
    this.modalRoot.appendChild(this.el);
    this.modalRoot.parentNode.style.overflow = '';
  }

  componentWillUpdate(nextProps) {
    if (this.props.show !== nextProps.show) {
      this.modalRoot.parentNode.style.overflow = nextProps.show ? 'hidden' : '';
    }
  }

  componentWillUnmount() {
    this.props.toggle(false);
    this.modalRoot.removeChild(this.el);
  }

  outerClick(event) {
    event.preventDefault();
    if (
      event.target === event.currentTarget ||
      event.target.nodeName.toLowerCase() === 'a'
    ) {
      this.props.toggle(false);
    }
  }

  render() {
    const ModalMarkup = (
      <Fragment>
        <ModalBackdrop show={this.props.show} />
        <ModalWrap show={this.props.show} onClick={this.outerClick}>
          <ModalDialog show={this.props.show}>
            <ModalContent>{this.props.children}</ModalContent>
          </ModalDialog>
        </ModalWrap>
      </Fragment>
    );
    return ReactDOM.createPortal(ModalMarkup, this.el);
  }
}

Modal.defaultProps = {
  show: false,
  toggle: () => {},
};

Modal.propTypes = {
  children: PropTypes.node.isRequired,
  show: PropTypes.bool,
  toggle: PropTypes.func,
};

export default Modal;

And last but not least the test: Modal.test.js

import React from 'react';
import Modal from './Modal.component';
import {
  ModalBackdrop,
  ModalContent,
  ModalDialog,
  ModalWrap,
} from './components';

describe('Modal component', () => {
  const Child = () => <div>Yolo</div>;

  it('should render all the styled components and the children', () => {
    const component = mount(
      <Modal>
        <Child />
      </Modal>
    );
    expect(component.find(ModalBackdrop).exists()).toBeTruthy();
    expect(component.find(ModalWrap).exists()).toBeTruthy();
    expect(component.find(ModalWrap).contains(ModalDialog)).toBeTruthy();
    expect(component.find(ModalDialog).contains(ModalContent)).toBeTruthy();
    expect(component.find(ModalContent).contains(Child)).toBeTruthy();
  });
});

A codesandbox so you can see it in action

Fabio Antunes
  • 22,251
  • 15
  • 81
  • 96

3 Answers3

47

So after a lot of fighting, experiment and hope. I managed to get the test working, the secret, which is kind obvious after I finally remember that is a possibility, is to modify jsdom and add our domNode, we just can't forget to unmount the component after each test.

Modal.test.js

import React from 'react';
import { mount } from 'enzyme';
import Modal from './Modal.component';
import {
  ModalBackdrop,
  ModalContent,
  ModalDialog,
  ModalWrap,
} from './components';

describe('Modal component', () => {
  const Child = () => <div>Yolo</div>;
  let component;

  // add a div with #modal-root id to the global body
  const modalRoot = global.document.createElement('div');
  modalRoot.setAttribute('id', 'modal-root');
  const body = global.document.querySelector('body');
  body.appendChild(modalRoot);

  afterEach(() => {
    component.unmount();
  });

  it('should render all the styled components and the children', () => {
    component = mount(
      <Modal>
        <Child />
      </Modal>,
    );
    expect(component.find(ModalBackdrop).exists()).toBeTruthy();
    expect(component.find(ModalWrap).exists()).toBeTruthy();
    expect(component.find(ModalWrap).contains(ModalDialog)).toBeTruthy();
    expect(component.find(ModalDialog).contains(ModalContent)).toBeTruthy();
    expect(component.find(ModalContent).contains(Child)).toBeTruthy();
  });

  it('should trigger toggle when clicked', () => {
    const toggle = jest.fn();
    component = mount(
      <Modal toggle={toggle}>
        <Child />
      </Modal>,
    );

    component.find(ModalWrap).simulate('click');
    expect(toggle.mock.calls).toHaveLength(1);
    expect(toggle.mock.calls[0][0]).toBeFalsy();
  });

  it('should mount modal on the div with id modal-root', () => {
    const modalRoot = global.document.querySelector('#modal-root');
    expect(modalRoot.hasChildNodes()).toBeFalsy();

    component = mount(
      <Modal>
        <Child />
      </Modal>,
    );

    expect(modalRoot.hasChildNodes()).toBeTruthy();
  });

  it('should clear the div with id modal-root on unmount', () => {
    const modalRoot = global.document.querySelector('#modal-root');

    component = mount(
      <Modal>
        <Child />
      </Modal>,
    );

    expect(modalRoot.hasChildNodes()).toBeTruthy();
    component.unmount();
    expect(modalRoot.hasChildNodes()).toBeFalsy();
  });

  it('should set overflow hidden on the boddy element', () => {
    const body = global.document.querySelector('body');
    expect(body.style.overflow).toBeFalsy();

    component = mount(
      <Modal>
        <Child />
      </Modal>,
    );

    component.setProps({ show: true });

    expect(body.style.overflow).toEqual('hidden');

    component.setProps({ show: false });
    expect(body.style.overflow).toBeFalsy();
  });
});

One big small thing, is that enzyme doesn't have full support for react 16 yet, github issue. And theoretically all tests should pass, but they were still failing the solution was to change the wrapper on the modal, instead of using <Fragment /> we need to use the old plain <div />

Modal.js render method:

render() {
    const ModalMarkup = (
      <div>
        <ModalBackdrop show={this.props.show} />
        <ModalWrap show={this.props.show} onClick={this.outerClick}>
          <ModalDialog show={this.props.show}>
            <ModalContent>{this.props.children}</ModalContent>
          </ModalDialog>
        </ModalWrap>
      </div>
    );
    return ReactDOM.createPortal(ModalMarkup, this.el);
  }

You can find a repo with all the code here

Fabio Antunes
  • 22,251
  • 15
  • 81
  • 96
  • 2
    Wow, thanks for that, my salvation after banging my head against a wall for quite a while :) Works great. – Jannis Jan 12 '18 at 04:51
  • Thanks a lot, this saved me what would probably be a few more hours. One question though, why would one need to unmount each component after each test? Do they stack up in the DOM until you explicitly unmount them? Wouldn't the same happen regardless of appending `div#modal-root` to it? – Herick Aug 21 '18 at 21:35
  • 1
    @Herick yep, so basically every time you mount the modal it will get appended to the `#modal-root` so if you have 5 tests at the end you will have 5 `Modal` components stacked. That's why we need to unmount after each test – Fabio Antunes Aug 22 '18 at 11:42
  • That would make sense. Unfortunately in my case it turned out to have nothing to do with `Modal`. The culprit was a `CSSTransition` from `react-transition-group` with a similar behaviour. – Herick Aug 22 '18 at 19:44
6

This can be simply tested by mocking the createPortal method.

ReactDOM.createPortal = jest.fn(modal => modal);

let wrapper = shallow(
    <Modal visible={true}>Text</Modal>
);

expect(wrapper).toMatchSnapshot();
vsync
  • 118,978
  • 58
  • 307
  • 400
Ashvin777
  • 1,446
  • 13
  • 19
1

For anyone having issues with the react testing library, this worked for me:

Modal.tsx

const domElement = React.useRef(document.getElementById('modal'));
const jsx = (<Modal>...</Modal>);
return ReactDOM.createPortal(jsx, domElement.current as HTMLElement);

Modal.test.tsx

const element = document.createElement('div');
element.setAttribute('id', 'modal');
element.setAttribute('data-testid', 'modal-test-id');

jest
    .spyOn(ReactDOM, 'createPortal')
    .mockImplementation((children, c, key) => {
        const symbol = Symbol.for('react.portal');
        return {
            $$typeof: symbol,
            key: key == null ? null : '' + key,
            children,
            containerInfo: element,
            implementation: null,
            type: symbol.description,
            props: null,
        } as ReactPortal;
    });

I had to dig into the react-dom library to see how to implement the createPortal method in my mock, because it wouldn't allow me to return just any object, it had to be a ReactPortal object.

Sources

The symbol was needed to determine what type of implementation to use to create the element and passing in the symbol for that helped. Please note, the containerInfo is where you pass in an element that you can work with within the tests, So that you don't have to try and include the entire App module.

Meggie
  • 11
  • 3