jest + enzyme 单元测试

1,172 阅读2分钟

官方文档

jest

enzyme

class 组件

import React from 'react'
import { Button, Space} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'

export default class CountDownButton extends React.Component {

    constructor(proos) {
      super(proos);
      this.state = {
        isRun: false,
        time: 60
      }
    }
  
    render() {
      const { isRun, time } = this.state;
      const { style, type, size } = this.props;
  
      return (
        isRun ? (
          <Button
            style={style}
            type={type}
            size={size}
            disabled={true}
          >{`${time} Get Latest Data`}
          </Button>) : (
          <Button
            style={style}
            type={type}
            size={size}
            icon={<ReloadOutlined />}
            onClick={this.onClickRefresh}
          >{`Get Latest Data`}
          </Button>
        )
      )
    }
  
    onClickRefresh = () => {
      this.setState({
        isRun: true,
        time: 60
      }, () => {
        this.props.onClick?.()
        this.timer && clearInterval(this.timer);
        this.timer = setInterval(() => {
          if (this.state.time == 1) {
            clearInterval(this.timer);
            this.setState({ isRun: false });
          } else {
            this.setState({ time: this.state.time - 1 });
          }
        }, 1000);
      });
    }
  
    componentWillUnmount() {
      this.timer && clearInterval(this.timer);
    }
  }

测试用例

import React from 'react'
import { shallow, configure } from 'enzyme'
import CountDownButton from './CountDownButton'
import Adapter from 'enzyme-adapter-react-16'
import renderer from 'react-test-renderer'

const setup = () => {
    return (
        <CountDownButton />
    )
}

describe('>>>CountDownButton --- event', () => {
    jest.useFakeTimers()
    it('+++ onClick', () => {
        let wrapper = shallow(setup())
        let instance = wrapper.instance()
        let btn = wrapper.find('Button')
        
        btn.simulate('click') 
        // 在某些不方便获取元素的情况下可以直接 instance.function()
        const counts = wrapper.state().time
        expect(instance.state.isRun).toBeTruthy()
        expect(instance.state.time).toBe(60)
        for (let i = 0; i < counts; i++) {
            jest.runOnlyPendingTimers()
        }
        expect(instance.state.isRun).toBeFalsy()
        expect(instance.state.time).toBe(1)

        // 模拟 willUnmount生命周期函数
        wrapper.unmount()
    })
})

通过 wrapper.props(), wrapper.state() 等同于 instance.props 和 instance.state 上述代码可以简写为

const { props, state, onClickRefresh} = instance
props.style
state.isRun
onClickRefresh()

如果是采用了 instance.onClickRefresh() 而不是 btn.simulate('click'), 是有隐患的,btn点击事件变动为 onClick 方法,这时就会出现单测也需要改为 instance.onClick();如果采用后者 simulate 方式就不需要改动

其实 react 官方是推荐使用 [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro) ,和 enzyme 相比是更注重组件的内容(展示)是否正确, 所以这个库没有办法获取组件的props、state等实现细节。更多两个库的区别可以看这里

在class组件里是可以缺啥补啥, 比如可以有以下代码,以便解决 antd form组件的 ref 找不到的问题,

instance.formRef = {
        current: {
          resetFields: jest.fn(),
          getFieldValue: jest.fn(),
        },
      }; 

不过类似这种ref实例找不到问题,通过这种方式解决并不是太好,有点头痛医头的意思,有大佬知道更好方法的话,希望能请教下

函数组件

