去年底在公司分享了關於使用 Jest 進行前端單元測試的議題,簡報版本在此,現在將其整理為文章以方便日後查詢與閱讀。
前言
從事前端工作也幾年了,先前有諸多原因都未能在公司產品上實行單元測試,例如做功能都來不及了,哪有時間寫測試、程式寫的太過耦合難以測試之類的。
我以前只在個人的 side project 上寫過單元測試,也的確感受到「測試能夠減低重構時不致無意間把原有功能搞壞就釋出的風險」帶來的好處。而這次算是我第一次在公司產品上導入單元測試,在測試的領域還算是新手,這篇文章便作為新手的個人筆記,有什麼錯誤的部份請偷偷告訴我 🙂
再提一下情境:我們使用 React + Redux 進行 SPA (Single Page Application) 的前端網站開發,是前後端分離的開發模式,後端團隊出 JSON 格式的 API 給前端進行資料串接。在 Scrum 會議中 Story Refinement Meeting 開 Story 的 Sub tasks 都會特別開 Unit Test 實作的票。
Why Jest?
為什麼選擇 Jest 呢?跟最常拿來被比較的 Mocha 來說,從 Jasmine 發展而來的 Jest 本身對於 React 的整合度挺不錯的(而且是 Facebook 自家的,釋出與修正速度可靠許多),而且本身也整合好了斷言 (Assertion)、Mock 等 Library 以及 Coverage 報告功能,還有滿特別的 Snapshot 測試,以及優秀的 watch 模式可以只測試當次更改的檔案,最重要是它的設定頗好上手的。
Jest 在舊版時的 Auto mocking 有點難用,當時在做個人 side project 也曾經在 contributors 建議下在 Mocha 跟 Jest 之間轉換過一兩次。
至於執行速度倒是有各家說法,我沒有實際去比較過各 frameworks 的速度,但我知道 Jest 跟新興的 AVA 都能平行跑多個測試,相信表現也不差。
Mocha 也有它適合使用的場景,例如執行環境是瀏覽器的時候。
Jest 基本設定
首先要先安裝 Jest 本身及其相關套件:
yarn add jest@^19.0.2 babel-jest@^19.0.0 react-test-renderer@^15.4.2 jasmine-reporters@^2.2.0 identity-obj-proxy@^3.0.0 redux-mock-store@^1.2.0 nock@^9.0.9 --dev
其中 jasmine-reporters
是為了產生給 CI 用的 JUnitXmlReporter
格式之用;identity-obj-proxy
是為了 mock sass、css 檔案;而 redux-mock-store
就是為了 mock redux 的 store;nock
可以 mock 掉 HTTP requests。
安裝完畢後,先準備幾個檔案:
接著在 package.json
中新增 jest
的設定區塊:
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!**/__mocks__/**",
"!**/__tests__/**"
],
"setupFiles": [
"<rootDir>/config/jest/setup.js"
],
"setupTestFrameworkScriptFile": "<rootDir>/config/jest/setupTestFramework.js",
"testPathIgnorePatterns": [
"<rootDir>[/\\\\](build|docs|node_modules)[/\\\\]"
],
"testURL": "http://localhost",
"transform": {
"^.+\\.(js|jsx)$": "<rootDir>/config/jest/transform.js",
"^.+\\.(scss|css)$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|css|scss|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
],
"testRegex": "/__tests__/.*\\.(test|spec)\\.js$"
}
以下將分段解釋各區塊的用途,當然也可以直接閱讀官網說明文件。
"collectCoverageFrom": [
"src/**/*.js",
"!**/__mocks__/**",
"!**/__tests__/**"
],
是計算 coverage 的檔案範圍,前面加驚嘆號便是排除的規則。
"setupFiles": [
"<rootDir>/config/jest/setup.js"
],
是在每個測試之前都會跑的 script 設定,可設定多組,而且它們將跑在 setupTestFrameworkScriptFile
之前。本例的 setup.js
檔案內容是:
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
global.__CLIENT__ = true;
global.__DEVELOPMENT__ = false;
大致上是 mock 掉 localStorage 的方法跟環境變數值。
"setupTestFrameworkScriptFile": "<rootDir>/config/jest/setupTestFramework.js",
在每個測試之前都會跑這段的設定,而且它在 setupFiles
設定之後。通常是作為動態複寫掉 jasmine
的 API 之用,以本例而言,setupTestFramework.js
內容為:
if (process.env.CI) {
const jasmineReporters = require('jasmine-reporters');
const junitReporter = new jasmineReporters.JUnitXmlReporter({
savePath: 'testresults',
consolidateAll: false,
});
jasmine.getEnv().addReporter(junitReporter);
}
是在 CI 環境中設定 jasmine reporter 的參數。
"testPathIgnorePatterns": [
"<rootDir>[/\\\\](build|docs|node_modules)[/\\\\]"
],
testPathIgnorePatterns
顧名思義,就是排除在之外的測試檔案規則,以本例而言就排除掉 node_modules
任何子目錄中的測試檔案。(我們沒理由去測經由 npm
或 yarn
安裝到 node_modules
中的 packages)
"testURL": "http://localhost",
testURL
設定在 jsdom 環境中的網址,它同時也反映在 location.href
上。
"transform": {
"^.+\\.(js|jsx)$": "<rootDir>/config/jest/transform.js",
"^.+\\.(scss|css)$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|css|scss|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
],
這兩段一起講:transformIgnorePatterns
就是排除在 transform
規則之外的檔案規則。
而 transform
則是一組「要如何在 Jest 環境中轉換檔案的設定」,例如我們想測試 TypeScript 的檔案,但 Jest 原生並不支援,就可在此設定 transformer 將之編譯成 JavaScript。
而這邊做了 3 種轉換器設定,第一組處理 .js/.jsx
檔案的 transform.js
:
const babelJest = require('babel-jest');
module.exports = babelJest.createTransformer();
使其透過 babel
轉換。
第二組處理 .scss/.css
檔案的 cssTransform.js
:
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/tutorial-webpack.html
module.exports = {
process() {
return `
const idObj = require('identity-obj-proxy');
module.exports = idObj;
`;
},
getCacheKey(fileData, filename) { // eslint-disable-line no-unused-vars
// The output is always the same.
return 'cssTransform';
},
};
把 style mock 掉,遇到這類檔案便回傳相應的 key,這樣我們就可以測試 classname
是否相符。
最後是處理 .js/.jsx/.css/.scss/.json
之外的檔案類型的 fileTransform.js
:
const path = require('path');
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/tutorial-webpack.html
module.exports = {
process(src, filename) {
// eslint-disable-next-line prefer-template
return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
},
};
透過轉換器可以取回相應的檔案名稱。
最後是 testRegex
,讓我們設定測試腳本檔案本身的規則:
"testRegex": "/__tests__/.*\\.(test|spec)\\.js$"
例如在此我們就是得放在 __tests__
目錄底下,並以 .test.js
或 .spec.js
檔案名稱作為結尾。
透過 Enzyme 使用如 jQuery 般好用的元素選擇器
Enzyme 是由 Airbnb 開發的開放原始碼專案,它建構在 React 原生的 ReactTestUtils 之上,目的是讓開發者更容易撰寫測試,它提供類似 jQuery 的選擇器語法,比起官方原生的寫法簡短許多。
例如原生的寫法是這樣:
ReactTestUtils.findRenderedDOMComponentWithClass('.abc')
但換成 Enzyme 的話,只要這樣:
subject.find('.abc')
相當簡潔方便!
而測試 React Component 時免不了要 render 它才能進行測試,而 Enzyme 提供三種 render API 的方式:
- shallow
- 封裝原生的 shallow rendering
- 只 render 第一層(Component 本身),速度較快,無法測試完整的生命週期
- render
- 類似
shallow
,但使用 Cheerio,也就不是 React Virtual DOM,而是真的瀏覽器中的 HTML DOM 了。
- 類似
- mount
- Full rendering
- 能夠測試完整的生命週期
- 能夠測試與 DOM 的互動與後續產生的變化,例如 Click 後改變 state,進而改變 DOM。
語法及規則
describe
與 it
(test
)
官網文件。
這是撰寫單元測試的語法架構,主要由 describe
跟 it
(在這邊又可以用 test
代替)組成。
it
是測試最小單位,裡面請只做一件測試案例(test case),確保要測試的這件事情是單一的。第一個參數是測試名稱,我們通常會寫的像「一句話」,例如 it('has lemon in', ...)
、it('did not rain', ...)
。
describe
則是把相關的測試案例都整理在一起,其下又可以包一層 describe
作更進一步的分類整理。還可以搭配 before
、beforeEach
、after
、afterEach
作更多「每次測試案例之前之後,要作什麼動作」等更多不同的變化用法。
還可以透過在字首加上 f
或 x
以「只測它」或「跳過它」。在新版 Jest 中它們被定義成更明確語意的 .only
跟 .skip
了。
expect
斷言(Assertion)就是我們要驗證的事情本身。
例如 期望 A 是一顆蘋果:expect(A).toBe('Apple')
。如果斷言的期望結果與執行結果不一致,那代表這個測試案例會失敗。
相關語法直接參考 官網文件 吧。
我們使用的命名規則
前面提到我們的專案是 React + Redux,而 Redux 的部份我們使用 Ducks: Redux Reducer Bundles。
測試腳本檔案都放在與「欲測試的 source」同一層的 __tests__
目錄底下,檔名叫做 {source 檔名}.test.js
。例如目錄結構會像這樣:
components/
Header/
__tests__/
Header.test.js
Header.js
而 redux 相關的檔案我們使用了 Ducks Pattern 去寫,因此 source 都是在同一支檔案中,相關的測試檔案命名會是將 actions 的測試叫做 {source 檔名}.actions.test.js
、reducer 的測試則叫做 {source 檔名}.reducers.test.js
,實例:
redux/
modules/
__tests__/
albums.actions.test.js
albums.reducers.test.js
albums.js
另外,如果測試案例中需要定義一些假資料,過長的內容不建議直接放在測試腳本中,而是新建另外一支檔案放置,目錄位置與測試案例同一層,命名為 mocks/mock{首字大寫的 source 檔案名稱}.json
,例如:
redux/
modules/
__tests__/
mocks/
mockAlbum.json
albums.actions.test.js
albums.reducers.test.js
albums.js
我們專案中的測試重點及方法
這是現行專案的測試撰寫重點參考,目前也有在評估使用 wix
的 redux-testkit。
Component
- Snapshot 測試(Jest v14 出現的神奇玩意)
- 比對產生的 HTML 結果是否與上次 snapshot 相符(
expect(xxx).toMatchSnapshot()
)
- 比對產生的 HTML 結果是否與上次 snapshot 相符(
- 模擬行為測試,例如點擊行為
simulate
- 可以搭配
toBeCalledWith
偵測是否觸發了某個 function,並是隨著哪些值被觸發的
import React from 'react';
import { mount } from 'enzyme';
import mockResponse from './mocks/mockResponse';
describe('<Albums />', function test() {
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
const Albums = require('../Albums');
const { data } = mockResponse.items;
this.params = {
list: data,
load: jest.fn(),
};
this.makeSubject = (params = this.params) => {
return mount(<Albums {...params} />);
};
});
describe('Albums', () => {
let subject;
beforeEach(() => {
subject = this.makeSubject();
});
it('should render', () => {
expect(subject.length).toBeTruthy();
});
it('should render as snapshot', () => {
expect(subject.html()).toMatchSnapshot();
});
});
describe('when click item', () => {
let subject;
it('performance tab should trigger load', () => {
subject = this.makeSubject();
const item = subject.find('.specialItem');
item.simulate('click');
expect(this.params.load).toBeCalled();
});
});
});
Container
- Container 與 Component 都有被 render 出來
- 需要用
<Provider>
包裹並搭配 mock store
- 需要用
- 被 mock 的 component 有被 call
- 執行相關的 actions 有觸發 dispatch
jest.mock('../Search');
jest.mock('../../../../redux/modules/search', () => {
return {
load: keyword => keyword,
};
});
import React from 'react';
import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import SearchContainer from '../SearchContainer';
import Search from '../Search';
const storeFake = (state) => {
return {
default: () => {},
subscribe: () => {},
dispatch: jest.fn(),
getState: () => {
return { ...state };
},
};
};
describe('<SearchContainer />', function() {
let subject;
let Component;
let SearchComponent;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
const store = storeFake({});
subject = mount(
<Provider store={store}>
<SearchContainer />
</Provider>
);
Component = subject.find(SearchContainer);
SearchComponent = Component.find(Search);
});
it('component should be called exactly once', () => {
expect(Search.mock.calls.length).toBe(1);
});
it('should render', () => {
expect(Component.length).toBeTruthy();
expect(SearchComponent.length).toBeTruthy();
});
it('should execute the dispatch function with `淡水` after calling the load function with `淡水`', () => {
SearchComponent.props().load('淡水');
expect(subject.instance().store.dispatch).toBeCalledWith('淡水');
});
});
Reducer
- 輸入一次 initial state 與 action 後,返回預期的 state
- 連續輸入 initial state 與 actions 後,與 snapshot 相符
describe('todoReducer', () => {
let todoReducer;
let todo;
let initialState;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
todoReducer = require('../todo').default;
todo = require('../todo');
initialState = {
todo: [],
};
});
describe('add new todo', () => {
it('should add new todo', () => {
const result = todoReducer(initialState, todo.add('buy ticket'));
expect(result).toEqual({
todo: ['buy ticket'],
});
});
});
describe('not exist type', () => {
it('should show original state', () => {
const result = todoReducer(initialState, 'NOT_EXIST');
expect(result).toEqual(initialState);
});
});
it('should match snapshot', () => {
const uiActions = [
{ type: 'ADD_TODO', payloads: 'go to supermarket' },
{ type: 'ADD_TODO', payloads: 'send email' },
];
let state;
for (const action of uiActions) {
state = todoReducer(state, action);
expect(state).toMatchSnapshot();
}
});
});
Action
- 執行 action 後,返回預期的物件(例如
{ type: XXX, payloads: 123 }
) - 承上,但中間經過 mock 過的 http request(透過 nock 設定成功與失敗的 response)
import nock from 'nock';
import configureMockStore from 'redux-mock-store';
import mockTodo from './mocks/mockTodo.json';
describe('todo actions', () => {
let todo;
let middlewares = []; // ...略
let mockStore = configureMockStore(middlewares);
let store;
let scope;
const apiBaseUrl = 'http://localhost';
let TODO_LOAD;
let TODO_LOAD_SUCCESS;
let TODO_LOAD_FAIL;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
nock.cleanAll();
scope = null;
todo = require('../todo'); // eslint-disable-line global-require
store = mockStore({
todo: [],
});
TODO_LOAD = 'patw/todo/TODO_LOAD';
TODO_LOAD_SUCCESS = 'patw/todo/TODO_LOAD_SUCCESS';
TODO_LOAD_FAIL = 'patw/todo/TODO_LOAD_FAIL';
});
describe('action: load - load & success', () => {
let expectedBody;
let expectedActions;
beforeEach(() => {
expectedBody = {
items: mockTodo.items,
};
expectedActions = [
{
type: TODO_LOAD,
},
{
result: {
items: {
data: mockTodo.items.data,
},
},
type: TODO_LOAD_SUCCESS,
},
];
scope = nock(apiBaseUrl)
.get(`/api/todos`)
.reply(200, {
items: {
data: expectedBody.items.data,
},
});
});
it('should be thenable function', async () => {
const result = store.dispatch(todo.load());
await result;
expect(typeof result.then).toBe('function');
expect(scope.isDone()).toBeTruthy();
});
it('should show load & success actions', async () => {
await store.dispatch(todo.load());
expect(store.getActions()).toEqual(expectedActions);
expect(scope.isDone()).toBeTruthy();
});
it('should get expected data', async () => {
const result = await store.dispatch(todo.load());
expect(result.data).toEqual(expectedActions[1].result);
expect(scope.isDone()).toBeTruthy();
});
});
describe('action: load - load & fail', () => {
let expectedActions;
const errorMessage = 'Internal Server Error';
beforeEach(() => {
expectedActions = [
{
type: TODO_LOAD,
},
{
error: {
body: 'Internal Server Error',
},
type: TODO_LOAD_FAIL,
},
];
scope = nock(apiBaseUrl)
.get(`/api/todos`)
.reply(500, {
body: errorMessage,
});
});
it('should show load & fail actions', () => {
return store.dispatch(todo.load())
.catch(() => {
expect(store.getActions()).toEqual(expectedActions);
expect(scope.isDone()).toBeTruthy();
});
});
it('should get exception', async () => {
try {
await store.dispatch(todo.load());
} catch (object) {
expect(object.response.data.body).toEqual(errorMessage);
expect(scope.isDone()).toBeTruthy();
}
});
});
});
Reselect
- 計算結果與預期相符
- 輸入相同的值,不會重新計算(recomputation)
describe('ui selector test', () => {
let ui;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
ui = require('../ui'); // eslint-disable-line global-require
});
it('uiHeaderSelector', () => {
const state = { ui: { hideHeader: true } };
expect(ui.uiHeaderSelector(state)).toBeTruthy();
});
});
Router
- params/path 相符
- property 相符
- component 相符
import { match } from 'react-router';
jest.mock('../containers/App/App');
jest.mock('../components/Project/Project');
describe('routes testing', () => {
let getRoutes;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
getRoutes = require('../routes').default;
});
describe('/projects/:id', () => {
const expectedId = 9527;
const location = `/projects/${expectedId}`;
let App;
let Project;
beforeEach(() => {
App = require('../containers/App/App').default;
Project = require('../components/Project/Project').default;
});
it('should be correct id in router params', () => {
match({
routes: getRoutes(),
location,
}, (error, redirectLocation, renderProps) => {
expect(renderProps.params.id).toBe(expectedId);
});
});
it('should render correct components', () => {
match({
routes: getRoutes(),
location,
}, (error, redirectLocation, renderProps) => {
expect(renderProps.routes[1].component).toEqual(App);
expect(renderProps.routes[2].component).toEqual(Project);
});
});
});
// Auto redirect
describe('/', () => {
const location = '/';
it('should have REPLACE action and correct pathname', () => {
match({
routes: getRoutes(),
location,
}, (error, redirectLocation) => {
expect(redirectLocation.action).toEqual('REPLACE');
expect(redirectLocation.pathname).toEqual('/projects/9527');
});
});
});
describe('NotFound page', () => {
let App;
let NotFound;
beforeEach(() => {
App = require('../containers/App/App').default;
NotFound = require('../containers/NotFound/NotFound').default;
});
[
'/notfound',
'/it-should-not-be-existed',
].forEach((location) => {
describe(`on ${location}`, () => {
it('should render correct components', () => {
match({
routes: getRoutes(),
location,
}, (error, redirectLocation, renderProps) => {
expect(renderProps.routes[1].component).toEqual(App);
expect(renderProps.routes[2].component).toEqual(NotFound);
});
});
});
});
});
});
其他小技巧
jest.mock()
- Jest 的特色之一,讓測試專注在現在測試的模組本身,mock 掉 import 進來的東西(例如子 component)。
- Jest v15 起,auto mocking 是預設關閉的。
- 透過 mock 可以自訂 import 模組中某個 function 返回的東西。
// foo.js
module.exports = function() {
// some implementation;
};
// test.js
jest.mock('../foo', () => {
return jest.fn(() => 42);
});
const foo = require('../foo');
// foo is a mock function
foo();
// 42
jest.resetModules()
確保 import
/require
的 module 是初始乾淨未被污染的狀況。
通常放在 beforeEach
中。
jest.resetAllMocks()
重設所有 mock 物件的狀態。
jest.fn()
把第三方或傳入的 function mock 起來,如此僅要測試被 call 的次數或是附帶傳入的參數是否正確即可。
global.ga = jest.fn();
global._paq = {
push: jest.fn(),
};
例如在程式碼中呼叫了 Google Analytics (ga) 或 piwik (paq) 的程式碼,但測試時沒有必要載入 ga.js
,那麼就透過 jest.fn()
mock 掉它就好。
此外,可以測試該被 mock 過的 function 是否被呼叫(toBeCalled()
)、被呼叫時附帶了什麼參數(toBeCalledWith(params)
)。在 beforeEach
中,用 mockFn.mockClear()
確保每次都重置狀態(傳入參數、次數)。
timer 相關 mock
在測試中要處理 setTimeout
、setInterval
、clearTimeout
、clearInterval
有些麻煩,因為面對的是真實的時間流,還好 Jest 也提供了 mock timer 的方法,讓你可以掌控時間,太神了!
執行 jest.useFakeTimers()
以啟用 fake timer,如此一來所有的 timer function 都會變成 mock function 了。
亦可透過 jest.runAllTimers();
快進功能,幫忙快速跑過所有的時間等待。
詳情可見官方文件。
Async Test
可以透過安裝 babel-jest
套件來支援 async
/await
的語法,讓我們可以更簡潔地處理非同步的測試。例如:
it('works with async/await', async () => {
await expect(user.getUserName(4)).resolves.toEqual('Mark');
});
至於錯誤處理的話可以這樣寫:
it('tests error with async/await', async () => {
await expect(user.getUserName(3)).rejects.toEqual({
error: 'User with 3 not found.',
});
});
Coverage
Jest 已經幫我們內建了計算\產生 coverage 的機制,只要在執行 jest 時加上 --coverage
參數即可。
以在本篇 Jest 基本設定
一節的設定而言,我們只要打:
yarn run test -- --coverage
執行完就能看到 Coverage 分析報告了,同時它也會產生 HTML 版的報告哦。
此外,如果整合進 CI 流程中,我們串接的是 Codecov 這套服務;在 CI 中跑完測試後,可以設定將 coverage 狀態回覆在 Pull request 中。
結語
簡單的介紹就到這邊,對於測試的認識不深,獻醜了 XD 有更好的作法可以一起討論哦!
雖然才剛開始一段時間,不過在前端專案中導入測試後,我本身的確是有感到有幫助的,特別是在重構之時,也增加了重構時的信心。而在寫 Component 時,自然也會盡量想將 Component 的職責單一化,以更易於撰寫測試。(對,我們不是 TDD)
另外要導入也要看專案團隊的合作模式,倒不是一定要跑 Scrum 才能導入,而是要整個團隊的理解跟配合(Scrum 也不是萬靈丹啊~),例如專案經理(或是管理層)是否能夠了解撰寫測試帶來的好處,將之視為一項投資、開發團隊亦要認同,不然即便成為一項佔用開發估時的工作,最後也不一定能與期望結果相符;此外專案本身的實作週期若是太短,一切都是趕上線為第一優先(例如接案公司),那可能要玩到測試就挺難的了 XD
接下來有空會再分享「撰寫 E2E 自動化測試」的心得(雖然是以手機 App 為測試目標啦),下次見!