14

携程租车 React Native 单元测试实践

 4 years ago
source link: https://www.infoq.cn/article/AYS6fpGLU7jb9kiXHDkC
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在较大规模的前端项目中,测试对于保证代码质量十分重要,而 React 的组件化和函数式编程, 这种相同输入一定返回相同输出的幂等特性特别适合单元测试。本篇即是 React 和 React Native 项目单元测试的完整方案介绍。

一、技术选型: Jest + Enzyme + react-hooks-testing-library

1.1 jest

Jest 是 FaceBook 出品的前端测试框架,适合用于 React 和 React Native 的单元测试。

有以下几个特点:

  • 简单易用:易配置,自带断言库和 mock 库。
  • 快照测试:能够创造一个当前组件的渲染快照,通过和上次保存的快照进行比较,如果两者不匹配说明测试失败。
  • 测试报告:内置了 Istanbul,通过一定配置可以测试代码覆盖率,生成测试报告。

1.2 Enzyme

Enzyme 是 AirBnb 开源的 React 测试工具库,通过一套简洁的 api,可以渲染一个或多个组件,查找元素,模拟元素交互(如点击,触摸),通过和 Jest 相互配合可以提供完整的 React 组件测试能力。

二、环境配置

直接贴上所需要安装的依赖:

复制代码

"devDependencies": {   
    "@testing-library/react-hooks": "^3.2.1",  //React Hooks 测试支持,仅支持 React 16.9.0 以上
    "babel-jest": "^24.8.0",
    "enzyme": "^3.10.0",
    "enzyme-adapter-react-16": "^1.14.0", // 依据对应 React 版本安装,React 15 需安装 enzyme-adapter-react-15
    "jest": "^24.8.0",
    "jest-junit": "^7.0.0",
    "jest-react-native": "^18.0.0", //RN 支持,非 RN 可以不装 
    "react-test-renderer": "16.9.0", 
    "redux-mock-store": "^1.5.3" //Redux 测试模拟 store
}

根目录下添加 jest.config.js 文件作为配置文件:

复制代码

module.exports = {
  preset: 'react-native',
  globals: { // 模拟的全局变量
    _window: {},
    __DEV__: true,
  },
  setupFiles: ['./jest.setup.js'], // 运行测试前需运行的初始化文件,例子在下方
  moduleNameMapper: { // 需要模拟的静态资源
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
    "\\.(css|less|scss)$": "<rootDir>/__mocks__/stylesMock.js"
  },
  transform: { // 转译配置,RN 项目配置如下,普通 React 项目可以使用 babel-jest
    '^.+\\.js$': '<rootDir>/node_modules/react-native/jest/preprocessor.js',
  },
  testMatch: ['**/__tests__/**/*.(spec|test).js'],// 正则匹配的测试文件
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
  unmockedModulePathPatterns: ['<rootDir>/node_modules/react'],
  collectCoverage: true,
  collectCoverageFrom: [// 生成测试报告时需覆盖测试的文件
    'src/**/*.js',
  ],
  coverageReporters: ['text-summary', 'json-summary', 'lcov', 'html', 'clover'],
  testResultsProcessor: './node_modules/jest-junit',
  transformIgnorePatterns: ['<rootDir>/node_modules/(?!@ctrip|react-native)'], //transform 白名单
};

三、Jest 简单函数单元测试

待测试函数

复制代码

function add(x, y) {
    return x + y;
}

测试文件

复制代码

test('should return 3', () => {
    const x = 1;
    const y = 2;
    const output = 3;
    expect(add(x, y)).toBe(output);
  });
});
  • describe:创造一个块,将一组相关的测试用例组合在一起
  • test:也可以用 it,测试用例
  • expect:使用该函数断言某个值

常用断言

  • toBe:测试是否完全相等
  • toBeCloseTo:浮点数比较
  • toEqual:对象深度比较
  • not:取反
  • toBeNull:匹配 null
  • toBeUndefined:匹配 undefined
  • toBeDefined:与 toBeUndefined 相反
  • toBeTruthy:匹配真
  • toBeFalsy:匹配假
  • toBeGreaterThan:大于
  • toBeGreaterThanOrEqual:大于等于
  • toBeLessThan:小于
  • toBeLessThanOrEqual:小于等于
  • toMatch:正则表达匹配
  • resolves/reject:测试 promise
  • toBeCalled:函数是否被调用
  • toBeCalledWith:函数是否以某些参数为入参被调用
  • assertions:检测用例中有多少个断言被调用,一般用于异步测试

四、Jest 周期函数

在写测试用例之前,可以用四个周期函数进行一些处理:

复制代码

beforeAll(() => {
  console.log('所有测试用例测试之前运行');
});

afterAll(() => {
  console.log('所有测试用例测试完毕后运行');
});

beforeEach(() =>{
  console.log('每个测试用例测试之前运行');
});

afterEach(() => {
  console.log('每个测试用例测试完毕后运行');
});

五、Jest Mock 函数

在单元测试中,有许多对象或函数并不需要真实的引用,因此需要 mock。比如之前提到的初始化文件 jest.setup.js 中,我们会 mock 一些对象:

复制代码

jest.useFakeTimers(); //mock 时间

jest.mock('./src/commons/CViewPort', () => { //mock 一些组件
  return props => {
    return <View {...props}>{props && props.children}</View>;
  };
});

jest.mock('./src/commons/CToast', () => {
  return {
    show: () => {},
  };
});

也可以手动 mock 一些 React Native 组件,在根目录下建立 mocks 文件夹。文件下建立需要 mock 的组件的文件,如建立 InteractionManager.js。

复制代码

const InteractionManager = {
  runAfterInteractions: callback => callback(),
};

module.exports = InteractionManager;

建立好文件后,这样 mock 即可:

复制代码

jest.mock('InteractionManager');

六、Jest UI 快照测试

Jest 提供了 snapshot 快照功能用于 UI 测试,可以创建组件的渲染快照并将其与以前保存的快照进行比较,如果两者不匹配,则测试失败。快照将在测试文件的当前文件路径自动生成的 snapshots 文件夹中保存。当主动修改造成 ui 变化时,使用 jest -u 来更新快照。

复制代码

it('render List', () => {
  const tree = renderer.create(<List {...props} />).toJSON();
  expect(tree).toMatchSnapshot();
});

快照不匹配:

2IzIz22.png!web

七、Jest 异步测试

Jest 单元测试是同步的,因此面对异步操作如 fetch 获取数据,需要进行异步的模拟测试。首先,对 fetch 函数进行 mock:

复制代码

const cityInfo = {
    1: '北京',
    2: '上海'
}

export default function fetch(url, params) {
   return new Promise((resolve, reject) => {
      if (params.cityId && cityInfo[params.cityId]) {
        resolve(cityInfo[params.cityId]);
      } else {
        reject('city not found');
      }
    });
}

接着创建测试用例进行异步测试:

复制代码

it('test cityInfo', async () => {
  expect.assertions(1); // 检测用例中有多少个断言被调用
  const data = await fetch('/cityInfo', {cityId: 1});
  expect(data).toEqual('北京');
});

八、Enzyme 组件测试

复制代码

import { mount, shallow, render } from ‘enzyme';

Enzyme 对测试组件进行渲染分为三种:

  • shallow:浅渲染,仅渲染单个组件,不包括其子组件。这对于隔离组件进行纯单元测试很有用,效率高,可以进行模拟交互,并且从 Enzyme 3 开始也可以访问组件生命周期,所以一般组件测试用 shallow 即可。
  • mount:完整渲染,包括其子组件。因为渲染了真实的 DOM 节点,可以用来测试 DOM API 的交互和组件的生命周期。
  • render:静态渲染,渲染为静态 HTML 字符串,包括子组件,不能访问生命周期,不能模拟交互。

8.1 测试组件模拟交互

复制代码

const onClickLabel = jest.fn();
const label = shallow(<Label filterData={filterData} onClickLabel={onClickLabel} />);

label.childAt(0).find({ eventName: 'click filterLabel' }).simulate('press');
expect(onClickLabel).toBeCalled();

8.2 测试组件内部方法

复制代码

const fliterModal = shallow(<FilterModal {...props} />);
const instance = fliterModal.instance(); // 获取当前组件实例

//jest.spyOn 创建一个 mock 函数,该 mock 函数不仅捕获函数的调用情况,还可以正常的执行被 spy 的函数。
jest.spyOn(instance, '_onClear');

instance.forceUpdate();

fliterModal.childAt(0).simulate('press');
expect(instance._onClear).toBeCalled();// 测试组件实例上的方法是否被调用

九、Redux 测试

在使用 React 或者 React Native 时通常会使用 Redux 进行状态的管理,需要 mock store 进行测试。

复制代码

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { updateList } from '../pages/List/action';

const middlewares = [thunk];
// 引入 redux-mock-store 对 store 进行 mock
const mockStore = configureMockStore(middlewares);

describe('list action test', () => {
  it('updateList test', () => {
    const store = mockStore({ flist: {} });
    const mockData = {
      flist: { afitem: 1 }
    };

    const expectedActions = { type: 'UPDATE_LIST', flist: { afitem: 1 }};

    expect(store.dispatch(updateList(mockData.flist))).toEqual(expectedActions);
  });
});

