owned this note
owned this note
Published
Linked with GitHub
# 單元測試
# 這份文件將會告訴你哪些資訊
這份文件會告訴你如何使用jest與enzyme兩個工具來撰寫單元測試,並應用在你的react專案裡,你將會學會以下所提到的項目。
* **[學會使用 jest](#jest)** - 寫測試程式用的框架
* **[學會使用 enzyme](#enzyme)** - react版的jquery,也可以說是Virtual DOM的Selector
* **[學會使用 snapshot(快照)](#snapshot)** - 能夠將DOM建立`snap`檔,方便比對兩個DOM是否相同
* **[學會使用 Mock Function](#mockfunction)** - 在function上放監聽器,紀錄function有無被呼叫或是被呼叫幾次等相關資訊
* **[學會如何觀看 覆蓋率(coverage)](#coverage)** - 下指令檢查所有的js檔案中,有哪些部分還沒有被測試程式執行過,當覆蓋率達100%,代表整個專案都被測試程式執行過了,但這並不能保證程式一定沒問題。還會講到[如何將覆蓋率匯出成HTML報表觀看](#coverage-html)
* **[測試程式到底該測哪些東西](#guideline)** - 究竟Unit Test該測哪些項目呢?
* **[如何測試Container](#container)** - Container外包了許多層Provider與Router,如果沒有把這些資訊引入到Container裡,測試會出問題,這邊將會教你如何解決這個問題。
* **[如何測試Redux Saga](#saga)**
* **[常見錯誤](tips)**
# 有關單元測試的前言
前端的單元測試在很多人看來都是一個可有可無的東西,理由一般有下面幾條(以下內容統一稱單元測試為單測):
- 寫單測比較費時,有這個時間不如多做幾個需求
- 測試在驗收的時候對頁面的功能都會操作一遍,寫單測相當於做無用功
- 後端提供給前端的接口需要保證質量,因此需要做單測,但前端很少需要提供接口給其他人
其實,我大體上是同意以上觀點的。在大部分的情況下,如果公司的業務不復雜,是完全沒必要做單測的。但如果涉及到以下幾個方面,你就要考慮是否有必要引入單測了:
- 業務比較複雜,前端參與的人員超過3人
- 公司非常注重代碼質量,想盡一切辦法杜絕線上出bug
- 你是跨項目組件的提供方
- 你在做一個開源項目
參考資料:[ React單元測試:Jest + Enzyme(一)](https://segmentfault.com/a/1190000011468620)
## 單元測試 - 3A原則
在撰寫單元測試的程式碼時,有個 3A 原則,來輔助設計測試程式,可以讓測試程式更好懂。3A 原則如下:
1. Arrange : 初始化目標物件、相依物件、方法參數、預期結果,或是預期與相依物件的互動方式。
2. Act : 呼叫目標物件的方法。
3. Assert : 驗證是否符合預期
---
# <a id='jest'>Jest - Test runner, JavaScript testing framework</a>
Jest是一個JavaScript的測試框架,也是所謂的Test runner,類似的項目大概有Jasmine(茉莉花), Mocha(摩卡咖啡), AVA這幾種測試框架,Mocha應該是最多人使用的,但後來Facebook延續Jasmine開發Jest,也是目前專案預設的測試框架,所以就繼續沿用。
因為Jest是新框架,所以也比Mocha多了一些新功能,或是讓語法更加精鍊。
## 主要API使用教學
* jest指令是使用`jest-cli`模組,在react-boilerplate中已經安裝在`devDependencies`內,相關設定也幫你設定好了。
* 可以安裝vsCode的[jest-snippets套件](https://marketplace.visualstudio.com/items?itemName=andys8.jest-snippets)
先寫一個簡單的function來測試,接著用pass.test.js檔案來寫測試程式,完成後在cmd下執行`jest`,jest就會去所有目錄中找檔名是`.test.js`或`.spec.js`結尾的檔案來跑測試程式,把測試程式都放在根目錄中的`__test__`資料夾也可以。
不過在我們專案直接下`npm run test`就可以了,jest指令已經被整合進去了。
這是我們要測試的pass.js
```javascript=
const isPass = score => {
if(score>=60){
return true;
}else{
return false;
}
};
module.exports = isPass;
```
這是我們的測試程式pass.test.js
```javascript=
const isPass = require("./pass");
//describe中通常寫一個元件或是一個function
describe("function isPass()", () => {
//it是這個元件或function中的test case
it("should return true when score is 60", () => {
expect(isPass(60)).toBe(true); // 期待isPass(60)回傳的結果是true
});
it("should return true when score is 45", () => {
expect(isPass(45)).toBe(false);
});
});
```
- **describe**: 將相關的測試案例整合起來定義一個測試結果(test suite),可以使用beforeEach,afterEach決定再跑測試之前或之後要先執行的區塊。Test Suites的數字就是describe的數量。
- **it , test**: 定義一個最小的測試案例(test case)。Tests的數字就是describe的數量。it是test的alias,所以兩個是一樣的東西
- **expect**: 用來判斷是否和預期值相同的斷言庫。
- **toBe**: 比較兩物件是否有相同的值,常用來比較數值。
參考資料:[React 前端單元測試教學](https://medium.com/@savemuse/react-%E5%89%8D%E7%AB%AF%E6%B8%AC%E8%A9%A6%E6%95%99%E5%AD%B8-2ccedbe79411)
jest 的一些斷言方法
```javascript=
// be and equal
expect(4 * 2).toBe(8); // ===
expect({bar: 'bar'}).toEqual({bar: 'baz'}); // == deep equal
expect(1).not.toBe(2);
// boolean
expect(1 === 2).toBeFalsy();
expect(false).not.toBeTruthy();
// comapre
expect(8).toBeGreaterThan(7);
expect(7).toBeGreaterThanOrEqual(7);
expect(6).toBeLessThan(7);
expect(6).toBeLessThanOrEqual(6);
// Promise
expect(Promise.resolve('problem')).resolves.toBe('problem');
expect(Promise.reject('assign')).rejects.toBe('assign');
// contain
expect(['apple', 'banana']).toContain('banana');
expect([{name: 'Homer'}]).toContainEqual({name: 'Homer'});
// match
expect('NBA').toMatch(/^NB/);
expect({name: 'Homer', age: 45}).toMatchObject({name: 'Homer'});
```
---
# <a id='coverage'>Jest 覆蓋率解說</a>
[stackover的網友講解如何看覆蓋率報表](https://stackoverflow.com/questions/26618243/how-do-i-read-an-istanbul-coverage-report)
![](https://i.imgur.com/QweACYy.png)
* Stmts(Statement):
* 有多少比例的statment被執行到,一個console.log("statement_01");及算是一個statment,然而一行line中可以有多個statement,如下面`choice12.js`程式中的第8行,一行line中就包含`console.log("statement_01"); console.log("statement_02");`
共2個statement。
* Branch:
* 我們可以看到function choice12中的switch有3種可能性,但我只測試了其中兩種,Default的情境並沒測試到,所以3個branch只測試到其中2種,百分比就是66.67%。
* Funcs(Functions):
* 一個檔案中有多少比例的Function被執行,`choice12.js`中只有一個Function,所以如果去測試那個function,覆蓋率就會100%。
* Lines:
* 如上面statement所說,基本上lines應該是大於等於statement的。
* Uncoverd Lines:
* choice12.js程式中的8,9行沒被測試到
想要讓覆蓋率變成100%,只要將測試程式中的10,11,12行註解拿掉即可。
---
## 使用jest查看覆蓋率
在react-boilerplate中,使用`npm test`可以直接查看覆蓋率,一般情況則使用`jest --coverage`查看,檢查的範圍可以在`package.json`設定,如下
```
"jest": {
"collectCoverageFrom": [
"app/**/*.{js,jsx}",
"!app/**/*.test.{js,jsx}",
"!app/*/RbGenerated*/*.{js,jsx}",
"!app/app.js",
"!app/global-styles.js",
"!app/*/*/Loadable.{js,jsx}"
]
```
## 如何只對單一檔案查看覆蓋率
因為無法在react-boilerplate中似乎無法對「單一」測試檔進行jest --coverage,安裝jest-single-file-coverage方能使用。
**安裝 [jest-single-file-coverage](https://github.com/DaleLaw/jest-single-file-coverage)**
在專案中的`package.json`檔案中的`script`屬性加上
```"test:single": "node ./node_modules/jest-single-file-coverage"```
就能使用`npm run test:single <file_path>`對該路徑下的測試檔案進行測試,並顯示coverage
---
choice12.js
```javascript=
const choice12 = choice => {
switch (choice) {
case 1:
return 1;
case 2:
return 2;
default:
console.log("statement_01"); console.log("statement_02"); //沒被測試到
return 0; //沒被測試到
}
};
module.exports = choice12;
```
choice12.test.js or chocie12.spec.js
```javascript=
const choice12 = require('./choice12');
describe('function choice12()', () => {
it('should return 1 when enter 1', () => {
expect(choice12(1)).toBe(1);
});
it('should return 2 when enter 2', () => {
expect(choice12(2)).toBe(2);
});
// it('should return 0 when enter 5', () => {
// expect(choice123(5)).toBe(0);
// });
});
```
## <a id='coverage-html'>將覆蓋率匯出成HTML報表觀看</a>
在command line下的Uncoverd lines大概只能顯示`... 19,20,22,23`短短幾行,非常不方便,所以這邊分享觀看jest匯出的精美網頁版coverage報表。
在jest中測試覆蓋率後,似乎預設會匯出HTML檔案報表(不確定是不是react-boilerplate中
已經設定好的關係),在每次測試覆蓋率後,可以在根目錄看到`coverage`資料夾,這個資料夾也在`.gitingore`中被記載,直接開啟`coverage/lcov-report/index.html`就能看到精美的覆蓋率報告。
在index.html中可以看到每個檔案的覆蓋率
![](https://i.imgur.com/IN1HKik.png)
點擊index.html中的檔名則可觀看每個檔案中覆蓋率
![](https://i.imgur.com/sbEbzBJ.png)
觀看程式碼時,不同顏色與符號分別代表哪些資訊:
- 粉紅色的程式碼: 尚未被執行的statement或function
- 黃色的程式碼: 沒被涵蓋到的branch
- `E` stands for 'else path not taken', which means that for the marked if/else statement, the 'if' path has been tested but not the 'else'.
- `I` stands for 'if path not taken', which is the opposite case: the 'if' hasn't been tested.
- The `Nx` in left column is the amount of times that line has been executed.
↓「`I`代表的是if-else的if沒被執行,`E`代表else的部分沒被執行」示意圖
![](https://i.imgur.com/4zQEBOr.png)
![](https://i.imgur.com/CoxOjVq.png)
以上資訊是參考類似的測試工具 [Istanbul - a JavaScript test coverage tool](https://stackoverflow.com/questions/26618243/how-do-i-read-an-istanbul-coverage-report)
---
# <a id='snapshot'>Snapshot功能</a>
Snapshot testing is another new idea from Facebook. It provides an alternate way to write tests without any assertions. To write tests using assertions, Enzyme is quite useful.
快照(Snapshot)可以測試到組件的渲染結果是否符合預期,預期就是指你上一次錄入保存的結果,`toMatchSnapshot`方法會去幫你對比這次將要生成的結構與上次的區別。使用snapshot,可以**防止無意間修改**组件的某些部分,以及快速增加測試程式的覆蓋率,剩下像是click事件這種不太能直接用snapshot比對的部分,再靠手動去撰寫程式檢測。
在這邊會使用enzyme,可以先到下一章了解[enzyme](#enzyme)
```jsx=
import React, { Component } from 'react';
import { render } from 'enzyme';
it('renders correctly', () => {
const wrapper = render(
<div id="helloworld">
<strong>Hello World!</strong>
</div>
);
expect(wrapper).toMatchSnapshot();
});
```
第一次執行測試的時候,只要遇到`toMatchSnapshot`,就會在測試程式的同一層建立一個`__snapshots__`資料夾,並且產生`snap`檔,如果原來的測試檔案叫做`index.test.js`,那麼所產生的snap檔為`index.test.js.snap`
`index.test.js.snap`快照檔內容
```javascript=
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
id="helloworld"
>
<strong>
Hello World!
</strong>
</div>
`;
```
第二次跑測試程式的時候,就會去比對snapshot,如果比對結果有出入,就會跳出失敗訊息,當你想要更新snapshot時,只要執行`jest -u`或`jest --updateSnapshot
`就可以了。u是update的意思。
特別要注意的是,如果你Component內容改錯了,還下`jest -u`的話,就會把錯誤的DOM給記錄到snapshot裡面,所以在update之前記得要確認自己現在的程式OK才update。
如過只要測試單一的`test.js`檔案,像是我如果想執行`/app/components/choice`資料夾底下的測試程式,就在指令的地方下`jest /app/components/choice`,就會只測試對應到的檔案,在cmd上會顯示`Ran all test suites matching "/app/components/choice".`,同樣的,如果只要更新相關檔案的snap檔,就下`jest /app/components/choice -u`就可以了。
* [官網中有關update snapshot的使用說明](https://facebook.github.io/jest/docs/en/snapshot-testing.html#updating-snapshots)
註解: 個人覺得snapshot只能拿來當輔助,畢竟它只能比對現在DOM架構之前有無差異,但並不知道什麼才是"真正對的"架構。
---
# <a id='mockfunction'>Mock(模擬) Function</a>
* 參考文件:https://facebook.github.io/jest/docs/en/mock-functions.html
以上的斷言基本是用在測試同步函數的返回值,如果所測試的函數存在異步邏輯。那麼在測試時就應該利用jest 的mock function 來進行測試。通過mock function 可以輕鬆地得到回調函數的調用次數、參數等調用信息,而不需要編寫額外的代碼去獲取相關數據,讓測試用例變得更可讀。
```javascript=
// 輸入一個數字,它會回你平方跟乘以3的數字
function getDoubleAndMultiplyby3(val, callback) {
if (val < 0) {
return;
}
callback(val * val, val * 3);
}
const mockFn = jest.fn();
getDoubleAndMultiplyby3(5, mockFn);
getDoubleAndMultiplyby3(10, mockFn);
describe("testing getDouble() with a mock function", () => {
it("it should was called least once", () => {
// expect(mockFn).toHaveBeenCalled();
expect(mockFn).toBeCalled();
});
it("it should was called twice", () => {
expect(mockFn).toHaveBeenCalledTimes(2);
});
it("it should was return 25,15 in first callback", () => {
// expect(mockFn).toHaveBeenCalledWith(25,15);
expect(mockFn).toHaveBeenCalledWith(25, 15);
});
it("it should was return 100,30 in last callback", () => {
// expect(mockFn).toHaveBeenLastCalledWith(100,30);
expect(mockFn).lastCalledWith(100, 30);
});
});
```
* 參考資料:[利用Jest 進行測試](https://juejin.im/post/59b5e79f6fb9a00a600f4216)
# <a id='enzyme'>Enzyme - React版的`jquery`或`cheerio`</a>
enzyme可以把它想像成一個react版本的jquery,使用enzyme中的shallow,就可以將react component轉換成像jquery一樣的物件,接著可以使用像是find之類的method取得所要的資訊進行比對驗證.
除了shallow以外,還有mount與render兩種method,但基本的shallow最常用。
* `mount`:Full Rendering,非常適用於存在於DOM API存在交互組件,或者需要測試組件完整的生命週期
* `render`:Static Rendering,用於將React組件渲染成靜態的HTML並分析生成的HTML結構。`render`返回的`wrapper`與其他兩個API類似。不同的是`render`使用了第三方HTML解析器和`Cheerio`。
---
- Airbnb 開發的開放原始碼專案。
- 供測試使用的 utility,非框架。所以不包含測試環境, 斷言庫。
- 引身自 TestUtils, JSDOM, CheerIO。
- 提供可以渲染出 react 元件,並可模擬(simulate)使用者行為,如input change, button clicked。
- 核心有包含jquery,可以使用選擇器搜尋DOM的樹結構。
簡易的Enzyme範例
```jsx=
import { shallow } from 'enzyme';
import MyComponent from './MyComponent';
import Foo from './Foo';
describe('<MyComponent />', () => {
// 檢查是否成功渲染一個MyComponent
it('renders <MyComponent /> components', () => {
const wrapper = shallow(<MyComponent />);
expect(wrapper).toHaveLength(1);
// expect(wrapper).exists()); //應該與上一行效果一樣
});
// 檢查MyComponent中是否成功渲染3個Foo
it('renders three <Foo /> components', () => {
const wrapper = shallow(<MyComponent />);
expect(wrapper.find(Foo)).toHaveLength(3);
});
// 檢查MyComponent中是否成功渲染3個Foo
it('counter+1 when the last button is clicked', () => {
const wrapper = shallow(<MyComponent />);
//找出wrapper中的最後一個button,並且用滑鼠點擊
wrapper.find('button').last().simulate('click');
// ... expect(counter).toBe(1);
});
});
```
## 如何測試有delay的Component
兩個重點:
* 使用`async()`
* 使用`await sleep(毫秒)`
以下的測試的情境是當error發生的時候,畫面下方的Snackbar會跳出並顯示錯誤訊息,2秒後自動消失。
open這屬性會從原本的false變成true,並在2秒後變回false,等待2秒這件事情我們使用`await sleep(2050)`來實作,2050是因為2000+50,如果剛好用2000的話怕會有誤差,所以多50毫秒。另外,如果程式中使用到`await`,則必須把程式包在`async()`內。
```jsx=
it('set props to error, Snackbar should be opened', async () => {
const { wrapper, dispatch } = setup();
expect(wrapper.find(Snackbar).prop('open')).toBeFalsy();
wrapper.setProps({ error: 'RequestToken.Invalid' });
expect(wrapper.find(Snackbar).prop('open')).toBeTruthy();
await sleep(2050); // wait for more than 2000 ms(autoHideDuration)
expect(dispatch).toHaveBeenCalledWith({ error: '', type: 'app/Main/REQUEST_ERROR' });
wrapper.setProps({ error: '' });
expect(wrapper.find(Snackbar).prop('open')).toBeFalsy();
});
```
---
# <a id='guideline'>究竟是該測些什麼</a>
究竟Unit Test該測哪些項目呢? 以下是一些網路文章給的想法
## [React Component Testing with Enzyme](https://medium.com/@WendellLiu/react-component-testing-with-enzyme-af2e4f871f2f) 文章的結論
作者整理出3個他覺得該測試的項目
* 本身性質測試:
* 這Component本身的是哪種類型的DOM,是div還是input或其他
* 這Component的Class有哪些
* 這Component在帶入不同props下是否有切換到正確的Class
* 包含測試:
* 是否包含正確的子Component,像LoginPage中就會有兩個TextField跟一個RaisedButton
* Event 測試:
* 例如 clicking, dragging, keyboard input, etc,本文章範例是按下按鈕後會新增一個Todo,所以會去模擬按按鈕一次後預期Todolist中的item要多一個
## [The Right Way to Test React Components](https://medium.freecodecamp.org/the-right-way-to-test-react-components-548a4736ab22) 文章的結論
* 每個Component測試都該先注意的是它會render的樣子,至少要測這元件會顯示哪些基本的DOM
* 另外要測試Component所收到props以及本身就有的state
* 以及測試這Component會有哪些互動event(例如 clicking, dragging, keyboard input, etc)
* 不要測試 Prop types,因為這在主程式就已經有判斷機制了,不值得在測試程式再測一次
* Inline styles CSS通常不值得我們去測試,我覺得可能是因為CSS很常會修改,感覺太細節了
* 主Component旗下的component有哪些,以及收到props對Componet的影響,這是重要的測試項目
## 結論By Allen
* 我覺得本身性質測試不是那麼重要,包含測試比較重要一點
* Props是關鍵,不同的Props基本上會呈現不同Component,這部分搭配Coverage較容易觀察出哪些可能還沒有檢測到
* Event一定要測
* 參考Coverage,盡量達到100%覆蓋率,但是不用走火入魔
[Redux github 中的測試程式範例](https://github.com/reactjs/redux/tree/master/examples/counter/src/components)
![](https://i.imgur.com/yBql92U.png)
---
# <a id='container'>如何測試Container - Testing Container in Provider</a>
單純的component測試因為沒跟其他物件以及資料有相依的狀況,較容易測試,而Container則相對複雜,我們可以看到下面的`<App />`被包在很多層DOM裡面,如果缺乏這些外層DOM的資訊,將很難對`<App />`中的Container進行測試,解決方式就是模擬外層DOM將這些模擬的資訊傳遞給裡面的DOM。
```jsx=
<Provider store={store}>
<MuiThemeProvider muiTheme={getMuiTheme(customizedTheme)}>
<LanguageProvider messages={messages}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</LanguageProvider>
</MuiThemeProvider>
</Provider>
```
* `Provider`:提供store
* `MuiThemeProvider`:提供Material UI的資訊
* `LanguageProvider`:真正的Provider是`IntlProvider`,提供跨國語言包
* `ConnectedRouter`:負責記錄history
解決方法就是建立一個`enzymeHelper.js`檔案,在裡面實作`mountWithProviders`取代enzyme原生的`mount`,在`mountWithProviders
`執行enzyme的`mount`,並將所有要傳給Container的資訊放進context內,建立了出來的wrapper就能夠正常運作。
enzymeHelper.js
```jsx=
import React from 'react';
import { shallow, mount } from 'enzyme';
import { IntlProvider, intlShape } from 'react-intl'; // mock IntlProvider in LanguageProvider
import getMuiTheme from 'material-ui/styles/getMuiTheme'; // mock MuiThemeProvider
import ReactRouterEnzymeContext from 'react-router-enzyme-context'; // mock ConnectedRouter
import configureStore from 'redux-mock-store'; // mock Provider
// set up Provider
const store = configureStore([])({});
store.dispatch = jest.fn();
const dispatch = store.dispatch;
// set up intlProvider
const messages = require('translations/en.json');
const intlProvider = new IntlProvider({ locale: 'en', messages }, {});
const { intl } = intlProvider.getChildContext();
// set up MuiThemeProvider
const muiTheme = getMuiTheme();
// set up ConnectedRouter
const router = new ReactRouterEnzymeContext().get().context.router;
// assign props 'dispatch' into node, node is the component which we want to test.
function nodeWithProps(node) {
return React.cloneElement(node, { dispatch });
}
// pass down the context of Provider, intlProvider, MuiThemeProvider and ConnectedRouter to shallow
export function shallowWithProviders(node) {
return shallow(nodeWithProps(node), { context: { intl, muiTheme, router, store } });
}
// pass down the context of Provider, intlProvider, MuiThemeProvider and ConnectedRouter to mount
export function mountWithProviders(node) {
return mount(nodeWithProps(node), {
context: { intl, muiTheme, router, store },
childContextTypes: {
intl: intlShape,
muiTheme: React.PropTypes.object,
router: React.PropTypes.object,
store: React.PropTypes.object,
},
});
}
```
enzymeHelper.js的使用方法
```jsx=
import React from 'react';
import { mountWithProviders } from 'enzymeHelper';
const defaultProps = { 'test': "testVaule" }
const setup = (props = {}) => {
const wrapper = mountWithProviders(<MyContainer {...defaultProps} {...props} />);
const actions = {
testMethod: wrapper.instance().testMethod,
testOtherMethod: wrapper.instance().testOtherMethod,
mock: (...methods) => { //you need to implement mock() in every test.js
methods.forEach((method) => {
wrapper.instance()[method] = jest.fn();
actions[method] = wrapper.instance()[method];
});
},
};
return {
wrapper,
actions,
dispatch: wrapper.props().dispatch,
// customize common DOM you want to test
// ex: loginBtn: wrapper.find(FlatButton).at(0),
}
};
describe('functions of <MyComponent />', () => {
it('testMethod()', () => {
const { wrapper, actions, dispatch } = setup();
// testMethod() include a dispatch
actions.testMethod();
// check the testMethod() trigger dispatch or not
expect(dispatch).toHaveBeenCalledWith({
data: { testContent: null, type: 'Text' },
type: 'app/MyComponent/TEST_METHOD',
});
it('click button to trigger testMethod()', () => {
const { wrapper, actions } = setup();
// mock testMethod()
actions.mock('testMethod');
/*
* if you want to mock multiple functions
* you can assign mutiple parameters into actions.mock()
* ex: actions.mock('testMethod','testOtherMethod');
*/
wrapper.find('button').first().simulate('click');
// testMethod() will be called once after Clicking button
expect(actions.testMethod).toHaveBeenCalledTimes(1);
});
```
直接使用下方的Template開始開發Container的單元測試
```jsx=
// *** REMOVE THE COMMENTS IN THIS FILE TO START CODING UNIT TEST ***
/*
* ComponentName <= replace ComponentName with the Component you want to test.
* FunctionName <= replace FunctionName with the function you want to test.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithProviders } from 'enzymeHelper';
/* TODO: import the component you want like material-ui TextField or FlatButton */
import { ComponentName } from '../index';
const defaultProps = {
/* TODO: need to put default props here */
};
const setup = (props = {}) => {
const wrapper = mountWithProviders(<ComponentName {...defaultProps} {...props} />);
const actions = {
/* TODO: customize common DOM you want to test */
mock: (...methods) => {
methods.forEach((method) => {
wrapper.instance()[method] = jest.fn();
actions[method] = wrapper.instance()[method];
});
},
};
return {
wrapper,
actions,
dispatch: wrapper.props().dispatch,
/* TODO: customize common DOM you want to test */
};
};
describe('<ComponentName />', () => {
it('match snapshot', () => {
const wrapper = shallow(<ComponentName {...defaultProps} />);
expect(wrapper).toMatchSnapshot();
});
it('must have ......', () => {
const { wrapper } = setup();
// TODO: check if the important DOM exists ex: button, input, table
// ex: expect(wrapper.find(FlatButton)).toHaveLength(1);
});
// TODO: check events executing correctly or not, ex: click,change,keypress
// TODO: check UI changing correctly or not when different props assign
});
describe('functions of <ComponentName />', () => {
it('FunctionName()', () => {
const { actions } = setup();
// if you want to mock FunctionName(), then use actions.mock('FunctionName');
actions.FunctionName();
// TODO: check the result correct or not
});
// TODO: test other functions
});
```
Component的單元測試Template
```jsx=
//直接使用下方的Template開始開發Container的單元測試
import React from 'react';
import { shallow } from 'enzyme';
// TODO: import the component you want like material-ui TextField or FlatButton
const defaultProps = {
// TODO: need to put default props here
};
import CheckboxOption from '../index';
describe('<CheckboxOption />', () => {
it('match snapshot and must have ......', () => {
const wrapper = shallow(<CheckboxOption {...defaultProps} />);
// TODO: check if the important DOM exists ex: button, input, table
// ex: expect(wrapper.find(FlatButton)).toHaveLength(1);
});
// TODO: check events executing correctly or not, ex: click,change,keypress
// TODO: check UI changing correctly or not when different props assign
```
---
# <a id='saga'>如何測試Redux Saga</a>
Saga的測試方式跟UI的測試方式有點不同,所以在這邊解說該如何測試,其實主要就是把原本`saga.js`中所有yield後的`put`,`call`等相關指令全部測過一次。
* [youtube影片 - 如何測試Saga,必看!](https://youtu.be/-UQmC5czkTw)
* [非官方 redux-saga Unit Test sample code](https://codeburst.io/how-i-test-redux-saga-fcc425cda018)
* redux-saga 官方github資訊
* 關於redux-saga的測試 - Testing md 檔案 [[ 英文版 ]](https://github.com/redux-saga/redux-saga/blob/master/docs/advanced/Testing.md) [[ 中文版 ]](https://neighborhood999.github.io/redux-saga/docs/advanced/Testing.html)
* [範例程式 index.js](https://github.com/redux-saga/redux-saga/blob/master/examples/shopping-cart/src/sagas/index.js)
* [範例程式 test.js](https://github.com/redux-saga/redux-saga/blob/master/examples/shopping-cart/test/sagas.js)
## 範例程式
api.js
```javascript=
const api = {
fetchProductAPI() {
return 'iphone';
},
};
export default api;
```
saga.js
```javascript=
import { call, put } from 'redux-saga/effects';
import api from './api';
export default function* fetchProduct() {
try {
yield call(api.fetchProductAPI);
yield put({ type: 'PRODUCTS_RECEIVED', product: 'iphone' });
} catch (error) {
yield put({ type: 'PRODUCTS_REQUEST_FAILED', error });
}
}
```
**saga.test.js**
```javascript=
/* eslint-disable redux-saga/yield-effects */
import { put, call } from 'redux-saga/effects';
import { cloneableGenerator } from 'redux-saga/utils';
import fetchProduct from './saga';
import api from './api';
describe('fetchProduct()', () => {
// gen = fetchProduct(); <== original style
const gen = cloneableGenerator(fetchProduct)();
it('try', () => {
const clone = gen.clone();
expect(clone.next().value).toEqual(call(api.fetchProductAPI));
expect(clone.next().value).toEqual(put({ type: 'PRODUCTS_RECEIVED', product: 'iphone' }));
});
it('catch', () => {
const error = 'product not found';
const clone = gen.clone();
clone.next(); // <== before throw error, you need to execute gen.next();
expect(gen.throw('product not found').value).toEqual(put({ type: 'PRODUCTS_REQUEST_FAILED', error }));
});
});
```
## Redux-Saga的Library (目前沒使用Library)
Redux-Saga也有Library,其中星星數最多的框架是[redux-saga-test-plan ](https://github.com/jfairbank/redux-saga-test-plan)(415 star),但是目前一直套用失敗,有時間與需求的話再回頭研究,有個[網站](http://blog.scottlogic.com/2018/01/16/evaluating-redux-saga-test-libraries.html)分析各個Redux Saga Test Library。
## saga測試程式如何傳遞參數
generator.next(`value`)中的value該填入什麼才對呢?
可以看到下面的saga.test.js的第3行中`generator.next(product).value`,這個`product`填這邊的原因要看到saga.js,saga.js中第2行的`yield put({ type: 'CALL_API' });`是接續第1行的`const product = yield call(api);`,所以要將`product`填在next中讓之後的測試程式可以用到`product`。
saga.js程式片段
```javascript=
const product = yield call(api);
yield put({ type: 'CALL_API' });
```
saga.test.js程式片段
```jsx=
const prouduct = "mock product data";
expect(generator.next().value).toEqual(call(api));
expect(generator.next(product).value).toEqual(put({ type: 'CALL_API' }));
```
* 參考資料:[generator.next(value) 的解說](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/next)
## 如何解決saga中的if-else分支問題
有時候你的 saga 可能會有不同的結果。為了要測試不同的 branch 而不重複所有流程,你可以使用 **cloneableGenerator** utility function,他可以複製某一步驟的generator,範例如下
```javascript=
import { cloneableGenerator } from 'redux-saga/utils';
// 原本的程式是 const gen = getOPList(action);
// 如果要複製品這個generator,就改寫成下面這行
const gen = cloneableGenerator()();
it('use clone', () => {
//將剛剛的gen複製一份成clone
const clone = gen.clone();
expect(clone.next().value).toEqual(put({type:"ohya"})));
});
```
## 如何測試 try-catch 中的 catch 事件
想了解整段程式怎麼寫的可以參考[完整範例程式](https://gist.github.com/Hsueh-Jen/e70c59ab5638912b17c8c4e9cfc9b7f5),其中要跳到catch的case中,就必須使用`throw()`,但在`throw()`之前我們必須先執行一次`next()`才行,error部分的測試程式如下。
```javascript=
it('catch', () => {
const error = 'product not found';
const gen = fetchProduct();
gen.next(); // <== before throw error, you need to execute gen.next();
expect(gen.throw('product not found').value)
.toEqual(put({ type: 'PRODUCTS_REQUEST_FAILED', error }));
});
```
## 如何測試Saga程式中的flow function
基本上flow function都使用takeEvery,目前測試遇到takeEvery都會失敗,後續再研究如何測試takeEvery,或是根本不需要去測試。
* 以下是Annie找到的解法,有時間再補上作法
* [Annie找到的解法](https://github.com/redux-saga/redux-saga/issues/318)
* [使用createMockTask來模擬task](https://github.com/redux-saga/redux-saga/blob/master/docs/advanced/TaskCancellation.md#testing-generators-with-fork-effect)
* race的寫法在Main裡面
# <a id='tips'>常見錯誤</a>
觸發event的方式會因應你使用`shallow`或`mount`會有所不同,下面以`onChange`事件為例寫下兩種不同範例。
shallow
```javascript=
const input = wrapper.find(TextField);
input.simulate('change', { target: { value: 'newValue' } });
```
mount
```javascript=
const input = wrapper.find(TextField);
input.prop('onChange')({ target: { value: 'newValue' } });
```
取得Dialog中actions裡面的按鈕
```javascript=
wrapper
.find(Dialog)
.prop('actions')
.forEach((btn) => {
btn.props.onClick();
});
```
取得Dialog內Component的方法:<Dialog>中的內容無法直接使用find來抓取的,所以我們用prop('children')來抓取,但隨著每個Dialog內部有著不同的
```javascript=
const dialog = wrapper.find(Dialog);
dialog.prop('children')[0].props.onChange();
```
```javascript=
const dialog = wrapper.find(Dialog);
dialog.prop('children').props.children.forEach((el) => {
if (el.props.children) {
el.props.children[0].props.leftCheckbox.props.onCheck(null, true);
el.props.children[0].props.leftCheckbox.props.onCheck(null, false);
el.props.children[0].props.nestedItems.forEach((listItem) => {
listItem.props.leftCheckbox.props.onCheck(null, false);
listItem.props.leftCheckbox.props.onCheck(null, true);
});
}
});
```
---
# 參考資料
教學影片:
* [Enzyme Tutorial - How to Write Test Code for React](https://youtu.be/nvL2ha0XUYo?t=3m15s)
* [Using AirBnb’s Enzyme to Test React Components](https://youtu.be/0_8rESjFcro)
* [Testing React Components with Enzyme and Jest](https://youtu.be/u5XTnNBotqs)
* [如何測試Reducer,不包含saga](https://youtu.be/F5qu-ieYFTw)
文件與網站:
* enzyme on github - https://github.com/airbnb/enzyme
* enzyme document - http://airbnb.io/enzyme/
* [基於Jest + Enzyme 的React 單元測試](http://react-china.org/t/jest-enzyme-react/11769)
* [使用jest + Enzyme 進行反應項目測試 - 測試手法篇](http://echizen.github.io/tech/2017/02-12-jest-enzyme-method)
* [Unit Testing React Components: Jest or Enzyme?](https://www.codementor.io/vijayst/unit-testing-react-components-jest-or-enzyme-du1087lh8)
* [使用Jest 對React 進行快照測試與DOM 測試](http://laichuanfeng.com/work/jest-snapshot-and-dom-testing-in-react/)
* [React單元測試:Jest + Enzyme(一)](https://segmentfault.com/a/1190000011468620)
* [Testing React Components With Enzyme](http://codeheaven.io/testing-react-components-with-enzyme/)
* [Unit testing React components, 5 basic techniques](https://karl.run/2017/06/30/getting-up-and-running-with-testing-react/)
研究中的題目
* action.js中的測試程式感覺不太重要,只是一般的驗證參數
* action跟reducer的測試程式寫法參考 - [Redux unit testing with Jest
](https://hackernoon.com/redux-unit-testing-with-jest-f3a18f387f75)
* [Unit Testing Redux](http://frontend.turing.io/lessons/module-3/testing-redux.html)
* [Test your Redux container with Enzyme](https://medium.com/@visualskyrim/test-your-redux-container-with-enzyme-a0e10c0574ec)
* [Unit Testing Redux Connected Components](https://hackernoon.com/unit-testing-redux-connected-components-692fa3c4441c)
* [solution for simulating onchange event, but it still didn't work](https://github.com/airbnb/enzyme/issues/1412)
* 如何外掛 MuiThemeProvider - [How to pass context down to the Enzyme mount method to test component which includes Material UI component?](https://stackoverflow.com/questions/38264715/how-to-pass-context-down-to-the-enzyme-mount-method-to-test-component-which-incl)
* 如何外掛 IntlProvider
* [Injecting react-intl object into mounted Enzyme components for testing](https://stackoverflow.com/questions/37021217/injecting-react-intl-object-into-mounted-enzyme-components-for-testing)
* [Testing React Intl components with Jest and Enzyme](http://blog.sapegin.me/all/react-intl-jest-enzyme)
* [react-router-enzyme-context](https://github.com/express-labs/react-router-enzyme-context), [redux-mock-store](https://github.com/arnaudbenard/redux-mock-store)
* [如何測試material ui中Dialog裡的Button](https://github.com/mui-org/material-ui/issues/6290)
* [redcuer test youtube](https://www.youtube.com/watch?v=jB0_nl7aKqA)
* 當專案中有saga的時候,要單測reducer相當的難