Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

用jest+enzyme來寫Reactjs的單元測試吧! #1

Open
Hsueh-Jen opened this issue Sep 12, 2018 · 0 comments
Open

用jest+enzyme來寫Reactjs的單元測試吧! #1

Hsueh-Jen opened this issue Sep 12, 2018 · 0 comments
Labels
enzyme enzyme is like a ReactJS version jquery jest jest is a unit test tool for ReactJS

Comments

@Hsueh-Jen
Copy link
Owner

單元測試

這份文件將會告訴你哪些資訊

這份文件會告訴你如何使用jest與enzyme兩個工具來撰寫單元測試,並應用在你的react專案裡,你將會學會以下所提到的項目。

有關單元測試的前言

前端的單元測試在很多人看來都是一個可有可無的東西,理由一般有下面幾條(以下內容統一稱單元測試為單測):

  • 寫單測比較費時,有這個時間不如多做幾個需求
  • 測試在驗收的時候對頁面的功能都會操作一遍,寫單測相當於做無用功
  • 後端提供給前端的接口需要保證質量,因此需要做單測,但前端很少需要提供接口給其他人

其實,我大體上是同意以上觀點的。在大部分的情況下,如果公司的業務不復雜,是完全沒必要做單測的。但如果涉及到以下幾個方面,你就要考慮是否有必要引入單測了:

  • 業務比較複雜,前端參與的人員超過3人
  • 公司非常注重代碼質量,想盡一切辦法杜絕線上出bug
  • 你是跨項目組件的提供方
  • 你在做一個開源項目

參考資料: React單元測試:Jest + Enzyme(一)

單元測試 - 3A原則

在撰寫單元測試的程式碼時,有個 3A 原則,來輔助設計測試程式,可以讓測試程式更好懂。3A 原則如下:

  1. Arrange : 初始化目標物件、相依物件、方法參數、預期結果,或是預期與相依物件的互動方式。
  2. Act : 呼叫目標物件的方法。
  3. Assert : 驗證是否符合預期

Jest - Test runner, JavaScript testing framework

Jest是一個JavaScript的測試框架,也是所謂的Test runner,類似的項目大概有Jasmine(茉莉花), Mocha(摩卡咖啡), AVA這幾種測試框架,Mocha應該是最多人使用的,但後來Facebook延續Jasmine開發Jest,也是目前專案預設的測試框架,所以就繼續沿用。

因為Jest是新框架,所以也比Mocha多了一些新功能,或是讓語法更加精鍊。

主要API使用教學

  • jest指令是使用jest-cli模組,在react-boilerplate中已經安裝在devDependencies內,相關設定也幫你設定好了。
  • 可以安裝vsCode的jest-snippets套件

先寫一個簡單的function來測試,接著用pass.test.js檔案來寫測試程式,完成後在cmd下執行jest,jest就會去所有目錄中找檔名是.test.js.spec.js結尾的檔案來跑測試程式,把測試程式都放在根目錄中的__test__資料夾也可以。

不過在我們專案直接下npm run test就可以了,jest指令已經被整合進去了。

這是我們要測試的pass.js

const isPass = score => {
  if(score>=60){
    return true;
  }else{
    return false;
  }
};
module.exports = isPass;

這是我們的測試程式pass.test.js

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 前端單元測試教學

jest 的一些斷言方法

// 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'});

Jest 覆蓋率解說

stackover的網友講解如何看覆蓋率報表

  • 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

在專案中的package.json檔案中的script屬性加上

"test:single": "node ./node_modules/jest-single-file-coverage"

就能使用npm run test:single <file_path>對該路徑下的測試檔案進行測試,並顯示coverage


choice12.js

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

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);
  // });
});

將覆蓋率匯出成HTML報表觀看

在command line下的Uncoverd lines大概只能顯示... 19,20,22,23短短幾行,非常不方便,所以這邊分享觀看jest匯出的精美網頁版coverage報表。

在jest中測試覆蓋率後,似乎預設會匯出HTML檔案報表(不確定是不是react-boilerplate中
已經設定好的關係),在每次測試覆蓋率後,可以在根目錄看到coverage資料夾,這個資料夾也在.gitingore中被記載,直接開啟coverage/lcov-report/index.html就能看到精美的覆蓋率報告。

在index.html中可以看到每個檔案的覆蓋率

點擊index.html中的檔名則可觀看每個檔案中覆蓋率

