单元测试--(三)React + Enzyme

987 阅读8分钟

前言

  • Enzyme介绍
  • React16 类组件测试
  • React16 函数组件 + Hooks测试
  • React17 测试

一. Enzyme介绍

1.1 介绍

Enzyme是 Airbnb 开源的专为 React 服务的测试框架,它的 Api 像 Jquery 一样灵活,因为 Enzyme 是用 cheerio 库解析 html,cheerio 经常用于 node 爬虫解析页面,因此也被成为服务端的 Jquery

Enzyme官网地址

1.2 配置Enzyme

要完成渲染测试,还需要 Enzyme Adapter 库的支持,由于React 版本的不同,Enzyme Adapter的版本也不一样。Enzyme Adapter 顾名思义是为了适应不同的 React 版本,它和 React 版本对应关系如下图

image.png

// enzyme.js
import Enzyme from 'enzyme'; 
import Adapter from 'enzyme-adapter-react-16'; // 适应React-16
// Adapter: 在使用 enzyme 时,需要先适配React版本
Enzyme.configure({ adapter: new Adapter() }); // 适应React-16,初始化
export default Enzyme;

/*
    // 也可以挂载到global全局环境中
    global.shallow = shallow;
    global.render = render;
    global.mount = mount;
*/

1.3 渲染方式

react测试利器enzyme有三种渲染方式:shallow, mount, render。

1.3.1 shallow:

    浅渲染

    将组件渲染成虚拟DOM对象,只会渲染第一层,子组件将不会被渲染出来,使得效率非常高。
    不需要DOM环境,并可以使用jQuery的方式访问组件的信息
    
    大部分情况下,如果不深入组件内部测试,那么可以使用shallow渲染。
// todo.js
import React from 'react';
const Com = ({children}) => {
    return (<h2 className="test">{children}</h2>)
}
const TodoList = (props) => {
    return (
        <div className="todo-list">
            {props.list.map((todo, index) => (
                <div key={index}>
                    <span className="item-text">{todo}</span>
                    <button onClick={() => props.deleteTodo(index)}>done</button>
                </div>
            ))}

            <Com>test</Com>
        </div>
    );
};
export default TodoList;
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
    list: ['first', 'second'],
    deleteTodo: jest.fn()
};
const setupByShallow = () => {
    const wrapper = shallow(<TodoList {...props} />);
    // console.log('===shallow===', wrapper); // ShallowWrapper {}
    return {
        props,
        wrapper,
    };
};
describe('test Todo', () => {
    /*
        判断组件是否有button,因为不需要渲染子节点,
        所以使用shallow方法进行组件的渲染,因为props的list有两项,所以预期应该有两个button按钮
    */
    it('button length', () => {
        const { wrapper } = setupByShallow();
        expect(wrapper.find('button').length).toBe(2);
    });
});

1.3.2 mount:

    完整渲染

    它将组件渲染加载成一个真实的DOM节点,用来测试DOM API的交互和组件的生命周期。用到了jsdom来模拟浏览器环境
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
    list: ['first', 'second'],
    deleteTodo: jest.fn()
};
const setupByMount = () => {
    const wrapper = mount(<TodoList {...props} />);
    // console.log('===mount===', wrapper); // ReactWrapper {}
    return {
        props,
        wrapper,
    };
};
describe('test Todo', () => {
    /*
        判断组件的内容,使用mount方法进行渲染,然后使用forEach判断.item-text的内容是否和传入的值相等
    */
    it('should render item equal', () => {
        const { wrapper } = setupByMount();
        wrapper.find('.item-text').forEach((node, index) => {
            expect(node.text()).toBe(wrapper.props().list[index]);
        });
    });
});

1.3.3 render

    静态渲染
    它将React组件渲染成静态的HTML字符串,然后使用Cheerio这个库解析这段字符串,
    并返回一个Cheerio的实例对象,可以用来分析组件的html结构

    所以组件的生命周期方法内的逻辑都测试不到,所以render常常只用来测试一些数据(结构)一致性对比的场景
    
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
    list: ['first', 'second'],
    deleteTodo: jest.fn()
};
const setupByRender = () => {
    const wrapper = render(<TodoList {...props} />);
    console.log('===render===', wrapper); //  ===render=== LoadedCheerio {}
    return {
        props,
        wrapper,
    };
};
describe('test Todo', () => {
    /*
        判断组件是否有button这个元素,因为button是组件里的元素,所有使用render方法进行渲染,预期也会找到连个button元素
    */
    it('should render 2 item', () => {
        const { wrapper } = setupByRender();
        expect(wrapper.find('button').length).toBe(2);
    });
});

