使用enzyme测试React portals

24

我正在尝试使用React Fiber的portal为模态组件编写测试,但因为我的模态组件挂载到了位于<body />根部的domNode上,但由于该domNode不存在,测试失败了。

以下是一些提供上下文的代码:

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;

模态框.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;

最后但并非最不重要的测试: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();
  });
});

一个CodeSandbox,以便您可以看到它的实际效果。

3个回答

47

经过长时间的奋战、尝试和希望,我终于成功让测试工作了。秘诀其实很显然,我在终于想起这个可能后修改了 jsdom 并添加了我们的 domNode。只是在每次测试后别忘了卸载组件。

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

有一件很小但很重要的事情,就是目前酶不完全支持React 16,Github问题。理论上来说所有的测试应该通过,但还是会失败,解决方法是更改modal的包装器,不再使用<Fragment /> ,而是需要使用旧的<div />

Modal.js 渲染方法:

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

您可以在这里找到包含所有代码的存储库。


2
哇,谢谢你,这真是我的救星,我已经为此苦思冥想了好一阵子 :) 它运行得非常好。 - Jannis
非常感谢,这节省了我可能还需要几个小时的时间。不过有一个问题,为什么每次测试后都需要卸载每个组件?它们会在 DOM 中堆叠直到你明确地卸载它们吗?无论是否将 div#modal-root 添加到其中,情况不都是一样的吗? - Herick
1
@Herick 是的,基本上每次你挂载模态框时,它都会被附加到 #modal-root 上,所以如果你最后有 5 个测试,你将会有 5 个堆叠的 Modal 组件。这就是为什么我们需要在每个测试之后卸载它的原因。 - Fabio Antunes
那很有道理。不幸的是,在我的情况下,它与“Modal”没有任何关系。罪魁祸首是来自react-transition-groupCSSTransition,具有类似的行为。 - Herick

6

可以通过模拟createPortal方法来进行简单测试。

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

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

expect(wrapper).toMatchSnapshot();

这个完美运行了!虽然Typescript抱怨了,但测试仍然运行了。 - C_Z_
5
当使用mount和空快照与浅渲染一起运行时,我会收到“TypeError: Cannot read property 'tag' of null”错误。 - Black Mamba
使用sinonjs,您可以在测试的顶部添加sinon.stub(ReactDOM, "createPortal").callsFake((el) => el as ReactPortal);行并进行常规测试。 - Yunus Emre
@YunusEmre as 不是有效的 JavaScript。 - vsync
@vsync 对于非ts代码,你可以删除as ReactPortal - Yunus Emre

1

对于任何在使用 React 测试库时遇到问题的人,这个方法适用于我:

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

我不得不深入研究react-dom库,以了解如何在我的模拟中实现createPortal方法,因为它不允许我返回任何对象,它必须是一个ReactPortal对象。 来源 符号是必要的,以确定使用哪种实现来创建元素,并传递该符号有所帮助。请注意,containerInfo是您可以在测试中使用的元素,因此您不必尝试包含整个App模块。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接