十、React-Hooks 单元测试

在 React Native v0.59 版本以后,RN 也支持了 React Hooks 的开发,由于 Enzyme 对于 Hooks 的测试支持不理想,我们专门引入了 react-hooks-testing-library 用于 Hooks 的测试。

10.1 安装

复制代码

npm install --save-dev @testing-library/react-hooks

10.2 useState 测试

复制代码

// useCityName.js
import { useState, useCallback } from 'react';
export default function useCityName() {
  const [cityName, setCityName] = useState('北京');
  const format = useCallback(() => setCityName(x => x + '市'), []);
  return { cityName, format };
}


// useCityName.test.js
describe('test useCityName', () => {
  it('should use cityname', () => {
    const { result } = renderHook(() => useCityName());
    expect(result.current.cityName).toBe('北京');
    expect(typeof result.current.format).toBe('function');
  });

  it('should format cityname', () => {
    const { result } = renderHook(() => useCityName());
    act(() => {
      result.current.format();
    });
    expect(result.current.cityName).toBe('北京市');
  });
});

10.3 useEffect 测试

复制代码

// useCityInfo.js
import { useEffect } from 'react';

export default function useCityInfo({ cityInfo, id }) {
  useEffect(() => {
    cityInfo[id] = '北京';
    return () => {
      cityInfo[id] = '上海';
    };
  }, [id]);
}
// useCityInfo.test.js
describe('test useCityName', () => {
  it('should handle useEffect hook', () => {
    const cityInfo = {
      1: '北京',
      2: '上海',
    };

    const { sideEffect, unmount } = renderHook(useCityInfo, { initialProps: { cityInfo, id: 1 } });

    sideEffect({ cityInfo, id: 1 });

    expect(cityInfo[1]).toBe('北京');

    sideEffect({ cityInfo, id: 2 });

    expect(cityInfo[2]).toBe('北京');

    unmount();

    expect(cityInfo[1]).toBe('上海');
    expect(cityInfo[2]).toBe('上海');
  });
});

十一、单元测试覆盖率及 husky 做代码提交检查

Jest 集成了 Istanbul 这个代码覆盖工具并会生成详细报告,执行 jest --coverage 即可生成基于四个维度的覆盖率报告:

MjYZ3eM.png!web

  • 语句覆盖率 (statement)
  • 分支覆盖率 (branches)
  • 函数覆盖率 (functions)
  • 行覆盖率 (lines)

同时我们会配置 husky 在 commit 或者 push 之前添加钩子,在这些动作之前强制执行单元测试,通过测试才可提交到远程代码仓库以保证代码质量。

husky 在 package.json 中的配置:

复制代码

"scripts": {,
    "test": "jest --forceExit --silent"
},
"devDependencies": {
    "husky": "^3.0.9"
},
"husky": {
    "hooks": {
        "pre-push": "npm run test"
    }
},

十二、总结

本篇是 React Native 项目单元测试的一个简单教程,在携程的持续集成流程中再接入 sonar, 可以查看完整的单元测试报告。

在携程租车前端单元测试的实践中,我们总结出几个要点:

  • 将待测试的组件当成黑盒,不用考虑内部逻辑实现;
  • UI 改动频繁,优先保证公用组件,工具函数,核心代码的单元测试;
  • 模拟数据尽量真实;
  • 多考虑边界条件情况;

通过单元测试,给项目带来了不少好处:

  • 通过单元测试可以确保代码得到预期的结果,在测试环境中就发现 bug;
  • 当修改依赖的组件时,能在测试中发现被影响组件的错误,这样可以支持我们更好的重构代码,有利于项目的长期迭代;
  • 良好的单元测试就是一份最好的注释,同时迫使我们写易于测试的函数式代码;

另外我们在写单元测试的时候并不是堆砌覆盖率,而是需要保证功能细节的正确,覆盖率并不是最重要的,单元测试也不是银弹,我们也在结合诸如 airtest 自动化测试等其他测试和手段保证代码的质量。

作者介绍:

琨玮,携程高级前端开发工程师,从事 React Native/Web 前端的开发及维护工作,喜欢研究新技术。

本文转载自公众号携程技术(ID:ctriptech)。

原文链接:

https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697269321&idx=2&sn=d8f9855436b9fa38a1674781a383fcdf&chksm=8376ef7db401666bc73060add2c7c28e0dd63462f9ada06dfc4b54e31870cc44ec183d315a64&scene=27#wechat_redirect


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK