SunXinFei / sunxinfei.github.io

前后端技术相关笔记,已迁移到 Issues 中
https://github.com/SunXinFei/sunxinfei.github.io/issues
32 stars 3 forks source link

前端单元测试二三事 #27

Open SunXinFei opened 4 years ago

SunXinFei commented 4 years ago

好处

单元测试的好处就不用多说了,对于敏捷开发的迭代需求或者业务逻辑的重构,有了单元测试之后非常方便的担保业务逻辑平滑过渡,而且单元测试的case的存在,可以有效的说明逻辑,比代码注释更为清晰。在MVVM框架流行的今天,数据驱动DOM使得单元测试更加重要而且可行性更高。

痛点

前端单元测试推动是一直有痛点的,包括一些大厂对前端单元测试这个态度不是很统一。原因主要概括为两点:

  1. 相较于后端单元测试的断言非常清晰,期望的数据通过接口参数不同的调用,而前端除了数据的变化的业务逻辑外,还主要涉及到DOM的操作、变换、展示,随着业务线发展,页面一旦重新变化,case工作白做。
  2. 很多业务部门的业务线生命周期很短,还没写完单元case就已经死掉,转战了业务线,前期更多关注的是业务线的0-1的过程。

从而导致业务线前期稳定性靠开发者把控,后面项目逐渐庞大,测试用例又没有时间补贴,或者开发者转了战场。

折中

比较好的处理方式是折中方案,Utils工具类中的方法必须进行单元测试,业务基础组件和项目的基础业务逻辑必须进行单元测试,这样可以很好的避免后期基础的逻辑,手动痛苦地回归case。而目前看来,前端很多项目也确实是这么做的。其他的case则视重要性与时间代价视情况而定了。

SunXinFei commented 4 years ago

Jest在React项目

Jest是FB家的开源的单元测试的工具,对React项目支持非常友好。 jest运行时,如输入值和期望值如果不相符,清晰地标记显示出,方便开发者调试: image

jest支持localStorage

如果单元测试用例的组件中有localStorage的使用时,会有localstorage is not defined的错误或者调用getItem of null等方法不能使用, 我们建一个setUpTest.js内容如下:

import 'jsdom-global/register';

// browserMocks.js
const localStorageMock = (() => {
  let store = {};

  return {
    getItem(key) {
      return store[key] || null;
    },
    setItem(key, value) {
      store[key] = value.toString();
    },
    clear() {
      store = {};
    },
  };
})();

global.localStorage = localStorageMock;

然后在jest.config.js中配置

setupFilesAfterEnv: ['./tests/setupTests.js'],

jest中的XX_ENV配置

jest.config.js中配置

globals: {
    REACT_APP_ENV: 'dev'
 },

jest支持dva/redux等

这一类的配置有点类似,在业务组件的测试文件如Tag.test.js,注意亮点一个是标签为Tag.WrappedComponent,属性传递的即为Tag组件需要的store中的对象

import { shallow } from 'enzyme';
import React from 'react'; //必须要有,防止jsx在测试环境报错
import Tag from './index';

test('Tag test', () => {
  const wrapper = shallow(<Tag.WrappedComponent currentUser={{}} crowdList={[]} />);
  expect(wrapper.state('activeKey')).toBe('1');
});

jest测试组件内部方法

import { shallow } from 'enzyme';
import React from 'react'; //必须要有,防止jsx在测试环境报错
import Tag from './index';

test('Tag test', () => {
  const wrapper = shallow(<Tag.WrappedComponent currentUser={{}} crowdList={[]} />);
  const instance = wrapper.instance();//获取组件的实例
  expect(instance.addFn(2)).toEqual(3);
});

render、mount、shallow的区别

enzyme有3种渲染方式:render、mount、shallow; render采用的是第三方库Cheerio的渲染,渲染结果是普通的html结构,对于snapshot使用render比较合适。

shallow和mount对组件的渲染结果不是html的dom树,而是react树,如果你chrome装了react devtool插件,他的渲染结果就是react devtool tab下查看的组件结构,而render函数的结果是element tab下查看的结果。

这些只是渲染结果上的差别,更大的差别是shallow和mount的结果是个被封装的ReactWrapper,可以进行多种操作,譬如find()、parents()、children()等选择器进行元素查找;state()、props()进行数据查找,setState()、setprops()操作数据;simulate()模拟事件触发。

shallow只渲染当前组件,只能能对当前组件做断言;mount会渲染当前组件以及所有子组件,对所有子组件也可以做上述操作。一般交互测试都会关心到子组件,使用的都是mount。但是mount耗时更长,内存占用的更多。

jest快照

snapshot是使用render方法,并生成一个文件夹,每次运行进行前后的“快照”对比,这里的“快照”要单独说明一下,不是照片,在不toJSON情况下指的是生成的类似于AST(抽象语法树)的json结构,

import { render } from 'enzyme';
import React from 'react'; //必须要有,防止jsx在测试环境报错
import Tag from './index';

test('Tag test', () => {
  const wrapper = render(<Tag.WrappedComponent currentUser={{}} crowdList={[]} />);
  expect(wrapper).toMatchSnapshot();
});