觀看程式碼時,不同顏色與符號分別代表哪些資訊:

  • 粉紅色的程式碼: 尚未被執行的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的部分沒被執行」示意圖

以上資訊是參考類似的測試工具 Istanbul - a JavaScript test coverage tool


Snapshot功能

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

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快照檔內容

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders correctly 1`] = `
<div
  id="helloworld"
>
  <strong>
    Hello World!
  </strong>
</div>
`;

第二次跑測試程式的時候,就會去比對snapshot,如果比對結果有出入,就會跳出失敗訊息,當你想要更新snapshot時,只要執行jest -ujest --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就可以了。

註解: 個人覺得snapshot只能拿來當輔助,畢竟它只能比對現在DOM架構之前有無差異,但並不知道什麼才是"真正對的"架構。


Mock(模擬) Function

以上的斷言基本是用在測試同步函數的返回值,如果所測試的函數存在異步邏輯。那麼在測試時就應該利用jest 的mock function 來進行測試。通過mock function 可以輕鬆地得到回調函數的調用次數、參數等調用信息,而不需要編寫額外的代碼去獲取相關數據,讓測試用例變得更可讀。

// 輸入一個數字,它會回你平方跟乘以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);
  });
});

Enzyme - React版的jquerycheerio

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範例

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()內。

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();
});

究竟是該測些什麼

究竟Unit Test該測哪些項目呢? 以下是一些網路文章給的想法

React Component Testing with Enzyme 文章的結論

作者整理出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 文章的結論

  • 每個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 中的測試程式範例


如何測試Container - Testing Container in Provider

單純的component測試因為沒跟其他物件以及資料有相依的狀況,較容易測試,而Container則相對複雜,我們可以看到下面的<App />被包在很多層DOM裡面,如果缺乏這些外層DOM的資訊,將很難對<App />中的Container進行測試,解決方式就是模擬外層DOM將這些模擬的資訊傳遞給裡面的DOM。

<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

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的使用方法

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的單元測試

// *** 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

//直接使用下方的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

如何測試Redux Saga

Saga的測試方式跟UI的測試方式有點不同,所以在這邊解說該如何測試,其實主要就是把原本saga.js中所有yield後的put,call等相關指令全部測過一次。

範例程式

api.js

const api = {
  fetchProductAPI() {
    return 'iphone';
  },
};
export default api;

saga.js

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

/* 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 (415 star),但是目前一直套用失敗,有時間與需求的話再回頭研究,有個網站分析各個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程式片段

const product = yield call(api);
yield put({ type: 'CALL_API' });

saga.test.js程式片段

const prouduct = "mock product data";
expect(generator.next().value).toEqual(call(api));
expect(generator.next(product).value).toEqual(put({ type: 'CALL_API' }));

如何解決saga中的if-else分支問題

有時候你的 saga 可能會有不同的結果。為了要測試不同的 branch 而不重複所有流程,你可以使用 cloneableGenerator utility function,他可以複製某一步驟的generator,範例如下

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 事件

想了解整段程式怎麼寫的可以參考完整範例程式,其中要跳到catch的case中,就必須使用throw(),但在throw()之前我們必須先執行一次next()才行,error部分的測試程式如下。

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,或是根本不需要去測試。

常見錯誤

觸發event的方式會因應你使用shallowmount會有所不同,下面以onChange事件為例寫下兩種不同範例。

shallow

const input = wrapper.find(TextField);
input.simulate('change', { target: { value: 'newValue' } });

mount

const input = wrapper.find(TextField);
input.prop('onChange')({ target: { value: 'newValue' } });

取得Dialog中actions裡面的按鈕

wrapper
  .find(Dialog)
  .prop('actions')
  .forEach((btn) => {
    btn.props.onClick();
  });

取得Dialog內Component的方法:中的內容無法直接使用find來抓取的,所以我們用prop('children')來抓取,但隨著每個Dialog內部有著不同的

const dialog = wrapper.find(Dialog);

dialog.prop('children')[0].props.onChange();
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);
    });
  }
});

參考資料

教學影片:

文件與網站:

研究中的題目

@Hsueh-Jen Hsueh-Jen added jest jest is a unit test tool for ReactJS enzyme enzyme is like a ReactJS version jquery labels Sep 12, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enzyme enzyme is like a ReactJS version jquery jest jest is a unit test tool for ReactJS
Projects
None yet
Development

No branches or pull requests

1 participant