Jest测试React组件-Enzyme之深入浅出

首先我们要明确的一点, React 组件和其它的被进行单元测试对象有何不同,我们会发现:
• React 组件的 render 结果是一个组件树,并且整个树最终会被解析成一个纯粹由 HTML 元素构成的树形结构
• React 组件可以拥有 state,且 state 的变化会影响 render 结果
• React 组件可以拥有生命周期函数,这些生命周期函数会在特定时间点执行

React 组件的单元测试本质是也是单元测试,因此它也符合我们之前介绍过的单元测试的全部特点。唯一不同的地方在于 React 组件的单元测试中我们需要找到合适的方法对执行结果进行断言。换言之,我们要根据 React 的特点来设置代码的判断条件,来判断代码是否正确执行。

接下来要清晰两件事:
1, 就是何执行一个 React 组件;
2, 如何对这个React 组件编写断言。

看到这个问题有过react开发经验的朋友可能有点懵。平时不就是直接 ReactDOM.render 吗?不错,ReactDOM.render 确实可以执行一个 React 组件并将它渲染到页面中,但这种方式不利于编写测试代码。

有没有更简单的方式呢?其实 React 已经帮我们提供好了工具,让我们一起来看看。
在 React 的官方文档中提到了两个用于测试 React 组件的库:
1,react-test-renderer(https://reactjs.org/docs/test-renderer.html)
2,react-dom/test-utilshttps://reactjs.org/docs/test-utils.html)。


react-test-renderer:

在说 react-test-renderer 之前,让我们先聊聊什么是 renderer(渲染器)。

React 最早是被用来开发网页的,所以早期的 React 库中还包含了大量和 DOM 相关的逻辑。后来 React 的设计思想慢慢被迁移到其它场景,最被人们熟知的莫过于 React Native 了。为了灵活性和扩展性,React 的代码被分拆为 React 核心代码与各种 renderer(渲染器)[备注:这里https://github.com/chentsulin/awesome-react-renderer)有一份各种各样的 renderer(测试渲染器) 列表。]。

React 自带了 3 个 renderer,前两个是大家常见的:

而今天提到的 react-test-renderer 则负责将组件输出成 JSON 对象以方便我们遍历、断言或是进行 snapshot(快照) 测试。

react-dom/test-utils:

首先从名称可以看出这个库是包含在 react-dom 中的。所以它只是 react-dom 的辅助测试工具。在 React 文档站中它的介绍页上用的标题却只有 “Test Utilities” 两个单词,很容易让人产生误解。该库中的方法主要作用是帮我们遍历 ReactDOM 生成的 DOM 树,方便我们编写断言。注意:使用该库时必须提供一个 DOM 环境。当然这个 DOM 环境可以是 jsdom 这种模拟环境。(Jest 默认的执行环境就是 jsdom)


二者如何选择

react-test-renderer 和 react-dom/test-utils 两者看起来还是很相似。何时该选择哪一个库呢?根据实际使用经验,简单来说:
• 如果需要测试事件(如 click, change, blur 等),那么使用 react-dom/test-utils。
• 其它时候使用更简单、灵活的 react-test-renderer。

react-test-renderer 使用方法:
react-test-renderer这个包提供了一个React渲染器,可用于将React组件渲染为纯JavaScript对象,而无需依赖DOM或本地移动环境。从本质上讲,此包可轻松获取由React DOM或React Native组件呈现的平台视图层次结构(类似于DOM树)的快照,而无需使用浏览器或jsdom

例:

    import TestRenderer from 'react-test-renderer';

    function Link(props) {
        return <a href={props.page}>{props.children}</a>;
    }

    const testRenderer = TestRenderer.create(
        <Link page="https://www.facebook.com/">Facebook</Link>
    );

    console.log(testRenderer.toJSON());
    // { type: 'a',
    //   props: { href: 'https://www.facebook.com/' },
    //   children: [ 'Facebook' ] }

您可以使用Jest的快照测试功能将JSON树的副本自动保存到文件中,并在测试中签入未更改的快照。

您还可以遍历输出以找到特定节点并对其进行断言。
例:

    import TestRenderer from 'react-test-renderer';

    function MyComponent() {
        return (
            <div>
                <SubComponent foo="bar" />
                <p className="my">Hello</p>
            </div>
        )
    }

    function SubComponent() {
        return (
            <p className="sub">Sub</p>
        );
    }

    const testRenderer = TestRenderer.create(<MyComponent />);
    const testInstance = testRenderer.root;

    expect(testInstance.findByType(SubComponent).props.foo).toBe('bar');
    expect(testInstance.findByProps({className: "sub"}).children).toEqual(['Sub']);

TestRenderer.create(element, options):
TestRenderer使用传递的React元素创建一个实例。它不使用真实的DOM,但仍将组件树完全呈现到内存中,因此您可以对其进行断言。返回一个TestRenderer实例

testRenderer.root:
返回根“测试实例”对象,该对象可用于对树中的特定节点进行断言。您可以使用它在下面更深入地查找其他“测试实例”。

testRenderer.toJSON():
返回代表渲染树的对象。该树仅包含特定于平台的节点(例如<div>或<View>和它们的道具),但不包含任何用户编写的组件。这对于快照测试非常方便。

更多的测试渲染器以及渲染器实例和方法见下面地址:
https://reactjs.org/docs/test-renderer.html#testrenderercreate

react-test-renderer 在实际使用过程中又有两种用法:

• shallow render:组件只会被 render 一层(children中的React组件不会被render);
• full render:组件会被完全 render。
现在让我们通过例子来具体看看两种方式的差别。

假设我们有以下两个组件:

    const Link = ({to, children}) => (
       <a className="my-link" href={to} target="_blank" rel="noopener noreferrer">{children}</a>
    );

    const Header = () => (
        <div>
            <span className="brand">Hello world</span>
            <Link to="https://jd.com">JD</Link>
            <Link to="http://butler.jd.com">Butler</Link>
            <Link to="http://lrc.jd.com">lrc</Link>
        </div>
    );

shallow render 相关的工具类存在于 react-test-renderer/shallow 空间下,我们首先引入,并创建一个实例:

    import ShallowRenderer from 'react-test-renderer/shallow';
    const renderer = new ShallowRenderer();

ShallowRenderer 的实例为我们提供了两个方法:
• render()用于 render(渲染你) 一个组件。你可以把 ShallowRenderer 的实例想象成一个容纳被 render 组件的“空间”。
• getRenderOutput()在 render 之后,可以通过此命令获取 render 的结果。
让我们看看完整的例子:

  describe('Header', () => {
      it('should render a top level div', () => {
        const renderer = new ShallowRenderer();
        renderer.render(<Header />);
        const result = renderer.getRenderOutput();
        expect(result.type).toBe('div');
      });

      it('should render 3 Link', () => {
        const renderer = new ShallowRenderer();
        renderer.render(<Header />);
        const result = renderer.getRenderOutput();
        const childrenLink = result.props.children.filter(c => c.type === Link);
        expect(childrenLink.length).toBe(3);
      });
  });

我们首先验证了 Header render 后顶层元素是一个 div。接着在第二个用例中验证了 render 结果中包含 3 个 Link 组件的实例。由于 shallow render 只 render 一层,所以可以验证的信息也都比较简单。比较适合验证组件输出的结构是否符合预期。

接下来看看 full render。
首先引入工具库:

  import TestRenderer from 'react-test-renderer';

调用 TestRenderer 的 create 方法并传入要 render 的组件就可以获得一个 TestRenderer 的实例。该实例上存在着以下几个方法和属性:

  • .toJSON():生成一个表示 render 结果的 JSON 对象。该对象中只包含像 <div>(web 平台)或是 <View (native 平台)这样的原生节点。不会包含用户开发的组件的信息。适合用于 snapshot testing
  • .toTree():和 .toJSON() 类似,但信息更加丰富,包含了用户开发的组件的信息。
  • .update(element):通过传入一个新的元素来更新上次 render 得到的组件树。
  • .umount():umount 组件树,同时触发相应的生命周期函数。
  • .getInstance():返回根节点对应的 React 组件实例,如果存在的话。如果顶级组件是一个函数式组件,则无法获取。
  • .root:该属性保存了根节点对应的测试实例(test instance)。该实例为我们提供了一系列方法方便我们编写断言。

现在让我们看看测试实例上都有哪些方法和属性可供我们使用(完整列表请参考这里

  • .find() 与 .findAll():用于查找符合特定条件的测试实例。区别在于 .find() 会严格要求节点树种只有 1 个满足条件的测试实例,如果没有或者多于 1 个就会抛出异常。此区别同样适用于下面两组方法。
  • .findByType() 与 .findAllByType:用于查找特定类型的测试实例。这里的类型可以是 div 这种原生类型,也可以是 Link 这种用户编写的 React 组件。
  • .findByProps() 与 .findAllByProps():用于查找 props 符合特定结构的测试实例。
  • .instance:该测试实例对应的 React 组件实例。

现在让我们看一个完整的测试用例:

   import TestRenderer from 'react-test-renderer'; 
   describe('Header', () => {
      it('should render 3 a tag with className "my-link"', () => {
        const testRenderer = TestRenderer.create(<Header />);
        const testInstance = testRenderer.root;
        expect(testInstance.findAll(node => node.type === 'a' && node.props.className === 'my-link')).toHaveLength(3);
      });
    });

在这个用例中我们通过 .findAll()方法查找了 className 为 my-link 的 a 标签并确保找到了 3 个。

react-dom/test-utils 使用方法:
现在让我们来看看涉及到用户交互的组件如何编写单元测试。首先简单了解一下 react-dom/test-utils 的基本用法。
首先还是引入工具类:

    import  ReactTestUtils  from  'react-dom/test-utils';

ReactTestUtils 对象上我们通常会用到以下一些方法(完整方法列表请参考这里https://reactjs.org/docs/test-utils.html)):

  • .Simulate.{evnentName}():模拟在给定的 DOM 节点上触发特点事件。Simulate 可以触发所有 React 支持的事件类型
  • renderIntoDocument():把一个 React 组件 render 到一个 detached(独立的) 的 DOM 中。注意:该方法依赖 DOM 环境。不过不用担心,Jest 默认集成了 jsdom。该方法会返回被 render 的 React 组件的实例。
  • scryRenderedDOMComponentsWithClass() 与 findRenderedDOMComponentWithClass():查找匹配特定类名的 DOM 元素。区别在于 scryRenderedDOMComponentsWithClass() 会查找所有元素。而 findRenderedDOMComponentWithClass() 会假定页面中有且只有 1 个符合条件的元素,否则抛出异常。
  • scryRenderedDOMComponentsWithTag() 与 findRenderedDOMComponentWithTag():查找匹配特定标签类型的 DOM 元素。

还是让我们通过一个具体的组件来熟悉下实际用法。

假设我们有以下 Button 组件:

    import React from 'react';

    class Button extends React.Component {
      constructor() {
        super();

        this.state = { disabled: false };
        this.handClick = this.handClick.bind(this);
      }

      handClick() {
        if (this.state.disabled) { return }
        if (this.props.onClick) { this.props.onClick() }
        this.setState({ disabled: true });
        setTimeout(() => {this.setState({ disabled: false })}, 200);
      }

      render() {
        return (
          <button className="my-button" onClick={this.handClick}>{this.props.children}</button>
        );
      }
    };

    export default Button;

其主要功能就是点击 button 元素时执行 onClick 回调,并且设置了自上一次点击之后,200 毫秒内按钮进入禁用状态。
首先让我们测试一下执行 onClick 回调这个逻辑:

   it('should call onClick callback if provided', () => {
      const onClickMock = jest.fn();
      const testInstance = ReactTestUtils.renderIntoDocument(
        <Button onClick={onClickMock}>hello</Button>
      );
      const buttonDom = ReactTestUtils.findRenderedDOMComponentWithClass(testInstance, 'my-button');
      ReactTestUtils.Simulate.click(buttonDom);
      expect(onClickMock).toHaveBeenCalled();
    })

这里我们创建了一个 mock 方法 onClickMock 并将它作为回到函数传递给 Button 组件。然后利用 ReactTestUtils.Simulate.click 模拟触发点击事件。最后确认一下 onClickMock 被调用。
使用.toHaveBeenCalled以确保模拟功能得到调用。

接下来让我们测试一下点击过后 200 毫秒内进入禁用状态:

   it('should be throttled to 200ms', () => {
      const testInstance = ReactTestUtils.renderIntoDocument(<Button>hello</Button>);
      const buttonDom = ReactTestUtils.findRenderedDOMComponentWithClass(testInstance, 'my-button');
      ReactTestUtils.Simulate.click(buttonDom);
      expect(testInstance.state.disabled).toBeTruthy();
      jest.advanceTimersByTime(199);
      expect(testInstance.state.disabled).toBeTruthy();
      jest.advanceTimersByTime(1);
      expect(testInstance.state.disabled).toBeFalsy();
    });

由于涉及到定时器逻辑,我们在这个用例中使用了 Jest 提供的 timer mock 功能。详细用法请参考 Jest 官方文档。


来自官网翻译:
https://reactjs.org/docs/test-utils.html

在Facebook,我们使用Jest进行无痛的JavaScript测试。通过Jest网站的React Tutorialhttps://jestjs.io/docs/en/tutorial-react)了解如何开始使用Jest 。
https://www.jianshu.com/
我们建议使用React Testing Libraryhttps://testing-library.com/docs/react-testing-library/intro/),该旨在启用和鼓励最终用户编写使用组件的测试。

另外,Airbnb发布了一个名为Enzymehttps://enzymejs.github.io/enzyme/)的测试实用程序,该实用程序使断言,操作和遍历React Components的输出变得容易。

该库中的方法主要作用是帮我们遍历 ReactDOM 生成的 DOM 树,方便我们编写断言。

注意:使用该库时必须提供一个 DOM 环境。当然这个 DOM 环境可以是 jsdom 这种模拟环境。(Jest 默认的执行环境就是 jsdom)


Enzyme介绍:

前面已经介绍完了 React 自带的两个测试工具库。接下来简单介绍一下由 Airbnb 开源的 React 测试工具库 Enzyme

Enzyme 底层其实就是基于 react-test-renderer 和 react-dom/test-utils 的。它在二者的基础上进行了封装提供了更加简单易用的查询、断言方法。在概念上,Enzyme 也与二者非常相似。在 Enzyme 中有三种 render 模式:

如果你能理解前面对 react-test-renderer 和 react-dom/test-utils 的介绍,那么上手 Enzyme 应该是非常容易的。此处不再详细介绍 Enzyme 的使用方法。

让我们使用 Enzyme 改写一下前面为 Button 组件编写的测试:

   describe('Button', () => {
      it('should be throttled to 200ms', () => {
        const wrapper = mount(<Button>hello</Button>);
        wrapper.find('.my-button').simulate('click');
        expect(wrapper.state('disabled')).toBeTruthy();
        jest.advanceTimersByTime(199);
        expect(wrapper.state('disabled')).toBeTruthy();
        jest.advanceTimersByTime(1);
        expect(wrapper.state('disabled')).toBeFalsy();
      });

      it('should call onClick callback if provided', () => {
        const onClickMock = jest.fn();
        const wrapper = mount(<Button onClick={onClickMock}>hello</Button>);
        wrapper.find('.my-button').simulate('click');
        expect(onClickMock).toHaveBeenCalled();
      });
    });

现在我们可以通过 Enzyme 提供的 .find() 方法查找 DOM 节点,通过 .state() 方法读取 state。简单不少吧。

通过上面案例,我们不难发现Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。
Enzyme 兼容所有的主要测试运行器和判断库。

而enzyme还需要根据React的版本安装适配器(Adapter),适配器对应表如下:
image.png

Adapter:适配器模式(Adapter)的定义如下,将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。适配器模式分为类结构型模式和对象结构型模式两种,前者类之间的耦合度比后者高,且要求程序员了解现有组件库中的相关组件的内部结构,所以应用相对较少些。

因为我目前手里的项目用的是react17,而React17 目前官方还没出来适配器,网上有一个非官方适配器用于react17,地址:https://www.npmjs.com/package/@wojtekmaj/enzyme-adapter-react-17, 是由一位来自波兰克拉科夫的React开发人员发布在npm上的。

Wojciech Maj 介绍:
是来自波兰克拉科夫的React开发人员。为Intive工作,并且是React-PDF,React-Calendar和许多其他流行软件包的维护者。

个人学习笔记,知识点来源于官网以及网上的各路大神。如果有缘点到我的文章,并且对你有所帮助的话,请不要谢我,让我们一起感谢那些开拓者吧~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容