Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Testing React portals with enzyme

Tags:

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

like image 386
Fabio Antunes Avatar asked Jan 04 '18 11:01

Fabio Antunes


People also ask

Can we use Enzyme and React testing library together?

You can install and use React Testing Library alongside Enzyme, so if you already have a suite of Enzyme tests you can easily create new tests in RTL and keep your existing Enzyme tests.

What is the difference between Enzyme and React testing library?

Enzyme allows you to access the internal workings of your components. You can read and set the state, and you can mock children to make tests run faster. On the other hand, react-testing-library doesn't give you any access to the implementation details.

Should I use Enzyme with Jest?

Jest can be used without Enzyme to render components and test with snapshots, Enzyme simply adds additional functionality. Enzyme can be used without Jest, however Enzyme must be paired with another test runner if Jest is not used.


2 Answers

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

like image 179
Fabio Antunes Avatar answered Dec 23 '22 07:12

Fabio Antunes


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(); 
like image 44
Ashvin777 Avatar answered Dec 23 '22 09:12

Ashvin777