Categories
程式開發

攜程租車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組件測試能力。

二、環境配置

直接貼上所需要安裝的依賴:

"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)$": "/__mocks__/stylesMock.js"
  },
  transform: { //转译配置,RN项目配置如下,普通React项目可以使用babel-jest
    '^.+\.js$': '/node_modules/react-native/jest/preprocessor.js',
  },
  testMatch: ('**/__tests__/**/*.(spec|test).js'),//正则匹配的测试文件
  moduleFileExtensions: ('ts', 'tsx', 'js', 'jsx', 'json'),
  unmockedModulePathPatterns: ('/node_modules/react'),
  collectCoverage: true,
  collectCoverageFrom: (//生成测试报告时需覆盖测试的文件
    'src/**/*.js',
  ),
  coverageReporters: ('text-summary', 'json-summary', 'lcov', 'html', 'clover'),
  testResultsProcessor: './node_modules/jest-junit',
  transformIgnorePatterns: ('/node_modules/([email protected]|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 {props && props.children};
  };
});

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().toJSON();
  expect(tree).toMatchSnapshot();
});

快照不匹配:

攜程租車React Native單元測試實踐 1

七、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(

8.2 測試組件內部方法

const fliterModal = shallow();
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即可生成基於四個維度的覆蓋率報告:

攜程租車React Native單元測試實踐 2

  • 語句覆蓋率(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