React Native單元測試

概述

所謂單元測試,就是對每一個單元進行的測試,通常針對的是函數、類或單個組件,不涉及系統和集成,單元測試是軟件測試的基礎測試,一個完備的軟件系統都會涉及到單元測試。node

目前,Javascript的測試工具不少,可是針對React的測試主要使用的是Facebook推出的Jest框架,Jest是基於Jasmine的JavaScript測試框架,具備上手容易、快速、可靠的特色,是React.js默認的單元測試框架。相比其餘的測試框架,Jest具備以下的一些特色:react

  • 適應性:Jest是模塊化、可擴展和可配置的;
  • 沙箱和快速:Jest虛擬化了JavaScript的環境,能模擬瀏覽器,而且並行執行;
  • 快照測試:Jest可以對React 樹進行快照或別的序列化數值快速編寫測試,提供快速更新的用戶體驗;
  • 支持異步代碼測試:支持promises和async/await;
  • 自動生成靜態分析結果:不只顯示測試用例執行結果,也顯示語句、分支、函數等覆蓋率。

環境搭建

安裝Jest

首先,在項目目錄下使用下面的命令安裝Jest。ios

npm install --save-dev jest
//或者
yarn add --dev jest

若是你使用的是react-native init命令行方式來建立的RN項目,且RN版本在0.38以上,則無需手動安裝,系統在生成項目的時候會自動添加依賴。es6

"scripts": {
 "test": "jest"
},
"jest": {
 "preset": "react-native"
}

配置Babel

如今不少的項目都使用es6及以上版本編寫,爲了兼容老版本,咱們可使用Babel來將es5的語法轉換爲es6。使用Babel前,咱們須要使用以下的命令來安裝Babel。正則表達式

yarn add --dev babel-jest babel-core regenerator-runtime

說明:若是使用的是Babel 的version 7則須要安裝babel-jest, babel-core@^7.0.0-bridge.0 和 @babel/core,安全命令以下:shell

yarn add --dev babel-jest babel-core@^7.0.0-bridge.0 @babel/core regenerator-runtime

而後在項目的根目錄裏添加 .babelrc 文件,在文件中配置以下react-native腳本內容。npm

{
  "presets": ["react-native"],
  "sourceMaps":true  // 用於對齊堆棧,精準的定位單元測試中的問題
}

若是是自動生成的, .babelrc 文件的配置腳本以下:json

{
  "presets": ["module:metro-react-native-babel-preset"]
}

此時,須要將上面的presets配置修改成 "presets": ["react-native"]。axios

完整配置

爲了方便查看, 下面是package.json文件的完整配置:react-native

{
  "name": "jestTest",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest"
  },
  "dependencies": {
    "react-native": "0.55.4",
    "react": "^16.6.0",
    "react-dom": "^16.6.0"
  },
  "devDependencies": {
    "babel-core": "^6.26.3",
    "babel-jest": "^23.6.0",
    "jest": "23.6.0",
    "metro-react-native-babel-preset": "0.48.3",
    "react-test-renderer": "16.5.0",
    "regenerator-runtime": "^0.12.1"
  },
  "jest": {
    "preset": "react-native",
    "transform": {
      "^.+\\.js$": "babel-jest"
    },
    "transformIgnorePatterns": [
      "node_modules/(?!(react-native)/)"
    ]
  }
}

說明:若是報AccessibilityInfo錯誤,請注意react-naitve的版本號,由於react-naitve的版本和其餘庫存在一些兼容問題,請使用0.55.4及如下穩定版本。

Cannot find module 'AccessibilityInfo' (While processing preset: "/Users/xiangzhihong029/Documents/rn/jestTest/node_modules/react-native/Libraries/react-native/react-native-implementation.js")

Enzyme

Enzyme 是 Airbnb 公司開源的測試工具庫,是react-addons-test-utils的封裝的產品,它模擬了 jQuery 的 API,很是直觀而且易於使用和學習,提供了一些不同凡響的接口和幾個方法來減小測試的樣板代碼,方便你判斷、操縱和遍歷 React Components 的輸出,而且減小了測試代碼和實現代碼之間的耦合。相比react-addons-test-utils,enzyme的API 就一目瞭然,下表是兩個框架經常使用的函數的對比。
在這裏插入圖片描述
Enzyme提供了三種渲染方法:

shallow