1.3.4 比较

三种方法中,shallow和mount因为返回的是DOM对象,可以用simulate进行交互模拟,而render方法不可以。
一般shallow方法就可以满足需求,如果需要对子组件进行判断,需要使用render,
如果需要测试组件的生命周期,需要使用mount方法。

1.3.5 性能测试

√ 测试 shallow 500次 (72 ms)
√ 测试 render 500次  (83 ms)
√ 测试 mount 500次   (347 ms)

结果:
    shallow < render < mount
    
const Example = () => {
    return (<h2>Example</h2>);
};
describe('test Performance', () => {
    it('测试 shallow 500次', () => {
        for (let i = 0; i < 500; i++) {
            const app = shallow(<Example/>)
            app.find('h2').text()
        }
    })
    it('测试 render 500次', () => {
        for (let i = 0; i < 500; i++) {
            const app = render(<Example/>)
            app.find('h2').text()
        }
    })
    it('测试 mount 500次', () => {
        for (let i = 0; i < 500; i++) {
            const app = mount(<Example/>)
            app.find('h2').text()
        }
    })
});

二. React16 类组件测试

2.1 UI测试

 快照会生成一个组件的UI结构,并用字符串的形式存放在__snapshots__文件里,
 通过比较两个字符串来判断UI是否改变,因为是字符串比较,所以性能很高

 使用 snapshot 进行 UI 测试   
    当使用toMatchSnapshot的时候,会生成一份组件DOM的快照,
    以后每次运行测试用例的时候,都会生成一份组件快照和第一次生成的快照进行对比,
    如果对组件的结构进行修改,那么生成的快照就会对比失败。
    可以通过更新快照重新进行UI测试
    
    --updateSnapshot:
        执行此命令,当UI变化会同步更新快照文件

2.1.1 react-test-renderer

import renderer from 'react-test-renderer';
import React from 'react';
const Button = (props) => {
    return (
        <div>
            <h1>{props.text}</h1>
        </div>
    );
};
const props = {
    text: '按钮测试用例'
};
describe('test Button', () => {
    // 第一种方式:通过react-test-renderer
    it('Button render correctly', () => {
        // 生成快照
        const tree = renderer.create(<Button {...props} />).toJSON();
        // 匹配之前的快照
        expect(tree).toMatchSnapshot(); 
    });
    
});

2.1.2 enzyme + enzyme-to-json

import toJson from 'enzyme-to-json';
import React from 'react';
import Enzyme from './enzyme'
const { shallow } = Enzyme
const Button = (props) => {
    return (
        <div>
            <h1>{props.text}</h1>
        </div>
    );
};
const props = {
    text: '按钮测试用例'
};
describe('test Button', () => {
    // 第二种方式: enzyme-to-json
    it('Button render correctly2', () => {
        const wrapper = shallow(<Button {...props} />);
        expect(toJson(wrapper, {
            // 是否返回渲染到最大深度的测试对象, 仅适用于mount包装器
            mode: 'deep',
        })).toMatchSnapshot();
    });
});

2.2 Enzyme常用API

    simulate(event, mock):模拟事件,用来触发事件,event为事件名称,mock为一个event object
    instance():返回组件的实例
    find(selector):根据选择器查找节点,selector可以是CSS中的选择器,或者是组件的构造函数,组件的display name等
    at(index):返回一个渲染过的对象
    get(index):返回一个react node,要测试它,需要重新渲染
    contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为react对象或对象数组
    text():返回当前组件的文本内容
    html(): 返回当前组件的HTML代码形式
    props():返回根组件的所有属性
    prop(key):返回根组件的指定属性
    state():返回根组件的状态
    setState(nextState):设置根组件的状态
    setProps(nextProps):设置根组件的属性
