iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 30
2
Modern Web

寫React的那些事系列 第 30

React Day30 - Redux Testing(2)

今天我們要繼續完成Redux的reducers和components的測試,本來想一篇介紹一個,但沒想到已經是最後一篇了,就讓我們把最後的test都完成吧!

撰寫Reducer tests


在redux中reducer非常pure,它是依照我們傳入的action來回傳next state,所以test也非常好寫。唯一我覺得要特別注意的地方,就是每個reducer都可以加上初始化的test case,因為建立store時,會根據這個預設值來初始化。以下用filter redux來做範例。

test/reducers/filter.test.js

import { expect } from 'chai';
import * as types from '../../src/constants/ActionTypes';
import filter from '../../src/reducers/filter';

describe('filter reducer testing', () => {
  it('should handle filter state for default value', () => {
    // 當prev state尚未建立時,Redux會先發出預設action
    // 所以unit test可以這樣測試初始state值是否如預期
    expect(
      filter(undefined, { type: '' })
    ).to.be.equal('SHOW_ALL');
  });

  it('should handle filter state', () => {
    // 針對set filter來測試
    expect(
      filter('', {
        type: types.SET_FILTER,
        filter: 'SHOW_COMPLETED'
      })
    ).to.be.equal('SHOW_COMPLETED');
  });
});

在寫reducer test時,常常會需要設定prev state的值,我們可以使用mocha hook提供的beforeEach在每個test case之前先設定prev state值,昨天我們也有使用到其他的mocha hook,以下簡單說明這四種hook。

describe('hooks', function() {
  before(() => {
    // 在所有test case執行之前,會先執行一次
  });
  after(() => {
    // 在所有test case執行之後,才會執行一次
  });
  beforeEach(() => {
    // 在每個test case執行之前,都會先執行一次
  });
  afterEach(() => {
    // 在每個test case執行之後,都會執行一次
  });
});

所以像todos reducer,如果我們希望可以測試刪除功能,我們可以這樣寫:

import { expect } from 'chai';
import * as types from '../../src/constants/ActionTypes';
import todos from '../../src/reducers/todos';

describe('todos reducer testing', () => {
  let originTodos;
  beforeEach(() => {
    originTodos = [
      {
        task: 'Search mocha',
        isCompleted: false
      }
    ];
  });

  // ... other test case

  it('should handle todos state when deleting task', () => {
    // todos reducer第一個參數傳入prev state
    // 刪除之後,就會是空的,所以用 .to.be.empty
    expect(
      todos(originTodos, {
        type: types.DELETE_TASK,
        idx: 0
      })
    ).to.be.empty;
  });
});

撰寫Component tests


Component通常只依賴父層傳入的props,官方有推出測試package react-addons-test-utils來模擬render的功能,不過,airbnb開發的Enzyme更直覺更容易了解,它背後也是透過react-addons-test-utils來操作,受到官方的推薦,所以接下來會直接介紹Enzyme的測試方式。

首先,我們要再設定 .babelrc ,讓babel幫我們轉譯react:

{
  "presets": ["es2015", "react"]
}

安裝相關packages:

npm install enzyme react-addons-test-utils --save-dev

Enzyme有三種測試API,shallowrendermount,以下分別說明。

Shallow Rendering API

使用 shallow 渲染component,會把component當作獨立的單元渲染出來,不會影響到子元件,Enzyme有提供許多 shallow API可以使用,請參考

以下使用 Loading.test.js 來當範例:

import { expect } from 'chai';
import React from 'react';
import { shallow } from 'enzyme';
import Loading from '../../src/components/Loading';

describe('Loading component testing', () => {
  it('should have loading class', () => {
    const wrapper = shallow(<Loading />);
    expect(wrapper.find('.loading').exists()).to.be.true;
  });
});

Static Rendering API

使用 render 會把component模擬render在靜態的HTML上,和shallow其實很類似,主要是透過另一套Cheerio實作出來,Enzyme有提供許多 render API可以使用,請參考,大致上都和shallow用法相同。

以下使用 Filter.test.js 來當範例:

import { expect } from 'chai';
import React from 'react';
import { render } from 'enzyme';
import Filter from '../../src/components/Filter';

describe('Filter component testing', () => {
  it('should have three buttons', () => {
    const props = {
      filter: 'SHOW_ALL'
    };
    const wrapper = render(<Filter {...props} />);
    expect(wrapper.find('button').length).to.equal(3);
  });
});

Full Rendering API (mount(...))

當component有需要和DOM做一些互動操作,就可以使用 mount ,它提供simulate模擬events,Enzyme有提供許多 mount API可以使用,請參考

因為mount相關的API需要在DOM中執行,所以我們還需要使用jsdom來模擬DOM環境,按照Enzyme官網教學建立一個setup.js,並在before的區塊裡執行它。

