使用 Enzyme 进行 React 组件测试进阶

很早之前,写过一篇 《使用enzyme 测试你的 React 组件》, 综合叙述了如何利用 Karma + Webpack + Enzyme 进行组件的测试, 从而确保我们的质量。 相对而言,这次会丰富一些,比如组件的 UI 事件以及 redux 引入后的测试。

项目使用 yarn-react-webpack-seed 为案例,你可以在项目里找到源码。

建立测试

安装 karma

$ npm install karma karma-chai karma-chrome-launcher karma-coverage karma-jasmine karma-sourcemap-loader jasmine-core karma-webpack --save-dev 

安装 enzyme 相关

npm install enzyme redux-mock-store enzyme-adapter-react-16 jasmine-enzyme --save-dev

在项目建立 test 目录,新增 karma.conf.js

然后在 package.json 的 script 添加

"test": "NODE_ENV=test karma start ./test/karma.conf.js --log-level debug"

接下里我们在 test 添加 specs 目录,用于存放我们的测试文件。

同时我们需要 test 添加 enzyme.js

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-15.4';
import jasmineEnzyme from 'jasmine-enzyme';

// Configure Enzyme for the appropriate React adapter
Enzyme.configure({ adapter: new Adapter() });

// Initialize global helpers
beforeEach(() => {
  jasmineEnzyme();
});

// Re-export all enzyme exports
export * from 'enzyme';

接下来我们测试你一个简单的组件。

我们在 src/components 建立一个简单的 links.js 组件

import React, { Component } from 'react';
import { Link } from 'react-router-dom';

class Links extends Component {

  render() {
    return (
      <div>
        <aside>
          <h4>Categories</h4>
          <ul>
            <li><Link to="/start">Start</Link></li>
            <li><Link to="/how">How</Link></li>
            <li><Link to="/guide">Guide</Link></li>
          </ul>
        </aside>
      </div>
    );
  }
}


export default Links;

src/components/links.js

我们 test/specs 目录最好也还是和 src 目录保持,只是测试的文件添加 .test.js 来表示它是用于测试的文件。

import React from 'react';
import Links from '../../src/components/links';
import { shallow } from '../../enzyme';


describe('Links component test', () => {

  it('render links', () => {
    const wrapper = shallow(
      <Links />
    );
    expect(wrapper.find('li').length).toBe(3);
  });

})

大概就是确定我们渲染后的节点,内容,关于 wrapper 支持的 API 。可以参考 Rendering API

模拟交互事件

当然,如果我们组建比较复杂不单单只是一些属性的判断而还有一些事件的交互,比如点击,输入框内容变化,等等。

我们可以在 src/components 下 建立一个 form.js

import React, { Component } from 'react';

class Form extends Component {
  constructor(props) {
    super(props);
    this.state = {
      phone: 123,
      error: false,
    };
    this.onChange = this.onChange.bind(this);
    this.reset = this.reset.bind(this);
  }

  onChange(e) {
    const val = e.target.value;
    if (!/^[1][3,4,5,7,8][0-9]{9}$/.test(val)) {
      this.setState({
        error: true,
      });
    } else {
      this.setState({
        phone: val,
        error: false,
      });
    }
  }

  reset() {
    this.setState({
      phone: '',
      error: false,
    });
  }

  render() {
    const { phone, error } = this.state;
    return (
      <div className="m-form">
        <h3>This is form</h3>
        <p>
          <span className="js-contents">{phone}</span>
        </p>
        <p>
          <input type="text" onChange={this.onChange} />
        </p>
        {
          error && (<div className="js-error">Error Phone Number</div>)
        }
        <p><button onClikc={this.reset} type="button">Reset</button></p>
      </div>
    );
  }
}

export default Form;

一个简单的输入手机号并验证的情况。

enzyme 可以通过 simulate 来模拟 DOM 的交互事件,比如 click, focus,blur 等事件。

我们模拟用户输入手机可以这么写了

import React from 'react';
import Form from '../../../src/components/form';
import { mount } from '../../enzyme';