shallow 方法就是對官方的 Shallow Rendering 的封裝,淺渲染在將一個組件做爲一個單元進行測試的時候很是有用,能夠確保你的測試不會去間接斷言子組件的行爲。shallow 方法只會渲染出組件的第一層 DOM 結構,其嵌套的子組件不會被渲染出來,從而使得渲染的效率更高,單元測試的速度也會更快。例如:

import { shallow } from 'enzyme'

describe('Enzyme Shallow', () => {
  it('App should have three <Todo /> components', () => {
    const app = shallow(<App />)
    expect(app.find('Todo')).to.have.length(3)
  })
}

mount

mount 方法則會將 React 組件渲染爲真實的 DOM 節點,特別是在依賴真實的 DOM 結構必須存在的狀況下,好比說按鈕的點擊事件。
徹底的 DOM 渲染須要在全局範圍內提供完整的 DOM API, 這也就意味着它必須在至少「看起來像」瀏覽器環境的環境中運行,若是不想在瀏覽器中運行測試,推薦使用 mount 的方法是依賴於一個名爲 jsdom 的庫,它本質上是一個徹底在 JavaScript 中實現的 headless 瀏覽器。
mount渲染方式的示例以下:

import { mount } from 'enzyme'

describe('Enzyme Mount', () => {
  it('should delete Todo when click button', () => {
    const app = mount(<App />)
    const todoLength = app.find('li').length
    app.find('button.delete').at(0).simulate('click')
    expect(app.find('li').length).to.equal(todoLength - 1)
  })
})

render

render 方法則會將 React 組件渲染成靜態的 HTML 字符串,返回的是一個 Cheerio 實例對象,採用的是一個第三方的 HTML 解析庫 Cheerio。這個 CheerioWrapper 能夠用於分析最終結果的 HTML 代碼結構,它的 API 跟 shallow 和 mount 方法的 API 都保持基本一致。

import { render } from 'enzyme'

describe('Enzyme Render', () => {
  it('Todo item should not have todo-done class', () => {
    const app = render(<App />)
    expect(app.find('.todo-done').length).to.equal(0)
    expect(app.contains(<div className="todo" />)).to.equal(true)
  })
})

Jest單元測試

簡單示例

首先,咱們在項目的根目錄新建一個名爲__test__的文件夾,而後編寫一個組件,例如:

import React, {Component} from 'react';
import {
    Text, View,
} from 'react-native';

export default class JestTest extends Component{
    render() {
        return(<View />)
    }
}

而後,咱們在__test__文件夾下編寫一個名爲jest.test.js的文件,代碼以下:

import React from 'react';
import JestTest from '../src/JestTest';

import renderer from 'react-test-renderer';

test('renders correctly', () => {
    const tree = renderer.create(<JestTest/>).toJSON();
    expect(tree).toMatchSnapshot();
});

使用命令 「yarn jest」 ,系統就會開始執行單元測試,若是沒有任何錯誤,將會顯示PASS相關的信息。
在這裏插入圖片描述
固然,上面的例子並無涉及到任何的業務邏輯,只是介紹了下在React Native中如何使用Jest進行單元測試。

生成快照測試

快照測試是第一次運行測試的時候在不一樣狀況下的渲染結果(掛載前)保存的一份快照文件,後面每次再運行快照測試時,都會和第一次的比較,除非使用「npm test -- -u」命令從新生成快照文件。

爲了測試快照測試,咱們先新建一個帶有邏輯的組件。例如:

import React, {Component} from 'react';
import {
    Text, View,
    Button
} from 'react-native';

export default class JestTest extends Component{

    constructor() {
        super();
        this.state = {liked: false};
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        return this.setState({
            liked: !this.state.liked
        });
    }

    render() {
        const text = this.state.liked ? 'like' : 'not liked';
        return (<Text onClick={this.handleClick}>
            You {text} this.Click to toggle.
        </Text>);
    }
}

上面的組件擁有三種狀態,初始狀態,點擊狀態,以及再次被點擊的狀態,因此在測試文件中,咱們分別生成三種狀態的快照,快照測試文件的代碼以下:

import React from 'react';
import renderer from 'react-test-renderer';
import JestTest from "../src/JestTest";

describe('<JestTest/>', () => {
    it('Snapshot', () => {
        const component = renderer.create(<JestTest/>);

        let snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot();

        snapshot.props.onClick();
        snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot();

        snapshot.props.onClick();
        snapshot = component.toJSON();
        expect(snapshot).toMatchSnapshot()
    });
});

而後,在控制檯運行yarn jest命令,就會看到在__tests___snapshots_目錄下看到快照測試,快照測試文件的代碼以下:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<JestTest/> Snapshot 1`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  not liked
   this.Click to toggle.
</Text>
`;

exports[`<JestTest/> Snapshot 2`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  like
   this.Click to toggle.
</Text>
`;

exports[`<JestTest/> Snapshot 3`] = `
<Text
  accessible={true}
  allowFontScaling={true}
  ellipsizeMode="tail"
  onClick={[Function]}
>
  You 
  not liked
   this.Click to toggle.
</Text>
`;

若是須要更新快照文件,執行yarn test -- -u命令。

DOM測試

DOM測試主要測試組件生成的 DOM 節點是否符合預期,好比響應事件以後,組件的屬性與狀態是否符合預期。DOM 測試 依賴於官方的 TestUtil,因此須要安裝react-addons-test-utils依賴庫,安裝的時候注意版本的兼容問題。不過在實戰過程當中,我發現react-addons-test-utils會報不少錯誤,而且官方文檔也不是很友好。

這裏推薦使用airbnb開源的Enzyme 腳手架,Enzyme是由 airbnb 開發的React單測工具,它擴展了React的TestUtils,並經過支持相似jQuery的find語法能夠很方便的對render出來的結果作各類斷言,開發體檢十分友好。

生成測試報告

使用命令yarn test -- --coverage就能夠生成測試覆蓋報告。如圖:
在這裏插入圖片描述
同時,還會在根目錄生成一個名爲 coverage 的文件夾,是測試覆蓋報告的網頁版,包含更多,更詳細的信息。

Jest基礎語法

匹配器

匹配器用於測試輸入輸出的值是否符合預期,下面介紹一些常見的匹配器。

普通匹配器

最簡單的測試值的方法就是看值是否精確匹配,使用的是toBe(),例如:

test('two plus two is four', () => {
  expect(2 + 2).toBe(4);
});

toBe()使用的是JavaScript中的Object.is(),屬於ES6中的特性,因此不能檢測對象,若是要檢測對象的值的話,須要用到toEqual。

test('object assignment', () => {
  const data = {one: 1};
  data['two'] = 2;
  expect(data).toEqual({one: 1, two: 2});
});

Truthiness

在實際的測試中,有時候咱們須要明確區分undefined、null和false等狀況,而Jest提供的下面的一些規則能夠幫咱們完成上面的需求。

  • toBeNull只匹配null
  • toBeUndefined只匹配undefined
  • toBeDefine與toBeUndefined相反
  • toBeTruthy匹配任何if語句爲真
  • toBeFalsy匹配任何if語句爲假

數字匹配器

toBeGreaterThan():大於
toBeGreaterThanOrEqual():大於或者等於
toBeLessThan():小於
toBeLessThanOrEqual():小於或等於
注:對比兩個浮點數是否相等,使用的是toBeCloseTo()而不是toEqual()。

例子:

test('two plus two', () => {
  const value = 2 + 2;
  expect(value).toBeGreaterThan(3);
  expect(value).toBeGreaterThanOrEqual(3.5);
  expect(value).toBeLessThan(5);
  expect(value).toBeLessThanOrEqual(4.5);

  // toBe and toEqual are equivalent for numbers
  expect(value).toBe(4);
  expect(value).toEqual(4);
});
test('兩個浮點數字相加', () => {
  const value = 0.1 + 0.2;
  //expect(value).toBe(0.3);           這句會報錯,由於浮點數有舍入偏差
  expect(value).toBeCloseTo(0.3); // 這句能夠運行
});

字符串

使用toMatch()函數測試字符串,傳遞的參數須要是正則表達式。例如:

test('there is no I in team', () => {
  expect('team').not.toMatch(/I/);
});

test('but there is a "stop" in Christoph', () => {
  expect('Christoph').toMatch(/stop/);
});

數組

若是要檢測某個字符串是否包含某個字符串或字符,可使用toContain()。例如:

const list = [
    'diapers',
    'kleenex',
    'trash bags',
    'paper towels',
    'beer',
];

test('字符串包含', () => {
    expect(list).toContain('beer');
});

toThrow

若是想在測試特定函數的時候拋出錯誤,則能夠在它調用的時候可使用toThrow()。

異步函數

在實際開發過程當中,常常會遇到一些異步的JavaScript代碼。當有異步方式運行的代碼的時候,Jest須要知道當前它測試的代碼是否已經完成,而後它才能夠轉移動另外一個測試。也就是說,測試的用例必定要在測試對象結束以後纔可以運行。異步測試有多種手段:

回調

回調函數和異步沒有必然的聯繫,回調只是異步的一種調用方式而已。如今假設一個fetchData(call)函數,獲取一些數據並在完成的時候調用call(data),咱們想要測試返回的數據是否是包含字符串'peanut butter',那麼咱們能夠這樣寫:

function fetchData(call) {
  setTimeout(() => {
    call('peanut butter1')
  },1000);
}

test('the data is peanut butter', (done) => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done()
  }
  fetchData(callback);
});

Promise

Promise表示「承諾未來會執行」的對象,基礎內容能夠參考廖雪峯的Promise。例如,仍是上面的fetchData,咱們使用Promise代替回調來實現網絡請求。則測試代碼寫法以下:

test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

上面,咱們使用expect.assertions來驗證必定數量的斷言是否被調用,若是想要Promise被拒絕,咱們可使用.catch方法。

test('the fetch fails with an error', () => {
  expect.assertions(1);
  return fetchData().catch(e => expect(e).toMatch('error'));
});

Async/Await

Async/Await是一種新的異步請求實現方式,若要編寫async測試,只須要在函數前面使用async關鍵字便可。例如:

test('the data is peanut butter', async () => {
  expect.assertions(1);
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

test('the fetch fails with an error', async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (e) {
    expect(e).toMatch('error');
  }
});

Jest Object

在寫測試的時候,咱們常常須要進行測試以前作一些準備工做。例如,屢次測試重複設置的工做,可使用beforeEach和afterEach。

beforeEach(() => {
  jest.resetModules();
});

test('moduleName 1', () => {
  jest.doMock('../moduleName', () => {
    return jest.fn(() => 1);
  });
  const moduleName = require('../moduleName');
  expect(moduleName()).toEqual(1);
});

test('moduleName 2', () => {
  jest.doMock('../moduleName', () => {
    return jest.fn(() => 2);
  });
  const moduleName = require('../moduleName');
  expect(moduleName()).toEqual(2);
});

在某些狀況下,若是隻須要在文件的開頭作一次設置,則可使用beforeAll和afterAll來處理。

beforeAll(() => {
  return initializeCityDatabase();
});

afterAll(() => {
  return clearCityDatabase();
});

test('city database has Vienna', () => {
  expect(isCity('Vienna')).toBeTruthy();
});

test('city database has San Juan', () => {
  expect(isCity('San Juan')).toBeTruthy();
});

做用域

默認狀況下,before和after的塊能夠應用到文件中的每個測試。此外能夠經過describe塊來將將測試中的某一塊進行分組,當before和after的塊在describe塊內部的時候,則只適用於該describe塊內的測試。例如:

describe('arrayContaining', () => {
  const expected = ['Alice', 'Bob'];
  it('matches even if received contains additional elements', () => {
    expect(['Alice', 'Bob', 'Eve']).toEqual(expect.arrayContaining(expected));
  });
  it('does not match if received does not contain expected elements', () => {
    expect(['Bob', 'Eve']).not.toEqual(expect.arrayContaining(expected));
  });
});

Jest測試之Mock

mock測試就是在測試過程當中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來建立以便繼續進行測試的測試方法。Mock函數一般會提供如下三種特性:

  • 捕獲函數調用狀況;
  • 設置函數返回值;
  • 改變函數的內部實現

本節,咱們主要介紹與 Mock 函數相關的幾個API,分別是jest.fn()、jest.spyOn()、jest.mock()。

jest.fn()

jest.fn()是建立Mock函數最簡單的方式,若是沒有定義函數內部的實現,jest.fn()會返回undefined做爲返回值。例如:

// functions.test.js

test('測試jest.fn()調用', () => {
  let mockFn = jest.fn();
  let result = mockFn(1, 2, 3);

  // 斷言mockFn的執行後返回undefined
  expect(result).toBeUndefined();
  // 斷言mockFn被調用
  expect(mockFn).toBeCalled();
  // 斷言mockFn被調用了一次
  expect(mockFn).toBeCalledTimes(1);
  // 斷言mockFn傳入的參數爲1, 2, 3
  expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
})

jest.fn()所建立的Mock函數還能夠設置返回值,定義內部實現或返回Promise對象。

// functions.test.js

test('測試jest.fn()返回固定值', () => {
  let mockFn = jest.fn().mockReturnValue('default');
  // 斷言mockFn執行後返回值爲default
  expect(mockFn()).toBe('default');
})

test('測試jest.fn()內部實現', () => {
  let mockFn = jest.fn((num1, num2) => {
    return num1 * num2;
  })
  // 斷言mockFn執行後返回100
  expect(mockFn(10, 10)).toBe(100);
})

test('測試jest.fn()返回Promise', async () => {
  let mockFn = jest.fn().mockResolvedValue('default');
  let result = await mockFn();
  // 斷言mockFn經過await關鍵字執行後返回值爲default
  expect(result).toBe('default');
  // 斷言mockFn調用後返回的是Promise對象
  expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

上面的代碼是jest.fn()提供的幾個經常使用的API和斷言語句,下面咱們在src/fetch.js文件中寫一些被測試代碼,以更加接近業務的方式來理解Mock函數的實際應用。

須要說明的是,被測試代碼中依賴了axios這個經常使用的請求庫和JSONPlaceholder這個上篇文章中提到免費的請求接口,請先在shell中執行npm install axios --save安裝依賴。

// fetch.js

import axios from 'axios';

export default {
  async fetchPostsList(callback) {
    return axios.get('https://jsonplaceholder.typicode.com/posts').then(res => {
      return callback(res.data);
    })
  }
}

咱們在fetch.js中封裝了一個fetchPostsList方法,該方法請求了JSONPlaceholder提供的接口,並經過傳入的回調函數返回處理過的返回值。若是咱們想測試該接口可以被正常請求,只須要捕獲到傳入的回調函數可以被正常的調用便可。例如:

import fetch from '../src/fetch.js'

test('fetchPostsList中的回調函數應該可以被調用', async () => {
  expect.assertions(1);
  let mockFn = jest.fn();
  await fetch.fetchPostsList(mockFn);

  // 斷言mockFn被調用
  expect(mockFn).toBeCalled();
})

jest.mock()

在上一個請求fetch.js文件夾中,咱們封裝的請求方法可能在其餘模塊被調用,但有時候咱們並不須要進行實際的請求(請求方法已經經過單側或須要該方法返回非真實數據)。此時,使用jest.mock()去mock整個模塊是十分有必要的。

// events.js

import fetch from './fetch';

export default {
  async getPostList() {
    return fetch.fetchPostsList(data => {
      console.log('fetchPostsList be called!');
      // do something
    });
  }
}

而後咱們編寫一個測試文件,用於測試getPostList請求。

// functions.test.js

import events from '../src/events';
import fetch from '../src/fetch';

jest.mock('../src/fetch.js');

test('mock 整個 fetch.js模塊', async () => {
  expect.assertions(2);
  await events.getPostList();
  expect(fetch.fetchPostsList).toHaveBeenCalled();
  expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});

測試代碼中,咱們使用了jest.mock('../src/fetch.js')去mock整個fetch.js模塊,若是註釋掉這行代碼,執行測試腳本時會出現如下報錯信息。
在這裏插入圖片描述

jest.spyOn()

jest.spyOn()方法一樣能夠建立一個mock函數,可是該mock函數不只可以捕獲函數的調用狀況,還能夠正常的執行被spy的函數。實際上,jest.spyOn()是jest.fn()的語法糖,它建立了一個和被spy的函數具備相同內部代碼的mock函數。例如:
在這裏插入圖片描述
上圖是以前jest.mock()的示例代碼中的正確執行結果的截圖,從shell腳本中能夠看到console.log('fetchPostsList be called!');這行代碼並無在shell中被打印,這是由於經過jest.mock()後,模塊內的方法是不會被jest所實際執行的。這時咱們就須要使用jest.spyOn()。

// functions.test.js

import events from '../src/events';
import fetch from '../src/fetch';

test('使用jest.spyOn()監控fetch.fetchPostsList被正常調用', async() => {
  expect.assertions(2);
  const spyFn = jest.spyOn(fetch, 'fetchPostsList');
  await events.getPostList();
  expect(spyFn).toHaveBeenCalled();
  expect(spyFn).toHaveBeenCalledTimes(1);
})

執行npm run test後,能夠看到shell中的打印信息,說明經過jest.spyOn(),fetchPostsList被正常的執行了。
在這裏插入圖片描述

E2E自動化測試

相關文章
相關標籤/搜索