首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

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

在较大规模的前端项目中,测试对于保证代码质量十分重要,而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组件测试能力。

二、环境配置

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

代码语言:javascript
复制
"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文件作为配置文件:

代码语言:javascript
复制
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简单函数单元测试

待测试函数

代码语言:javascript
复制
function add(x, y) {
    return x + y;
}

测试文件

代码语言:javascript
复制
  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 周期函数

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

代码语言:javascript
复制
beforeAll(() => {
  console.log('所有测试用例测试之前运行');
});

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

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

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

五、Jest Mock函数

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

代码语言:javascript
复制
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。

代码语言:javascript
复制
const InteractionManager = {
  runAfterInteractions: callback => callback(),
};

module.exports = InteractionManager;

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

代码语言:javascript
复制
jest.mock('InteractionManager');

六、Jest UI快照测试

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

代码语言:javascript
复制
it('render List', () => {
  const tree = renderer.create(<List {...props} />).toJSON();
  expect(tree).toMatchSnapshot();
});

快照不匹配:

七、Jest 异步测试

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

代码语言:javascript
复制
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');
      }
    });
}

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

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

八、Enzyme 组件测试

代码语言:javascript
复制
import { mount, shallow, render } from ‘enzyme';

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

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

8.1 测试组件模拟交互

代码语言:javascript
复制
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 测试组件内部方法

代码语言:javascript
复制
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进行测试。

代码语言:javascript
复制
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 安装

代码语言:javascript
复制
npm install --save-dev @testing-library/react-hooks

10.2 useState 测试

代码语言:javascript
复制
// 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 测试

代码语言:javascript
复制
// 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即可生成基于四个维度的覆盖率报告:

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

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

husky在package.json中的配置:

代码语言:javascript
复制
"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前端的开发及维护工作,喜欢研究新技术。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/AYS6fpGLU7jb9kiXHDkC
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券