走向成熟的必经之路 —— 前端测试

1,421 阅读9分钟

前言👀

不知为何,前端测试好像始终没有形成风气,对于一个项目测试是必不可少的组成部分,对于开源项目更是很重要的一环,这是对项目的一种负责的态度。

测试对我们来说不仅仅可以让我们了解我们的组件或逻辑是否符合我们的预期,同时还可以帮助其他小伙伴更快速的了解我们的组件(或者是函数等)的功能,以及项目上下文!

测试即文档🧪!!!

热身🙋‍♂️

有了这么多脚手架你还会不会自己手动配置前端开发环境呢?下面让我们来配置一个最简单Jest配置环境!

  1. 先快速生成一个package.json的文件来配置我们的项目:npm init -y,并在文件中加入如下配置:

    // package.json
    {
      ...
      "script": {
        "test": "jest"
      }
      ...
    }
    
  2. 测试中我们使用Jest + Enzyme来测试我们的React组件下面配置一下Jest环境:

    1. 首先安装最重要的Jest:npm i -D jest

    2. 然后再陆续将下列Dependencies安装好:

      • react
      • react-dom
      • axios
      • enzyme
      • enzyme-adapter-react-16
      • sinon
      • @babel/preset-env
      • @babel/preset-react

      既然是要对React项目进行测试,安装react以及react-dom依赖应该无需多言。

      下面解释一下其他依赖:

      首先对于一个项目,难免会发送一些请求,而在本次测试教程中我选用了axios发送请求,并对其进行mock或stub;

      紧随其后的enzyme和enzyme-adapter-react-16是可以获取React输出的一个工具,通过这个工具我们可以获取到React组件中我们想要的一些信息;

      其次,sinon则是一个单元测试的一个框架,这个框架可以对方法进行监听或者进行mock及stub等操作;

      最后介绍一下babel中的两个preset,@babel/preset-env允许我们使用最新的JavaScript;而@babel/preset-react允许我们使用JSX语法。

      ⭐️注意:enzyme-adapter-react-*要与项目中的react版本相匹配,具体版本请见官网

    3. 项目根目录下创建jest.config.js文件,并加入如下配置:

      module.exports = {
        collectCoverage: true, // 输出测试覆盖率等信息
      };
      
    4. 项目根目录下创建babel.config.js文件,并加入如下配置:

      module.exports = {
        presets: [
          [
            "@babel/preset-env",
            {
              targets: {
                node: "current"
              }
            }
          ],
          ["@babel/preset-react"]
        ]
      };
      

      Done!最终创建好项目之后,文件结构应该如下:

      怎么样,用久了create-react-app之后,偶尔手动配置一下环境是不是也蛮有趣的🤙

      实操💪

      1. 测试React组件文本内容

      Enzyme中为我们提供了mount/render/shallow等方法供我们渲染React组件,我们拿到渲染的组件之后,就可以获取到组件内部想要的内容了。以一个最简单的Header组件为例:

      // header/index.js
      export default class Header extends Component {  
        render() {
          return (
            <div>
              <h1>Welcome to my home!</h1>
            </div>
          )
        }
      }
      

      在上面的Header组件中,只有一个被h1包裹的内容,如果我们想验证h1中的内容,我们可以通过下面的测:

      // header/__tests__/index.test.js
      import React from 'react';
      import { configure, mount } from 'enzyme'; // 引入enzyme的mount方法
      import Adapter from 'enzyme-adapter-react-16';
      import Header from '../index';
      
      configure({adapter: new Adapter()}); // 配置enzyme要适配的React版本
      
      describe('Header component test', () => {
        it('should get h1 content', () => {
          const component = mount(<Header />);
          expect(component.find('h1').text()).toBe('Welcome to my home!');
        });
      });
      

      运行一下测试:

      第一次感觉绿色是如此美丽的颜色(手动doge)!

      👾有些小伙伴可能会考虑到:我们在每一个测试文件前面都要添加react的版本适配,着重复工作就太多了!既然有问题就会有解决方法滴~

      当我们查阅Jest官方的配置文档后会发现,Jest的配置中有一个setupFiles属性,那么这个配置属性就是告诉Jest:每次跑测试前都运行一下这个文件!所以我们的jest.config.js就修改成下面这样:

      module.exports = {
        collectCoverage: true,
        setupFiles: [
          "<rootDir>/config/setup.js", // <rootDir>代表当前文件的路径
        ]
      };
      

      setup.js文件内容如下:

      import { configure } from 'enzyme';
      import Adapter from 'enzyme-adapter-react-16';
      configure({adapter: new Adapter()});
      

      2. 测试React组件state

      只能获取到组件的文本怎么能满足我们呢?Aribnb不会让我们失望的!

      还是以Header组件为例,我们给Header组件添加一个state并在组件中显示,代码如下:

      export default class Header extends Component {
        constructor(props) {
          super(props);
          this.state = {
            date: new Date().toLocaleDateString()
          };
        }
        
        
        render() {
          const { date } = this.state;
          return (
            <div>
              <h1>Welcome to my home!</h1>
              <h2>Today is {date}</h2>
            </div>
          )
        }
      }
      

      我们在Header组件的state中添加了一个date用来保存当前日期,并在组件中展示,下面将演示一下如何获取到组件中的state

      // ...
      // 注意哟~我们已经配置了setup.js文件了,就可以把之前配置adapter的代码删了
      describe('Header component test', () => {
        // ...
        it('should get date in state', () => {
          const component = mount(<Header />);
          expect(component.state().date).toBe(new Date().toLocaleDateString());
          // 获取state除了可以用上面的方式,还可以使用下面这种方式
          expect(component.state('date')).toBe(new Date().toLocaleDateString());
          // 当然中渲染的结果也是没有问题的
          expect(component.find('h2').text()).toBe(`Today is ${new Date().toLocaleDateString()}`);
        });
      });
      

      没毛病,又是愉快的绿色!

      3. 测试React组件的props

      有了state怎么能少props?让我们再为Header组件添加个prop用来渲染一个导航栏:

      export default class Header extends Component {
        // ...
        render() {
          const { navs } = this.props;
          const { date } = this.state;
          return (
            <div>
              <ul>
                {
                  navs.map(nav => <li key={nav.link}>
                      <a href={nav.link}>{nav.text}</a>
                    </li>
                  )
                }
              </ul>
              // ...
            </div>
          )
        }
      }
      

      在下面的测试中,我们除了验证了props的准确性,还简单验证了一下渲染的结果:

      const NAVS = [
        {
          text: '首页',
          link: '/home'
        },
        {
          text: '个人信息',
          link: '/profile'
        }
      ];
      
      describe('Header component test', () => {
        // ...
        it('should get correct props', () => {
          const component = mount(<Header navs={NAVS} />);
          // 下面两种获取props的方式都有效
          // expect(component.props().navs).toBe(NAVS);
          expect(component.prop('navs')).toBe(NAVS);
          // 验证我们渲染的a标签的个数
          expect(component.find('a').length).toBe(2);
        });
      });
      

      🌱很有感觉就这样一直绿下去,让我们提升一下难度!

      4. 测试React组件中的事件

      我们的项目最终是为了用户服务的,所以怎么能少的了和用户的交互的!下面我们来测试一下组件中的事件,我们再新建一个Counter组件用来记录用户点击的次数:

      export default class Counter extends Component {
        constructor(props) {
          super(props);
          this.state = {
            count: 0
          };
          this.handleClick = this.handleClick.bind(this);
        }
      
        handleClick(operator) {
          let { count } = this.state;
          switch (operator) {
            case '+':
              count++;
              break;
            case '-':
              count--;
              break;
            default:
              break;
          }
          this.setState({count});
        }
      
        render() {
          return (
            <div>
              <button onClick={() => this.handleClick('+')}> + </button>
              点击了: {this.state.count} 次
              <button onClick={() => this.handleClick('-')}> - </button>
            </div>
          )
        }
      }
      
      

      下面介绍一下测试方法的思路:

      1. “监视”handleClick方法;
      2. 模拟用户点击button
      3. 点击button后获取更新的state
      4. 获取handleClick调用的次数;
      import React from 'react';
      import { mount, shallow } from 'enzyme';
      import sinon from 'sinon';
      
      import Counter from '../index';
      
      describe('Counter component test', () => {
        it('should call handle click event', () => {
          // 1. 监视handleClick方法
          const spy = sinon.spy(Counter.prototype, 'handleClick');
          const component = mount(<Counter />);
          // 2. 模拟用户点击按钮
          component.find('.plus').at(0).simulate('click');
          // 3. 获取点击button后的state
          const firstResult = component.state('count');
          expect(firstResult).toBe(1); // 点击一次+之后,结果应该为1
          // 4. 获取handleClick调用的次数
          expect(spy.calledOnce).toBeTruthy(); // handleClick被调用了一次
        });
      });
      

      sinon允许我们可以“监视”我们的事件,去判断事件是否被调用,被调用的次数,也可以进行mock,stub等操作。

      你以为这就结束了吗?都说到React不提一下Redux说得过去?⚛️

      5. 测试Redux中connected组件

      我们先把Counter组件修改成connect的组件:

      先写个action:

      // action.js
      function addOne(number) {
        number++;
        return ({
          type: 'ADD_ONE',
          payload: number
        });
      }
      
      function minusOne(number) {
        number--;
        return ({
          type: 'MINUS_ONE',
          payload: number
        });
      }
      
      export {
        addOne,
        minusOne
      }
      

      再来个ruducer:

      // reducer.js
      const initState = {
        count: 0
      };
      
      export default countReducer = (state = initState, action) => {
        switch (action.type) {
          case 'ADD_ONE':
            return {
              ...state,
              count: action.payload
            }
          case 'MINUS_ONE':
            return {
              ...state,
              count: action.payload
            }
          default:
            return state;
        }
      }
      

      最后把Counter修改一下:

      // counter/index.js
      import React, { Component } from 'react';
      import { connect } from 'react-redux';
      
      import { addOne, minusOne } from './action';
      
      class Counter extends Component {
        constructor(props) {
          super(props);
          this.handleClick = this.handleClick.bind(this);
        }
      
        handleClick(operator) {
          const { count } = this.props;
          switch (operator) {
            case '+':
              this.props.addOne(count);
              break;
            case '-':
              this.props.minusOne(count);
              break;
            default:
              break;
          }
        }
      
        render() {
          const count = this.props.count || 0;
          return (
            <div>
              <button className='plus' onClick={() => this.handleClick('+')}> + </button>
              点击了: {count} 次
              <button className='minus' onClick={() => this.handleClick('-')}> - </button>
            </div>
          )
        }
      }
      
      const mapStateToProps = state => ({
        count: state.count
      });
      
      const mapDispatchToProps = dispatch => ({
        addOne: number => dispatch(addOne(number)),
        minusOne: number => dispatch(minusOne(number))
      });
      
      export { Counter };
      export default connect(mapStateToProps, mapDispatchToProps)(Counter);
      

      上面这些代码都很基础,在此不再赘述。下面我们来说一下测试,因为组件修改成了connect组件,所以我们的测试逻辑与第一次Counter组件不太相同:

      1. 监视handleClick方法;
      2. 模拟用户点击按钮(注意哦⚠️我们点击事件中已经dispatch了action);
      3. 获取dispatch指定action后store中的值;

      测试代码如下:

      import React from 'react';
      import { mount, shallow } from 'enzyme';
      import sinon from 'sinon';
      import { Provider } from 'react-redux';
      import thunk from 'redux-thunk';
      import configStore from 'redux-mock-store';
      
      import ConnectedCounter, {Counter} from '../index';
      
      // mock store
      const mockStore = configStore([thunk]);
      const store = mockStore({count: 0});
      
      describe('Counter component test', () => {
        it('should get connected component', () => {
          // 1. 监视handleClick方法
          const spy = sinon.spy(Counter.prototype, 'handleClick');
          const component = mount(
            <Provider store={store}>
              <ConnectedCounter />
            </Provider>
          );
          // 2. 模拟用户点击按钮
          component.find('.plus').simulate('click');
          expect(spy.calledOnce).toBeTruthy(); // handleClick被调用了一次
          // 3. 获取dispatch action后store中的值
          const actions = store.getActions();
          const expectResult = {
            type: 'ADD_ONE',
            payload: 1
          };
          expect(actions[0]).toEqual(expectResult);
        });
      });
      

      因为我们组件需要接收store,并且平时项目中的store内容比较庞大或复杂,所以在此我们可以借助redux-mock-store来mock一个假的store来传给我们的组件。redux-mock-store的使用很简单,大家可以参照官方文档。因为我们的store中count的初始值为0,所以当我们成功dispatch了addOne()这个action之后,store中的count应该会变成1,这也就是我们最后一个断言所要判断的。

      ⚠️因为我们的Counter组件的逻辑以及内部实现已经被改变了,所以第一个测试是无法通过的!我们需要保证的是外部表现要与原先保持一致!

      6. axios测试

      其实对于axios本身我们并不需要测试,对于前端测试而言,个人认为更注重的是业务!也就是用户看到的东西。所以基于这一点,当测试中存在axios时,我们直接将其mock掉即可。

      import axios from 'axios';
      
      jest.mock('axios');
      
      describe('axios test', () => {
        it('should mock axios', () => {
          const users = [{name: 'Bob'}];
          const resp = {data: users};
          // 指定axios.get请求的返回值
          axios.get.mockResolvedValue(resp);
          // 获取数据
          axios.get('/axios/get/test')
            .then(response => {
              const receive = response.data;
              expect(receive).toEqual(resp);
            })
            .catch(error => new Error(error));
        });
      });
      

      结语

      前端测试框架除了Jest还有很多其它的,例如:Mocha,Chai,Ava等;React的测试框架也有@testing-library/react,react-test-renderer等;其实这些测试框架的使用都大同小异,主要是在测试时要注意不同测试框架版本会对应不同的前端框架版本,并且还会有一些特性差异。

      前端测试基础就先讲这么多,养成写测试的好习惯时走向“成熟”的第一步,要对自己和项目负责!

      如有错误请及时指出,加以改正,在此谢过!

      共勉!

      Btw,如果大家感兴趣可以关注一下我的公众号:非猿。再次感谢!