jest,enzyme支持jsx中import的css变量与click事件

enzyme默认是支持jsx中css的普通写法的,但是如果组件内部是使用的import的形式,比如:

<div className={styles.add_intersect} onClick={this.addNewGroup}>
       新增组           
</div>

上面这种情况下,我们使用enzyme的find等选择器是获取不到dom元素的,这里我们需要借助 identity-obj-proxy

  1. jest.config.js中添加配置:
    moduleNameMapper: {
    '\\.(css|less|sass|scss)$': 'identity-obj-proxy',
    },
  2. 测试逻辑为:
    import { shallow, mount } from 'enzyme';
    test('click action', () => {
    const wrapper = mount(<Tag.WrappedComponent currentUser={{}} crowdList={[]} />);
    wrapper
    .find('.add_intersect')
    .first()
    .simulate('click');
    expect(wrapper.state('activeObj')).toEqual({
    ext: 'inter',
    index: 1,
    });
    });

    参考:

https://github.com/facebook/jest/issues/3094

jest解决window.matchMedia is not a function 或 antdesign中The above error occurred in the <Row> component

setupTests.js中添加下面代码即可,mock出matchMedia:

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

参考

https://github.com/ant-design/ant-design/issues/21096

jest解决alias路径问题

module.exports = {
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
}
}

将这个配置进行添加,这样jest中就可以支持@/utils/utils.js

jest解决Attempted to log "Warning: An update to ForwardRef(TabNavList) inside a test was not wrapped in act(...).``This ensures that you're testing the behavior the user would see in the browser.

setupTests.js中添加如下代码


const mockConsoleMethod = (realConsoleMethod) => {
const ignoredMessages = [
'test was not wrapped in act(...)',
]

return (message, ...args) => { const containsIgnoredMessage = ignoredMessages.some(ignoredMessage => message.includes(ignoredMessage))

if (!containsIgnoredMessage) {
  realConsoleMethod(message, ...args)
}

} }

console.warn = jest.fn(mockConsoleMethod(console.warn)) console.error = jest.fn(mockConsoleMethod(console.error))

参考:
https://github.com/enzymejs/enzyme/issues/2073
## jest解决puppeteer安装失败导致`Browser is not downloaded`的问题
在`jest-puppeteer.config.js`中添加如下配置,将运行的browser指向本机的chrome浏览器

launch: { executablePath: '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome', }

SunXinFei commented 4 years ago

jest在Vue项目

防止element-ui报错找不到:"Unknown custom element: - did you register the component correctly"

const localVue = createLocalVue() localVue.use(ElementUI)

test("washSaveData", () => { Cases2WashSaveData.forEach((caseData) => { const wrapper = shallowMount(Rules,{localVue}, {}); expect(wrapper.vm.washSaveData(caseData.test)).toStrictEqual(caseData.expect); }); });


-  `npm i --save-dev identity-obj-proxy` 安装

- jest.config.js添加样式代理
```js
module.exports = {
  preset: "@vue/cli-plugin-unit-jest",
  moduleNameMapper: {
    '\\.(css|less|sass|scss)$': 'identity-obj-proxy',
  },
};

router-link “Unknown custom element: - did you register the component correctly”

stubs: ['router-link']

 const wrapper = shallowMount(Rules,{localVue, stubs: ['router-link']}, {});

参考: https://stackoverflow.com/questions/49681546/vue-test-utils-unknown-custom-element-router-link

支持element-ui组件的事件测试

let actions let store

beforeEach(() => { actions = { setMenuExpand: jest.fn() } store = new Vuex.Store({ state: { menuExpand: false, }, actions }) }) /*addNewGroup的click事件 / test("addNewGroup", () => { const wrapper = mount(Rules, { localVue,store, stubs: ["router-link"] }); });

参考:https://vue-test-utils.vuejs.org/zh/guides/#%E5%9C%A8%E7%BB%84%E4%BB%B6%E4%B8%AD%E6%B5%8B%E8%AF%95-vuex
## 相对较全的demo
```js
import ElementUI from "element-ui";
import { shallowMount, createLocalVue, mount } from "@vue/test-utils";
import Rules from "@/views/rules.vue";
import Vuex from "vuex";

const localVue = createLocalVue();
localVue.use(ElementUI);
localVue.use(Vuex);

let actions;
let store;

beforeEach(() => {
  actions = {
    setMenuExpand: jest.fn(),
  };
  store = new Vuex.Store({
    state: {
      menuExpand: false,
    },
    actions,
  });
});

/**addNewGroup的click事件 */
test("addNewGroup", () => {
  const wrapper = mount(Rules, { localVue, store, stubs: ["router-link"] });
  //初始化state数据
  wrapper.setData({
    ruleObj: { include: [[]], exclude: [[]] },
  });
  //寻找Dom
  const button = wrapper.find(".add-group");
  //Dom文本
  expect(button.text()).toBe("Btn");
  //事件触发
  button.trigger("click");
  //state数据
  expect(wrapper.vm.ruleObj).toStrictEqual({
    include: [[], []],
    exclude: [[]],
  });
});
SunXinFei commented 2 years ago

THREE等webgl工程单元测试