import renderer from 'react-test-renderer';
import React from 'react';
import Enzyme from './enzyme'
const { shallow, mount } = Enzyme
class Button extends React.Component {
    constructor() {
        super();
        this.type = 'default'
        this.state = {
            content: 'default'
        }
    }
    changeType(type){
        this.type = type
    }
    render() {
        const { content } = this.state;
        return (
            <>
                <h1>{this.props.bar}</h1>
                <p>{content}</p>
            </>
        );
    }
};
// 设置props
it('allows us to set props', () => {
    const wrapper = mount(<Button bar="baz" />);
    expect(wrapper.props().bar).toEqual('baz');

    wrapper.setProps({ bar: 'foo' });
    expect(wrapper.props().bar).toEqual('foo');
});
// 设置state
it('allows us to set state', () => {
    const wrapper = mount(<Button bar="baz" />);
    expect(wrapper.find('p').text()).toEqual('default');

    wrapper.setState({content: 'new'});
    expect(wrapper.find('p').text()).toEqual('new');
});
// 更改实例对象
it('allows us to change instance', () => {
    const wrapper = mount(<Button bar="baz" />);
    expect(wrapper.instance().type).toEqual('default');

    wrapper.instance().changeType('test')

    expect(wrapper.instance().type).toEqual('test');
});

2.3 模拟事件操作

2.3.1 模拟click

import React from 'react';
export default class Counter extends React.Component {
    constructor(props) {
      super(props);
      this.state = {count: 0};
      this.handleClick = this.handleClick.bind(this);
    }
    componentDidMount() {
      document.title = `You clicked ${this.state.count} times`;
    }
    componentDidUpdate() {
      document.title = `You clicked ${this.state.count} times`;
    }
    handleClick() {
      this.setState(state => ({
        count: state.count + 1,
      }));
    }
    render() {
      return (
        <div>
          <p className="para">You clicked {this.state.count} times</p>
          <button onClick={this.handleClick}>
            Click me
          </button>
        </div>
      );
    }
  }

describe('Counter', () => {
  const counter = shallow(<Counter />); // 浅渲染
  it('p tag content', () => {
    expect(counter.find('p').text()).toBe('You clicked 0 times');
  });

  it('simulate handleClick', () => {
    counter.find('button').simulate('click')
    expect(counter.find('p').text()).toBe('You clicked 1 times');
  });
});

2.3.2 模拟change + keyup事件

import React, { useState } from 'react'

const AddTodoView = ({ onAddClick }) => {
  const [value, setValue] = useState('');
  const onChangeHandle = (e) => {
    setValue(e.target.value)
  }
  const onKeyUpHandle = (e) => {
    if (e.keyCode === 13) {
      value && onAddClick(value);
      setValue('')
    }
  }
  return (
    <header className="header">
      <h1>todos-{value}</h1>
      <input
        className="new-todo"
        type="text"
        value={value}
        onKeyUp={e => onKeyUpHandle(e)}
        onChange={e => onChangeHandle(e)}
        placeholder="input todo item"
      />
    </header>
  )
}
const setup = () => {
    // 模拟 props
    const props = {
      // Jest 提供的mock 函数
      onAddClick: jest.fn()
    }
    // 通过 enzyme 提供的 shallow(浅渲染) 创建组件
    const wrapper = shallow(<AddTodoView {...props} />)
    return {
      props,
      wrapper
    };
};

describe('AddTodoView', () => {
    const { wrapper, props } = setup();
    it('When the Enter key was pressed', () => {
        // mock input 输入和 Enter事件
        const mockChangeEvent = {
          target: {
            value: 'Test'
          }
        };
        const mockKeyupEvent = {
            keyCode: 13, // enter 事件
        };
        /*
          使用 Enzyme 提供的 .simulate('keyup', mockEvent)来模拟点击事件,
          这里的 keyup 会自动转换成 React 组件中的 onKeyUp 并调用
        */
        wrapper.find('input').simulate('change', mockChangeEvent);
        expect(wrapper.find('h1').text()).toBe('todos-Test');

        
        wrapper.find('input').simulate('keyup', mockKeyupEvent);
        // 判断 props.onAddClick 是否被调用
        expect(props.onAddClick).toBeCalled();
        expect(wrapper.find('h1').text()).toBe('todos-');
    });
});

2.4 接口请求相关

2.4.1 真实请求的处理

  Q: 如何等待真实的请求完成?
  A: waitUntil: 这个函数接受一个函数作为参数,这个函数会返回一个bool值,当bool值为true的时候,表示异步调用结束,可以开始执行后面的逻辑了
