去年底在公司分享了關於使用 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 為測試目標啦),下次見!