所謂單元測試,就是對每一個單元進行的測試,通常針對的是函數、類或單個組件,不涉及系統和集成,單元測試是軟件測試的基礎測試,一個完備的軟件系統都會涉及到單元測試。node
目前,Javascript的測試工具不少,可是針對React的測試主要使用的是Facebook推出的Jest框架,Jest是基於Jasmine的JavaScript測試框架,具備上手容易、快速、可靠的特色,是React.js默認的單元測試框架。相比其餘的測試框架,Jest具備以下的一些特色:react
首先,在項目目錄下使用下面的命令安裝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" }
如今不少的項目都使用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 是 Airbnb 公司開源的測試工具庫,是react-addons-test-utils的封裝的產品,它模擬了 jQuery 的 API,很是直觀而且易於使用和學習,提供了一些不同凡響的接口和幾個方法來減小測試的樣板代碼,方便你判斷、操縱和遍歷 React Components 的輸出,而且減小了測試代碼和實現代碼之間的耦合。相比react-addons-test-utils,enzyme的API 就一目瞭然,下表是兩個框架經常使用的函數的對比。
Enzyme提供了三種渲染方法:
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 方法則會將 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 方法則會將 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) }) })
首先,咱們在項目的根目錄新建一個名爲__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 測試 依賴於官方的 TestUtil,因此須要安裝react-addons-test-utils依賴庫,安裝的時候注意版本的兼容問題。不過在實戰過程當中,我發現react-addons-test-utils會報不少錯誤,而且官方文檔也不是很友好。
這裏推薦使用airbnb開源的Enzyme 腳手架,Enzyme是由 airbnb 開發的React單測工具,它擴展了React的TestUtils,並經過支持相似jQuery的find語法能夠很方便的對render出來的結果作各類斷言,開發體檢十分友好。
使用命令yarn test -- --coverage就能夠生成測試覆蓋報告。如圖:
同時,還會在根目錄生成一個名爲 coverage 的文件夾,是測試覆蓋報告的網頁版,包含更多,更詳細的信息。
匹配器用於測試輸入輸出的值是否符合預期,下面介紹一些常見的匹配器。
最簡單的測試值的方法就是看值是否精確匹配,使用的是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}); });
在實際的測試中,有時候咱們須要明確區分undefined、null和false等狀況,而Jest提供的下面的一些規則能夠幫咱們完成上面的需求。
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()。
在實際開發過程當中,常常會遇到一些異步的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。例如,仍是上面的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測試,只須要在函數前面使用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'); } });
在寫測試的時候,咱們常常須要進行測試以前作一些準備工做。例如,屢次測試重複設置的工做,可使用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)); }); });
mock測試就是在測試過程當中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來建立以便繼續進行測試的測試方法。Mock函數一般會提供如下三種特性:
本節,咱們主要介紹與 Mock 函數相關的幾個API,分別是jest.fn()、jest.spyOn()、jest.mock()。
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(); })
在上一個請求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()方法一樣能夠建立一個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被正常的執行了。