import React, { useEffect, useState } from "react";
import { Select } from "antd";
const { Option } = Select;
import { FormSelectItem } from "./FormItem";
import {request} from "../utils/request";
import { Observer, useLocalObservable } from "mobx-react";
import store from '../lib/store'
import { getTranslate } from "../utils";
const FormAccountItem = (props) => {
  const STORE = useLocalObservable(() => store) || {}
  const [dymaicOptions, setDymaicOptions] = useState([]);
  useEffect(() => {
    let mounted = true;
    async function fetchData() {
      const params = {
        pageNo: 1,
        pageSize: 1000000,
      };
      const { accountList = [] } = await request("accountList", params,STORE);
      mounted && setDymaicOptions(accountList);
    }
    fetchData();
    return () => {
      mounted = false;
    };
  }, []);

  const onValueChange = (value) => {
    const item = dymaicOptions.find((item) => item.accountId === value);
    props.onChange(item);
  };

  return (
    <FormSelectItem
      name={"accountId"}
      label={"Account"}
      rules={[{ required: true}]}
      {...props}
      onChange={onValueChange}
    >
      {dymaicOptions.map((item) => {
        const { accountId, accountName } = item;
        return (
          <Option key={accountId} value={accountId}>
            {accountName}
          </Option>
        );
      })}
    </FormSelectItem>
  );
};

export default FormAccountItem;

单元测试

import React from "react";
import { mount } from "enzyme";
import FormAccountItem from "../src/components/FormAccountItem";
import { request } from "../src/utils/request";
import { Form } from "antd";
import { accountResponse } from "./mocks/data";
import { act } from "react-dom/test-utils";

jest.mock("../src/utils/request");

const setupFormItem = (component) => {
  return <Form>{component}</Form>;
};
describe(">>>FormAccountItem --- ", () => {
  it("+++ render ", async () => {
    await act(async () => {
      request.mockResolvedValue(accountResponse);
      const changeFn = jest.fn();
      let container = mount(
        setupFormItem(<FormAccountItem onChange={changeFn} />)
      );
      expect(container.length).toEqual(1);
      container.unmount();
    });
  });
});

函数组件和class组件在单测时相比,函数组件是获取不到instance、props、state的,所以想采用 instance.func() 偷懒的方式是不行的, 只能通过 simulate, 但在有些情况下会遇到一些问题 比如 antd 的select 组件,并不能找到 Option项,上面的 onValueChange 就不能被覆盖到。

注意点:

  1. useEffect 里的 fetchData 代码直接提取出来,test会有 Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. 问题,所以 fetchData 的封装
  2. container.unmount() 才会执行 useEffect 的 cleanup
  3. useEffect里的 setDymaicOptions 如果不在 act 里 会有 When testing, code that causes React state updates should be wrapped into act(...): act(() => { /* fire events that update state */ });
  1. 当一个组件里有多个网络请求模拟的时候,请参照 多个网络请求返回模拟
  1. 验证是否setDymaicOptions(setState)被调用

    import * as reactModule from "react";
    import { shallow } from "enzyme";
    import MultipleStatesComponent from "./MultipleStatesComponent";
    
    describe("test multiple state in component", () => {
      let wrapper;
      let setDataLength;
      let setLoading;
      let setText;
    
      beforeEach(() => {
        setDataLength = jest.fn(x => {});
        setLoading = jest.fn(x => {});
        setText = jest.fn(x => {});
    
        reactModule.useState = jest
          .fn()
          .mockImplementationOnce(x => [x, setDataLength])
          .mockImplementationOnce(x => [x, setLoading])
          .mockImplementationOnce(x => [x, setText]);
    
        wrapper = shallow(<MultipleStatesComponent />);
      });
    
      it("should test button one", () => {
        wrapper
          .find("button")
          .at(0)
          .simulate("click");
    
        expect(setDataLength).toHaveBeenCalledWith(10);
      });
    
  1. 对useEffect 的模拟

    import * as reactModule from "react";
    import { shallow } from "enzyme";
    import EffectComponent from "./EffectComponent";
    
    describe("test App Component", () => {
      it("should call the logic in useEffect", () => {
        const setDataSize = jest.fn(size => {});
        reactModule.useState = jest.fn(initialDataSize => [
          initialDataSize,
          setDataSize
        ]);
    
        reactModule.useEffect = jest.fn((effectLogic, triggers) => effectLogic());
        wrapper = shallow(<EffectComponent />);
        expect(setDataSize).toHaveBeenCalledWith(3);
      });
    });
    

参考资料

react function component testing