第一次嘗試將學習過的知識通過文章的方式記錄下來。在寫文章的過程中,發現自己更多的不足以及測試的重要性。本篇文章主要是記錄使用Jest + Enzyme進行React技術棧單元測試所需要掌握的基本知識以及環境搭建。
常用術語
- 根據測試手段劃分
黑盒
:完全不考慮程式的內部結構以及工作過程,僅關注程式的功能是否都正常。白盒
:已知程式內部邏輯結構,通過測試來證明程式內部能按照預定要求正確工作。灰盒
:介於黑盒與白盒之間的一種測試,關注輸出對於輸入的正確性,同時也關注內部表現,但這種關注不象白盒那樣詳細、完整。
- 根據專項劃分
功能
:測試程式是否滿足使用者提出的表面需求。效能
:測試程式的工作效率。安全
:測試程式是否能保護使用者的資訊、確保資訊不被輕易盜取。
- 根據測試點劃分
相容性
:測試程式在不同平臺下的表現。易用性
:測試程式是否友好,滿足使用者的使用習慣。UI元素
:頁面佈局是否一致、美觀。
為什麼要測試
- 作為現有程式碼行為的描述。
- 提升專案的健壯性、可靠性。
- 減少專案迭代重構帶來的風險。
- 促使開發者寫可測試的程式碼。
- 依賴的元件如果有修改,受影響的元件能在測試中發現錯誤。
- 降低人力測試的成本、提高測試的效率。
- ......
前端測試金字塔
測試框架、斷言庫(chai、expect.js、should.js、Sinon.JS等)、工具有很多,以下僅列出一些比較常見的或是本人正在使用的測試框架/工具。
-
1、單元測試(unit tests)
-
2、快照測試(snapshot tests)
-
3、端對端測試(e2e tests)
React單元測試
-
技術選型
-
環境搭建
-
安裝
Jest
npm install --save-dev jest 複製程式碼
-
安裝
Enzyme
npm install --save-dev enzyme jest-enzyme // react介面卡需要與react版本想對應 參考: https://airbnb.io/enzyme/ npm install --save-dev enzyme-adapter-react-16 // 如果使用的是16.4及以上版本的react,還可以通過安裝jest-environment-enzyme來設定jest的環境 npm install --save-dev jest-environment-enzyme 複製程式碼
-
安裝
Babel
npm install --save-dev babel-jest babel-core npm install --save-dev babel-preset-env npm install --save-dev babel-preset-react // 無所不能stage-0 npm install --save-dev babel-preset-stage-0 // 按需載入外掛 npm install --save-dev babel-plugin-transform-runtime 複製程式碼
-
修改
package.json
// package.json { "scripts": { "test": "jest" } } 複製程式碼
-
安裝其他需要用到的庫
// 安裝jquery來操作dom npm install --save jquery 複製程式碼
-
Jest
配置更多關於
Jest
的配置請查閱jestjs.io/docs/zh-Han…// jest.config.js module.exports = { setupFiles: ['./jest/setup.js'], // 配置測試環境,這些指令碼將在執行測試程式碼本身之前立即在測試環境中執行。 setupTestFrameworkScriptFile: 'jest-enzyme', // 配置測試框架 testEnvironment: 'enzyme', // 使用jest-environment-enzyme時所需的配置 testEnvironmentOptions: { enzymeAdapter: 'react16', // react介面卡的版本 }, testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/src/'], // 忽略的目錄 transform: { // 編譯配置 '^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest', '^.+\\.(css|scss)$': '<rootDir>/jest/cssTransform.js', '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '<rootDir>/jest/fileTransform.js', }, }; 複製程式碼
-
使用Jest測試一個Function
// add.js const add = (a, b) => a + b; export default add; 複製程式碼
// __tests__/add-test.js import add from '../add'; describe('add() test:', () => { it('1+2=3', () => { expect(add(1, 2)).toBe(3); // 斷言是通過的,但是如果我們傳入的是string型別呢? }); }); 複製程式碼
// 執行Jest npm test // 或 jest add-test.js --verbose 複製程式碼
-
快照測試
如果想確保UI不會意外更改,快照測試就是一個非常有用的工具。
// 安裝react、react-dom以及react-test-renderer npm install --save react react-dom react-test-renderer 複製程式碼
// components/Banner.js import React from 'react'; const Banner = ({ src }) => ( <div> <img src={src} alt="banner" /> </div> ); export default Banner; 複製程式碼
// __tests__/components/Banner-test.js import React from 'react'; import renderer from 'react-test-renderer'; import Banner from '../../components/Banner'; describe('<Banner />', () => { it('renders correctly', () => { const tree = renderer.create(<Banner />).toJSON(); expect(tree).toMatchSnapshot(); }); }); 複製程式碼
-
JSDOM(JS實現的無頭瀏覽器)
jsdom最強大的能力是它可以在jsdom中執行指令碼。這些指令碼可以修改頁面內容並訪問jsdom實現的所有Web平臺API。
// handleBtn.js const $ = require('jquery'); $('#btn').click(() => $('#text').text('click on the button')); 複製程式碼
// handleBtn-test.js describe('JSDOM test', () => { it('click on the button', () => { // initialization document document.body.innerHTML = '<div id="btn"><span id="text"></span></div>'; const $ = require('jquery'); require('../handleBtn'); // simulation button click $('#btn').click(); // the text is updated as expected expect($('#text').text()).toEqual('click on the button'); }); }); 複製程式碼
-
Mock模組
在需要Mock的模組目錄下新建一個
__mocks__
目錄,然後新建一樣的檔名,最後在測試程式碼中新增上jest.mock('../moduleName')
,即可實現模組的Mock。// request.js const http = require('http'); export default function request(url) { return new Promise(resolve => { // 這是一個HTTP請求的例子, 用來從API獲取使用者資訊 // This module is being mocked in __mocks__/request.js http.get({ path: url }, response => { let data = ''; response.on('data', _data => { data += _data; }); response.on('end', () => resolve(data)); }); }); } 複製程式碼
// __mocks__/request.js const users = { 4: { name: 'Mark' }, 5: { name: 'Paul' }, }; export default function request(url) { return new Promise((resolve, reject) => { const userID = parseInt(url.substr('/users/'.length), 10); process.nextTick(() => (users[userID] ? resolve(users[userID]) : reject(new Error(`User with ${userID} not found.`)))); }); } 複製程式碼
// __tests__/request.js jest.mock('../request.js'); import request from '../request'; describe('mock request.js', () => { it('works with async/await', async () => { expect.assertions(2); // 呼叫2個斷言 // 正確返回的斷言 const res = await request('/users/4'); expect(res).toEqual({ name: 'Mark' }); // 錯誤返回的斷言 await expect(request('/users/41')).rejects.toThrow('User with 41 not found.'); }); }); 複製程式碼
-
測試元件節點
shallow
:淺渲染,將元件作為一個單元進行測試,並確保您的測試不會間接斷言子元件的行為。支援互動模擬以及元件內部函式測試render
:靜態渲染,將React元件渲染成靜態的HTML字串,然後使用Cheerio這個庫解析這段字串,並返回一個Cheerio的例項物件,可以用來分析元件的html結構。可用於子元件的判斷。mount
:完全渲染,完整DOM渲染非常適用於您擁有可能與DOM API互動或需要測試包含在更高階元件中的元件的用例。依賴jsdom
庫,本質上是一個完全用JS實現的無頭瀏覽器。支援互動模擬以及元件內部函式測試
// components/List.js import React, { Component } from 'react'; export default class List extends Component { constructor(props) { super(props); this.state = { list: [1], }; } render() { const { list } = this.state; return ( <div> {list.map(item => ( <p key={item}>{item}</p> ))} </div> ); } } 複製程式碼
// __tests__/components/List-test.js import React from 'react'; import { shallow, render, mount } from 'enzyme'; import List from '../../components/List'; describe('<List />', () => { it('shallow:render <List /> component', () => { const wrapper = shallow(<List />); expect(wrapper.find('div').length).toBe(1); }); it('render:render <List /> component', () => { const wrapper = render(<List />); expect(wrapper.html()).toBe('<p>1</p>'); }); it('mount:allows us to setState', () => { const wrapper = mount(<List />); wrapper.setState({ list: [1, 2, 3], }); expect(wrapper.find('p').length).toBe(3); }); }); 複製程式碼
-
測試元件內部函式
// components/TodoList.js import React, { Component } from 'react'; export default class TodoList extends Component { constructor(props) { super(props); this.state = { list: [], }; } handleBtn = () => { const { list } = this.state; this.setState({ list: list.length ? [...list, list.length] : [0], }); }; render() { const { list } = this.state; return ( <div> {list.map(item => ( <p key={item}>{item}</p> ))} <button type="button" onClick={() => this.handleBtn}> add item </button> </div> ); } } 複製程式碼
// __tests__/components/TodoList-test.js import React from 'react'; import { shallow } from 'enzyme'; import TodoList from '../../components/TodoList'; describe('<TodoList />', () => { it('calls component handleBtn', () => { const wrapper = shallow(<TodoList />); // 建立模擬函式 const spyHandleBtn = jest.spyOn(wrapper.instance(), 'handleBtn'); // list的預設長度是0 expect(wrapper.state('list').length).toBe(0); // 首次handelBtn wrapper.instance().handleBtn(); expect(wrapper.state('list').length).toBe(1); // 模擬按鈕點選 wrapper.find('button').simulate('click'); expect(wrapper.state('list').length).toBe(2); // 總共執行handleBtn函式兩次 expect(spyHandleBtn).toHaveBeenCalledTimes(2); // 恢復mockFn spyHandleBtn.mockRestore(); }); }); 複製程式碼
-
測試程式碼覆蓋率
- 語句覆蓋率(statement coverage):是否測試用例的每個語句都執行了
- 分支覆蓋率(branch coverage):是否測試用例的每個if程式碼塊都執行了
- 函式覆蓋率(function coverage):是否測試用例的每一個函式都呼叫了
- 行覆蓋率(line coverage):是否測試用例的每一行都執行了
// jest.config.js module.exports = { collectCoverage: true, // 收集覆蓋率資訊 coverageThreshold: { // 設定覆蓋率最低閾值 global: { branches: 50, functions: 50, lines: 50, statements: 50, }, './firstTest/components': { branches: 100, }, }, }; 複製程式碼
-
redux單元測試
-
安裝
- redux-thunk :一個用於管理redux副作用(Side Effect,例如非同步獲取資料)的庫
- redux-saga : redux副作用管理更容易,執行更高效,測試更簡單,在處理故障時更容易。
- fetch-mock :模擬fetch請求
- node-fetch :fetch-mock依賴node-fetch
- redux-mock-store : 用於測試Redux非同步操作建立器和中介軟體的模擬儲存。主要用於測試與操作相關的邏輯,而不是與reducer相關的邏輯。
- redux-actions-assertions 用於測試redux actions的斷言庫
npm install --save redux-thunk npm install --save redux-saga npm install --save-dev fetch-mock redux-mock-store redux-actions-assertions npm install -g node-fetch 複製程式碼
-
測試同步action
// actions/todoActions.js export const addTodo = text => ({ type: 'ADD_TODO', text }); export const delTodo = text => ({ type: 'DEL_TODO', text }); 複製程式碼
// __tests__/actions/todoActions-test.js import * as actions from '../../actions/todoActions'; describe('actions', () => { it('addTodo', () => { const text = 'hello redux'; const expectedAction = { type: 'ADD_TODO', text, }; expect(actions.addTodo(text)).toEqual(expectedAction); }); it('delTodo', () => { const text = 'hello jest'; const expectedAction = { type: 'DEL_TODO', text, }; expect(actions.delTodo(text)).toEqual(expectedAction); }); }); 複製程式碼
-
測試基於redux-thunk的非同步action
// actions/fetchActions.js export const fetchTodosRequest = () => ({ type: 'FETCH_TODOS_REQUEST' }); export const fetchTodosSuccess = data => ({ type: 'FETCH_TODOS_SUCCESS', data, }); export const fetchTodosFailure = data => ({ type: 'FETCH_TODOS_FAILURE', data, }); export function fetchTodos() { return dispatch => { dispatch(fetchTodosRequest()); return fetch('http://example.com/todos') .then(res => res.json()) .then(body => dispatch(fetchTodosSuccess(body))) .catch(ex => dispatch(fetchTodosFailure(ex))); }; } 複製程式碼
// __tests__/actions/fetchActions-test.js import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import fetchMock from 'fetch-mock'; import * as actions from '../../actions/fetchActions'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); describe('fetchActions', () => { afterEach(() => { fetchMock.restore(); }); it('在獲取todos之後建立FETCH_TODOS_SUCCESS', async () => { fetchMock.getOnce('/todos', { body: { todos: ['do something'] }, headers: { 'content-type': 'application/json' }, }); // 所期盼的action執行記錄:FETCH_TODOS_REQUEST -> FETCH_TODOS_SUCCESS const expectedActions = [ { type: 'FETCH_TODOS_REQUEST' }, { type: 'FETCH_TODOS_SUCCESS', data: { todos: ['do something'] } }, ]; const store = mockStore({ todos: [] }); // 通過async/await來優化非同步操作的流程 await store.dispatch(actions.fetchTodos()); // 斷言actios是否正確執行 expect(store.getActions()).toEqual(expectedActions); }); it('在獲取todos之後建立FETCH_TODOS_FAILURE', async () => { fetchMock.getOnce('/todos', { throws: new TypeError('Failed to fetch'), }); const expectedActions = [ { type: 'FETCH_TODOS_REQUEST' }, { type: 'FETCH_TODOS_FAILURE', data: new TypeError('Failed to fetch') }, ]; const store = mockStore({ todos: [] }); await store.dispatch(actions.fetchTodos()); expect(store.getActions()).toEqual(expectedActions); }); }); 複製程式碼
-
測試 Sagas
有兩個主要的測試 Sagas 的方式:一步一步測試 saga generator function,或者執行整個 saga 並斷言 side effects。
- 測試 Sagas Generator Function 中的純函式
// sagas/uiSagas.js import { put, take } from 'redux-saga/effects'; export const CHOOSE_COLOR = 'CHOOSE_COLOR'; export const CHANGE_UI = 'CHANGE_UI'; export const chooseColor = color => ({ type: CHOOSE_COLOR, payload: { color, }, }); export const changeUI = color => ({ type: CHANGE_UI, payload: { color, }, }); export function* changeColorSaga() { const action = yield take(CHOOSE_COLOR); yield put(changeUI(action.payload.color)); } 複製程式碼
// __tests__/sagas/uiSagas-test.js import { put, take } from 'redux-saga/effects'; import { changeColorSaga, CHOOSE_COLOR, chooseColor, changeUI, } from '../../sagas/uiSagas'; describe('uiSagas', () => { it('changeColorSaga', () => { const gen = changeColorSaga(); expect(gen.next().value).toEqual(take(CHOOSE_COLOR)); const color = 'red'; expect(gen.next(chooseColor(color)).value).toEqual(put(changeUI(color))); }); }); 複製程式碼
- 測試 Sagas Generator Function 中的 side effects(副作用)
// sagas/fetchSagas.js import { put, call } from 'redux-saga/effects'; export const fetchDatasSuccess = data => ({ type: 'FETCH_DATAS_SUCCESS', data, }); export const fetchDatasFailure = data => ({ type: 'FETCH_DATAS_FAILURE', data, }); export const myFetch = (...parmas) => fetch(...parmas).then(res => res.json()); export function* fetchDatas() { try { const result = yield call(myFetch, '/datas'); yield put(fetchDatasSuccess(result)); } catch (error) { yield put(fetchDatasFailure(error)); } } 複製程式碼
// __tests__/sagas/fetchSagas-test.js import { runSaga } from 'redux-saga'; import { put, call } from 'redux-saga/effects'; import fetchMock from 'fetch-mock'; import { fetchDatas, fetchDatasSuccess, fetchDatasFailure, myFetch, } from '../../sagas/fetchSagas'; describe('fetchSagas', () => { afterEach(() => { fetchMock.restore(); }); // 一步步generator function 並斷言 side effects it('fetchDatas success', async () => { const body = { text: 'success' }; fetchMock.get('/datas', { body, headers: { 'content-type': 'application/json' }, }); const gen = fetchDatas(); // 呼叫next().value來獲取被yield的effect,並拿它和期望返回的effect進行比對 expect(gen.next().value).toEqual(call(myFetch, '/datas')); const result = await fetch('/datas').then(res => res.json()); expect(result).toEqual(body); // 請求成功 expect(gen.next(result).value).toEqual(put(fetchDatasSuccess(body))); }); it('fetchDatas fail', () => { const gen = fetchDatas(); expect(gen.next().value).toEqual(call(myFetch, '/datas')); // 模擬異常時的處理是否預期 const throws = new TypeError('Failed to fetch'); expect(gen.throw(throws).value).toEqual(put(fetchDatasFailure(throws))); }); // 執行整個 saga 並斷言 side effects。(推薦方案) it('runSage success', async () => { const body = { text: 'success' }; fetchMock.get('/datas', { body, headers: { 'content-type': 'application/json' }, }); const dispatched = []; await runSaga({ dispatch: action => dispatched.push(action), }, fetchDatas).done; expect(dispatched).toEqual([fetchDatasSuccess(body)]); }); it('runSage fail', async () => { const throws = new TypeError('Failed to fetch'); fetchMock.get('/datas', { throws, }); const dispatched = []; await runSaga({ dispatch: action => dispatched.push(action), }, fetchDatas).done; expect(dispatched).toEqual([fetchDatasFailure(throws)]); }); }); 複製程式碼
-
測試 reducers
// reducers/todos.js export default function todos(state = [], action) { switch (action.type) { case 'ADD_TODO': return [ { text: action.text, }, ...state, ]; default: return state; } } 複製程式碼
// __tests__/reducers/todos-test.js import todos from '../../reducers/todos'; describe('reducers', () => { it('should return the initial state', () => { expect(todos(undefined, {})).toEqual([]); }); it('todos initial', () => { expect(todos([{ text: '1' }], {})).toEqual([{ text: '1' }]); }); it('should handle ADD_TODO', () => { expect(todos([], { type: 'ADD_TODO', text: 'text' })).toEqual([ { text: 'text', }, ]); }); }); 複製程式碼