Testing React portals with enzyme

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 method on line 110: https://github.com/facebook/react/blob/master/packages/react-dom/src/client/ReactDOM.js
  • Calls another method in react-reconciler here: https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactPortal.js

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.


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

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