建立setup.js把它export出來,並且放在helpers資料夾中 helpers/setup.js

module.exports = () => {
  const jsdom = require('jsdom').jsdom;

  global.document = jsdom('');
  global.window = document.defaultView;
  Object.keys(document.defaultView).forEach((property) => {
    if (typeof global[property] === 'undefined') {
      global[property] = document.defaultView[property];
    }
  });

  global.navigator = {
    userAgent: 'node.js'
  };
};

使用 TodoItem.test.js 來當範例:

import { expect } from 'chai';
import React from 'react';
import DomMock from '../helpers/setup';
import { mount } from 'enzyme';
import TodoItem from '../../src/components/TodoItem';

describe('TodoItem component testing', () => {
  // 建立before,在test suite開始前先執行
  before(() => {
    // 執行剛剛建立的setup
    DomMock();
  });

  it('should show input when clicking Edit button', () => {
    const props = {
      todo: {
        task: 'Finish unit test',
        isCompleted: false
      }
    };

    // TodoItem裡面有tr,必須用table包裹
    const wrapper = mount(
      <table>
        <tbody>
          <TodoItem {...props} />
        </tbody>
      </table>);

    // 按下Edit之前,沒有input欄位
    expect(wrapper.find('input').length).to.equal(0);
    // 模擬click event
    wrapper.find('button').at(0).simulate('click');
    // 按下Edit之後,出現input欄位
    expect(wrapper.find('input').length).to.equal(1);
  });
});

Container component tests


前面我們講到的都是presentational component,再來要說明container component的測試方式,其實很簡單只需要把還沒被connect()包裝前的class export出來。

TodoAppContainer.js 來當範例:

// 使用name export,讓這個元件可以被unit test使用
export const App = props => {
  return (
    <div>
      // ...
    </div>
  );
};

const mapStateToProps = state => {};
const mapDispatchToProps = dispatch => {};

const TodoAppContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(App);

// 保留default export
export default TodoAppContainer;

在撰寫unit test時,就可以這樣讀取:

// 加上大括號,抓取name export
import { App } from '../../src/containers/TodoAppContainer';

其他的方式就都會和前面測試component一樣!

不過,這邊我們會再遇到一個問題,沒關係!關關難過~但關關不說。在TodoAppContainer中,我們使用了css檔案:

import '../../css/style.css';

Mocha在執行 .css 時會遇到問題,因為當我們開發時會透過webpack把這些靜態資源bundle出來,可是測試時,我們只有透過babel-core/register來編譯檔案。根據找到的這篇Handle WebPack CSS imports when testing with Mocha,我們在helpers資料夾下建立一個 css-null-compiler.js ,讓測試的時候可以跳過css、png...等,我們不需要它被轉譯的副檔名,或是也可以使用ignore-styles package。

這邊選用建立 helpers/css-null-compiler.js 檔案:

const noop = () => {
  return null;
};

// you can add whatever you wanna handle
require.extensions['.css'] = noop;
require.extensions['.png'] = noop;
require.extensions['.jpg'] = noop;

然後,在執行mocha時,我們在 package.json 的scripts加上 --require test/helpers/css-null-compiler

"scripts": {
  // ...
  "test": "mocha --compilers js:babel-core/register --require test/helpers/css-null-compiler --recursive"
},

目前應該會覺得指令真的好長,雖然保存在 package.json 不算很麻煩,但我們可以用到昨天提到的 mocha.opts 來讓指令更清爽。

mocha.opts

--compilers js:babel-core/register
--require test/helpers/css-null-compiler
--recursive

package.json 就可以改成:

"scripts": {
  // ...
  "test": "mocha"
},

如果有在global安裝mocha,也可以直接在CLI執行:

mocha

Done!以上範例都只有顯示局部的unit test程式碼,但我有把完整的unit test都補完,已經更新在Git 上,有興趣的話可以看完整的範例參考。

最後!呼~~終於,鐵人賽30篇完成啦!喔耶!本來以為第30篇會是參賽心得的,但沒想到因為中間有調整一些篇幅,剛好整整30篇呀!希望明天可以把這次的心得記錄下來,當作鐵人賽番外篇。

謝謝大家陪我度過這30天!

參考


React Test Utilities
Redux Testing


上一篇
React Day29 - Redux Testing(1)
下一篇
React Day 番外篇 - ironman 30+1天,賽後心得
系列文
寫React的那些事31

1 則留言

0
小財神
站方管理人員 ‧ 2016-12-30 09:56:47

期待你的心得喔! ^^

chiouchu iT邦新手 5 級 ‧ 2016-12-30 10:33:14 檢舉

謝謝你~

/images/emoticon/emoticon07.gif

我要留言

立即登入留言