import React from 'react';
import axios from 'axios';
import waitUntil from 'async-wait-until';
export default class AsyncComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            user: {}
        }
    }
    componentDidMount() {
        this.fetchUser()
            .then(res => {
                if(res){
                    this.setState({ user: res });
                }
            });
    }
    fetchUser() {
        let url = `http://localhost:3000/user?name=james`;
        return axios.get(url).then(res => {
            return res.data;
        }).catch(err => {
            console.error(err);
        });
    }
    render() {
        const { user } = this.state;
        return (
            <div style={{margin: '30px', fontSize: '16px'}}>
                <p className="name">{user.name}</p>
                <p className="age">{user.age}</p>
            </div>
        );
    }
}
describe('test Async', () => {
    it('expect component did mount will trigger re-render', async () => {
        const wrapper = mount(<Async />);
        await waitUntil(() => wrapper.state('user').name === 'james');
        expect(wrapper.find('.name').text()).toBe('james');
        expect(wrapper.find('.age').text()).toBe('30');
    });
});

2.4.2 mock请求的处理

import React from 'react';
import axios from 'axios';
import waitUntil from 'async-wait-until';
const nock = require('nock')
const { shallow, mount } = Enzyme
export default class AsyncComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            user: {
                name: '',
                age: ''
            }
        }
    }
    componentDidMount() {
        this.fetchUser()
            .then(res => {
                if(res){
                    this.setState({ user: res });
                }
            });
    }
    fetchUser() {
        // 注意要保证同源,否则会出现跨域
        let url = `${location.origin}/api/user`;
        return axios.get(url).then(res => {
            return res.data;
        }).catch(err => {
            // console.error(err);
        });
    }
    render() {
        const { user } = this.state;
        return (
            <div style={{margin: '30px', fontSize: '16px'}}>
                <p className="name">{user.name}</p>
                <p className="age">{user.age}</p>
            </div>
        );
    }
}

describe('test Async', () => {
  beforeAll(() => {
    // mock了一个成功的get请求
    nock('http://localhost:3000')
      .get('/api/user')
      .reply(200, {
          "name": "james22",
          "age": '30'
      });
  });
  afterAll(() => {
    // 用于对之前mock的接口进行清理
    nock.cleanAll();
  });
  it('expect component did mount will trigger re-render', async () => {
      const wrapper = mount(<Async />);
      await waitUntil(() => wrapper.state('user').name === 'james22');
      expect(wrapper.find('.name').text()).toBe('james22');
      expect(wrapper.find('.age').text()).toBe('30');
  });
});

describe('test Async', () => {
  let resolve = false;
  beforeAll(() => {
    // mock了一个失败get请求
    nock('http://localhost:3000')
      .get('/api/user')
      .reply(400, () => {
        resolve = true;
      });
  });
  afterAll(() => {
    // 用于对之前mock的接口进行清理
    nock.cleanAll();
  });
  it('expect component fetch error', async () => {
      const wrapper = mount(<Async />);
      await waitUntil(() => resolve);
      expect(wrapper.find('.name').text()).toBe('');
      expect(wrapper.find('.age').text()).toBe('');
  });
});

三. React16 函数组件 + Hooks测试

需要使用到的库是 @testing-library/react-hooks

import { useState, useCallback } from 'react'
import React from 'react'
import { renderHook, act } from '@testing-library/react-hooks'
import Enzyme from '@/test/demo/Enzyme16/enzyme'
const { shallow, render, mount } = Enzyme
const useCounter = () => {
    const [count, setCount] = useState(0);
    const inc = useCallback(() => setCount(x => x + 1), [])
    const dec = useCallback(() => setCount(x => x - 1), [])
    return {
        count,
        inc,
        dec
    }
}
function Counter() {
    const {count, inc, dec} = useCounter();
    return (
        <div>
            <button className="dec" onClick={dec}>-</button>
            <p>{count}</p>
            <button className="inc" onClick={inc}>+</button>
        </div>
    );
}
test('Counter test', () => {
  const wrapper = shallow(<Counter />);
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.inc();
    wrapper.find('.inc').simulate('click');
  });
  expect(result.current.count).toBe(1);
  expect(wrapper.find('p').text()).toBe('1');


  act(() => {
    result.current.dec();
    wrapper.find('.dec').simulate('click');
  });
  expect(result.current.count).toBe(0);
  expect(wrapper.find('p').text()).toBe('0');
});

四. React17 测试

React17 需要使用到的adapter是@wojtekmaj/enzyme-adapter-react-17,使用跟React16系列没有区别

import Enzyme from 'enzyme'; 
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({ adapter: new Adapter() });
export default Enzyme;

系列文章

单元测试--(一)前端测试的简单介绍

单元测试--(二)Jest测试工具介绍

单元测试--(三)React + Enzyme

单元测试--(四)周边生态库介绍