describe('Links component test', () => {

  it('render links', () => {
    const wrapper = mount(
      <Form />
    );
    expect(wrapper.find('input').length).toBe(1);
    expect(wrapper.find('button').length).toBe(1);
  });

  it('# onChange ()', () => {
    const wrapper = mount(
      <Form />
    );
    wrapper.find('input').simulate('focus');
    wrapper.find('input').simulate('change', { target: { value: '13' } });
    wrapper.find('input').simulate('change', { target: { value: '1378' } });
    expect(wrapper.find('.js-error').length).toBe(1);
  });

  it('# reset prop', () => {
    const foo = {
      count: 0,
      setBar() {
        this.count ++;
      }
    };

    spyOn(foo, 'setBar');
    const wrapper = mount(
      <Form
        reset={foo.setBar}
      />
    );
    wrapper.find('button').simulate('click');
    expect(foo.setBar).toHaveBeenCalled();
  });

})

我们通过 wrapper 先找到对应的输入框,这样我们可以来模拟输入值得变化。

判断函数调用

接触过单元测试的话,都知道 spy 。我们同理在 React 的测试用到 spy 来确定按钮点击等触发的函数调用。

it('# reset prop', () => {
    const foo = {
      count: 0,
      setBar() {
        this.count ++;
      }
    };

    spyOn(foo, 'setBar');
    const wrapper = mount(
      <Form
        reset={foo.setBar}
      />
    );
    wrapper.find('button').simulate('click');
    expect(foo.setBar).toHaveBeenCalled();
  });

如果你希望判断调用次数以及传入的参数可以查看 section-Spies 的文档。

Redux 引入

现在越来越多的 React 应用引入了 redux

比如我们设计一个简单的引入拉取数据的的程序,然后渲染列表。

具体 Demo Code

/* eslint-disable arrow-parens */
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { fetchList, fetchTags } from '../actions/guide';


class Guide extends Component {

  componentDidMount() {
    this.fetchList();
  }

  fetchList() {
    const { dispatch } = this.props;
    dispatch(fetchList());
    dispatch(fetchTags());
  }

  render() {
    const { data, tags} = this.props;
    let movies = null;
    let tagCom = null;
    if (tags && tags.length > 0 ) {
      tagCom = tags.map((item) => {
        return (
          <a href="#">{ item }</a>
        );
      });
    }
    if (data && data.length > 0 ) {
      movies = data.map((item) => {
        return (
          <div>
            <h3>{item.name}</h3>
            <p> ✨ {item.rank}</p>
          </div>
        );
      });
    }
    return (
      <div>
        <h3>This is guide</h3>
        <h4>Redux Test</h4>
        <p className="tag-list">{ tagCom }</p>
        { movies }
      </div>
    );
  }
}

const mapStateToProps = (state) => ({
  data: state['guide']['data'],
  tags: state['guide']['tags'],
});

export default connect(mapStateToProps)(Guide);

如果我们需要测试引入 store 的组件,我们需要先安装 redux-mock-store

npm install redux-mock-store --save-dev

接下来,我们先写模拟数据状态的文件,新建 test/fixtures/state.js

const mockState = {
  guide: {
    data: [
      {
        name: 'KungFu Hustle',
        rank: '8.3',
      },
      {
        name: 'CJ-7',
        rank: '6.4',
      },
    ],
    tags: ['movie', 'comedy'],
  },
};

export default mockState;

编写我们的测试文件 guide.test.js


import React from 'react';
import configureStore from 'redux-mock-store'
import { Provider } from 'react-redux';
import Guide from '../../../src/components/guide';
import { mount } from '../../enzyme';
import mockState from '../fixtures/state';


const mockStore = configureStore();
let wrapper;
let store;
beforeEach(() => {
  // 创建关联 store
  store = mockStore(mockState);
  // 渲染测试的组件将 store 传入
  wrapper = mount(<Provider store={store}><Guide /></Provider>)
});
describe('Links component test', () => {
  it('render movies and tags', () => {
    expect(wrapper.find('.movie-item').length).toBe(2);
    expect(wrapper.find('.tag-item').length).toBe(2);
  });
});

这样就可以针对引入 redux 的组件进行测试。

最后你可以在 yarn-react-webpack-seed 找到相关演示代码。

小结

本文在原有测试基础上,增加了对组件的事件交互和数据交互的测试,对于前端而言无论是 TDD 还是 BDD, 尝试去写自己组件的测试用例都有助于工程质量的提升和本身产品的效能开发,不过写好测试依旧需要花些时间。

扩展阅读