對 React 組件進行單元測試(unit testing) 原 薦

在這裏說一下前端開發的一個特點是更多的會涉及用戶界面,當開發規模達到一定程度時,幾乎註定了其複雜度會成倍的增長。

無論是在代碼的初始搭建過程中,還是之後難以避免的重構和修正bug過程中,常常會陷入邏輯難以梳理、無法掌握全局關聯的境地。

而單元測試作爲一種“提綱挈領、保駕護航”的基礎手段,爲開發提供了“圍牆和腳手架”,可以有效的改善這些問題。

作爲一種經典的開發和重構手段,單元測試在軟件開發領域被廣泛認可和採用;前端領域也逐漸積累起了豐富的測試框架和最佳實踐。

本文將按如下順序進行說明:

  • I. 單元測試簡介
  • II. React 單元測試中用到的工具
  • III. 用測試驅動 React 組件重構
  • IV. React 單元測試常見案例

I. 單元測試簡介

單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。

簡單來說,單元就是人爲規定的最小的被測功能模塊。單元測試是在軟件開發過程中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其他部分相隔離的情況下進行測試。

測試框架

測試框架的作用是提供一些方便的語法來描述測試用例,以及對用例進行分組。

斷言(assertions)

斷言是單元測試框架中核心的部分,斷言失敗會導致測試不通過,或報告錯誤信息。

對於常見的斷言,舉一些例子如下:

  • 同等性斷言 Equality Asserts

    • expect(sth).toEqual(value)
    • expect(sth).not.toEqual(value)
  • 比較性斷言 Comparison Asserts

    • expect(sth).toBeGreaterThan(number)
    • expect(sth).toBeLessThanOrEqual(number)
  • 類型性斷言 Type Asserts

    • expect(sth).toBeInstanceOf(Class)
  • 條件性測試 Condition Test

    • expect(sth).toBeTruthy()
    • expect(sth).toBeFalsy()
    • expect(sth).toBeDefined()

斷言庫

斷言庫主要提供上述斷言的語義化方法,用於對參與測試的值做各種各樣的判斷。這些語義化方法會返回測試的結果,要麼成功、要麼失敗。常見的斷言庫有 Should.js, Chai.js 等。

測試用例 test case

爲某個特殊目標而編制的一組測試輸入、執行條件以及預期結果,以便測試某個程序路徑或覈實是否滿足某個特定需求。

一般的形式爲:

it('should ...', function() {
        ...

    expect(sth).toEqual(sth);
});

測試套件 test suite

通常把一組相關的測試稱爲一個測試套件

一般的形式爲:

describe('test ...', function() {

    it('should ...', function() { ... });

    it('should ...', function() { ... });

    ...

});

spy

正如 spy 字面的意思一樣,我們用這種“間諜”來“監視”函數的調用情況

通過對監視的函數進行包裝,可以通過它清楚的知道該函數被調用過幾次、傳入什麼參數、返回什麼結果,甚至是拋出的異常情況。

var spy = sinon.spy(MyComp.prototype, 'componentDidMount');

...

expect(spy.callCount).toEqual(1);

stub

有時候會使用stub來嵌入或者直接替換掉一些代碼,來達到隔離的目的

一個stub可以使用最少的依賴方法來模擬該單元測試。比如一個方法可能依賴另一個方法的執行,而後者對我們來說是透明的。好的做法是使用stub 對它進行隔離替換。這樣就實現了更準確的單元測試。

var myObj = {
    prop: function() {
        return 'foo';
    }
};

sinon.stub(myObj, 'prop').callsFake(function() {
    return 'bar';
});

myObj.prop(); // 'bar'

mock

mock一般指在測試過程中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來創建以便測試的測試方法

廣義的講,以上的 spy 和 stub 等,以及一些對模塊的模擬,對 ajax 返回值的模擬、對 timer 的模擬,都叫做 mock 。

測試覆蓋率(code coverage)

用於統計測試用例對代碼的測試情況,生成相應的報表,比如 istanbul 是常見的測試覆蓋率統計工具

II. React 單元測試中用到的工具

Jest

不同於"傳統的"(其實也沒出現幾年)的 jasmine / Mocha / Chai 等前端測試框架 -- Jest的使用更簡單,並且提供了更高的集成度、更豐富的功能。

Jest 是 Facebook 出品的一個測試框架,相對其他測試框架,其一大特點就是就是內置了常用的測試工具,比如自帶斷言、測試覆蓋率工具,實現了開箱即用。

此外, Jest 的測試用例是並行執行的,而且只執行發生改變的文件所對應的測試,提升了測試速度。

四個基礎單詞

編寫單元測試的語法通常非常簡單;對於jest來說,由於其內部使用了 Jasmine 2 來進行測試,故其用例語法與 Jasmine 相同。

實際上,只要先記這住四個單詞,就足以應付大多數測試情況了:

  • describe: 定義一個測試套件
  • it:定義一個測試用例
  • expect:斷言的判斷條件
  • toEqual:斷言的比較結果
describe('test ...', function() {
    it('should ...', function() {
        expect(sth).toEqual(sth);
        expect(sth.length).toEqual(1);
        expect(sth > oth).toEqual(true);
    });
});

配置

Jest 號稱自己是一個 “Zero configuration testing platform”,只需在 npm scripts裏面配置了test: jest,即可運行npm test,自動識別並測試符合其規則的(一般是 __test__ 目錄下的)用例文件。

實際使用中,適當的自定義配置一下,會得到更適合我們的測試場景:

//jest.config.js

module.exports = {
    modulePaths: [
        "<rootDir>/src/"
    ], 
    moduleNameMapper: {
        "\.(css|less)$": '<rootDir>/__test__/NullModule.js'
    },
    collectCoverage: true,
    coverageDirectory: "<rootDir>/src/",
    coveragePathIgnorePatterns: [
        "<rootDir>/__test__/"
    ],
    coverageReporters: ["text"],
};

在這個簡單的配置文件中,我們指定了測試的“根目錄”,配置了覆蓋率(內置的istanbul)的一些格式,並將原本在webpack中對樣式文件的引用指向了一個空模塊,從而跳過了這一對測試無傷大雅的環節

//NullModule.js

module.exports = {};

另外值得一提的是,由於jest.config.js是一個會在npm腳本中被調用的普通 JS 文件,而非XXX.json.XXXrc的形式,所以 nodejs 的各自操作都可以進行,比如引入 fs 進行預處理讀寫等,靈活性非常高,可以很好的兼容各種項目

babel-jest

由於是面向src目錄下測試其React代碼,並且還使用了ES6語法,所以項目下需要存在一個.babelrc文件:

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

以上是基本的配置,而實際由於webpack可以編譯es6的模塊,一般將babel中設爲{ "modules": false },此時的配置爲:

//package.json

"scripts": {
    "test": "cross-env NODE_ENV=test jest",
},

//.babelrc

{
  "presets": [
    ["es2015", {"modules": false}],
    "stage-1",
    "react"
  ],
  "plugins": [
    "transform-decorators-legacy",               如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。
    "react-hot-loader/babel"                     感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各
  ],                                             個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處    
  "env": {                                       之後分享給大家。
    "test": {
      "presets": [
        "es2015", "stage-1", "react"
      ],
      "plugins": [
        "transform-decorators-legacy",
        "react-hot-loader/babel"
      ]
    }
  }
}

Enzyme

Enzyme 來自於活躍在 JavaScript 開源社區的 Airbnb 公司,是對官方測試工具庫(react-addons-test-utils)的封裝。

這個單詞的倫敦讀音爲 ['enzaɪm],酵素或酶的意思,Airbnb 並沒有給它設計一個圖標,估計就是想取用它來分解 React 組件的意思吧。

它模擬了 jQuery 的 API,非常直觀並且易於使用和學習,提供了一些與衆不同的接口和幾個方法來減少測試的樣板代碼,方便判斷、操縱和遍歷 React Components 的輸出,並且減少了測試代碼和實現代碼之間的耦合。

一般使用 Enzyme 中的 mount 或 shallow 方法,將目標組件轉化爲一個 ReactWrapper對象,並在測試中調用其各種方法:

import Enzyme,{ mount } from 'enzyme';

...

describe('test ...', function() {

    it('should ...', function() {
        wrapper = mount(
            <MyComp isDisabled={true} />
        );
        expect( wrapper.find('input').exists() ).toBeTruthy();
    });
});

sinon

圖中這位“我牽着馬”的並不是捲簾大將沙悟淨...其實圖中的故事正是人所皆知的“特洛伊木馬”;大概意思就是希臘人圍困了特洛伊人十多年,久攻不下,心生一計,把營盤都撤了,只留下一個巨大的木馬(裏面裝着士兵),以及這位被扒光還被打得夠嗆的人,也就是此處要談的主角sinon,由他欺騙特洛伊人 --- 後面的劇情大家就都熟悉了。

所以這個命名的測試工具呢,也正是各種僞裝滲透方法的合集,爲單元測試提供了獨立而豐富的 spy, stub 和 mock 方法,兼容各種測試框架。

雖然 Jest 本身也有一些實現 spy 等的手段,但 sinon 使用起來更加方便。

III. 用測試驅動 React 組件重構

這裏不展開討論經典的 “測試驅動開發”(TDD - test driven development) 理論 -- 簡單的說,把測試正向加諸開發,先寫用例再逐步實現,就是TDD,這是很好理解的。

而當我們反過頭來,對既有代碼補充測試用例,使其測試覆蓋率不斷提高,並在此過程中改善原有設計,修復潛在問題,同時又保證原有接口不收影響,這種 TDD 行爲雖然沒人稱之爲“測試驅動重構”(test driven refactoring),但“重構”這個概念本身就包含了用測試保駕護航的意思,是必不可少的題中之意。

對於一些組件和共有函數等,完善的測試也是一種最好的使用說明書。

失敗-編碼-通過 三部曲

由於測試結果中,成功的用例會用綠色表示,而失敗的部分會顯示爲紅色,所以單元測試也常常被稱爲 “Red/Green Testing” 或 “Red/Green Refactoring” , 這也是 TDD 中的一般性步驟:

  1. 添加一個測試
  2. 運行所有測試,看看新加的這個是不是失敗了;如果能成功則重複步驟1
  3. 根據失敗報錯,有針對性的編寫或改寫代碼;這一步的唯一目的就是通過測試,先不必糾結細節
  4. 再次運行測試;如果能成功則跳到步驟5,否則重複步驟3
  5. 重構已經通過測試的代碼,使其更可讀、更易維護,且不影響通過測試
  6. 重複步驟1

如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處之後分享給大家。

解讀測試覆蓋率

這就是 jest 內置的 istanbul 輸出的覆蓋率結果。

之所以叫做“伊斯坦布爾”,是因爲土耳其地毯世界聞名,而地毯是用來"覆蓋"的‍♀️。

表格中的第2列至第5列,分別對應四個衡量維度:

  • 語句覆蓋率(statement coverage):是否每個語句都執行了
  • 分支覆蓋率(branch coverage):是否每個if代碼塊都執行了
  • 函數覆蓋率(function coverage):是否每個函數都調用了
  • 行覆蓋率(line coverage):是否每一行都執行了

測試結果根據覆蓋率被分爲“綠色、黃色、紅色”三種,應該視具體情況儘量提高相應模塊的測試覆蓋率。

優化依賴 讓 React 組件變得 testable

合理編寫組件化的 React,並將足夠獨立、功能專一的組件作爲測試的單元,將使得單元測試變得容易;

反之,測試的過程讓我們更易釐清關係,將原本的組件重構或分解成更合理的結構。分離出的子組件往往也更容易寫成stateless的無狀態組件,使得性能和關注點更加優化。

明確指定 PropTypes

對於一些之前定義並不清晰的組件,可以統一引入 prop-types,明確組件可接收的props;一方面可以在開發/編譯過程中隨時發現錯誤,另外也可以在團隊中其他成員引用組件時形成一個明晰的列表。

IV. React 單元測試常見案例

用例的預處理或後處理

可以用beforeEachafterEach做一些統一的預置和善後工作,在每個用例的之前和之後都會自動調用:

describe('test components/Comp', function() {

    let wrapper;
    let spy;

    beforeEach(function() {
        jest.useFakeTimers();

        spy = sinon.spy(Comp.prototype, 'componentDidMount');
    });
    afterEach(function() {
        jest.useRealTimers();

        wrapper && wrapper.unmount();

        didMountSpy.restore();
        didMountSpy = null;
    });

    it('應該正確顯示基本結構', function() {
        wrapper = mount(
            <Comp ... />
        );

        expect(wrapper.find('a').text()).toEqual('HELLO!');
    });

    ...

});

調用組件的“私有”方法

對於一些組件中,如果希望在測試階段調用到其一些內部方法,又不想對原組件改動過大的,可以用instance()取得組件類實例:

it('應該正確獲取組件類實例', function() {
    var wrapper = mount(
        <MultiSelect
            name="HELLOKITTY"
            placeholder="select sth..." />
    );

    var wi = wrapper.instance();

    expect( wi.props.name ).toEqual( "HELLOKITTY" );
    expect( wi.state.open ).toEqual( false );
});

異步操作的測試

作爲UI組件,React組件中一些操作需要延時進行,諸如onscrolloninput這類高頻觸發動作,需要做函數防抖或節流,比如常用的 lodash 的 debounce 等。

所謂的異步操作,在不考慮和 ajax 整合的集成測試的情況下,一般都是指此類操作,只用 setTimeout 是不行的,需要搭配 done 函數使用:

//組件中

const Comp = (props)=>(
    <input type="text" id="searchIpt"               onChange={ debounce(props.onSearch, 500) } />
);

//單元測試中

it('應該在輸入時觸發回調', function(done) {
    var spy = jest.fn();

    var wrapper = mount(
        <Comp onChange={ spy } />
    );

    wrapper.find('#searchIpt').simulate('change');

    setTimeout(()=>{
        expect( spy ).toHaveBeenCalledTimes( 1 );
        done();
    }, 550);
});

一些全局和單例的模擬

一些模塊中可能耦合了對 window.xxx 這類全局對象的引用,而完全去實例化這個對象可能又牽扯出很多其他的問題,難以進行;此時可以見招拆招,只模擬一個最小化的全局對象,保證測試的進行:

//fakeAppFacade.js

var facade = {
    router: {
        current: function() {
            return {name:null, params:null};
        }
    },  appData: {
        symbol: "&yen;"
    }
};

window._appFacade = facade;
module.exports = facade;

//測試套件中

import fakeFak from '../fakeAppFacade';

另外比如 LocalStroage 這類對象,測試端環境中沒有原生支持,也可以簡單模擬一下:

//fakeStorage.js

var _util = {};
var fakeStorage = {
    "set": function(k, v) {
        _util['_fakeSave_'+k] = v;
    },
    "get": function(k) {
        return _util['_fakeSave_'+k] || null;
    },
    "remove": function(k) {
        delete _util['_fakeSave_'+k];
    },
    "has": function(k) {
        return _util.hasOwnProperty('_fakeSave_'+k);
    }
};
module.exports = fakeStorage;

棘手的 react-bootstrap/modal

在一個項目中用到了 react-bootstrap 界面庫,測試一個組件時,由於包含了其 Modal 模態彈窗,而彈窗組件是默認渲染到 document 中的,導致難以用普通的 find 方法等獲取

解決的辦法是模擬一個渲染到容器組件原處的普通組件:

//FakeReactBootstrapModal.js

import React, {Component} from 'react';

class FakeReactBootstrapModal extends Component {
    constructor(props) {
        super(props);
    }
    render() { //原生的 react-bootstrap/Modal 無法被 enzyme 測試
        const {
            show,
            bgSize,
            dialogClassName,
            children
        } = this.props;
        return show
            ? <div className={
                `fakeModal ${bgSize} ${dialogClassName}`
            }>{children}</div>
            : null;
    }
}

export default FakeReactBootstrapModal;

同時在組件渲染時,加入判斷邏輯,使之可以支持自定義的類代替 Modal 類:

//ModalComp.js

import { Modal } from 'react-bootstrap';

...

render() {
    const MyModal = this._modalClass || Modal;

    return (<MyModal 
        bsSize={props.mode>1 ? "large" : "middle"}      dialogClassName="custom-modal">

        ...

        </MyModal>;
}

而測試套件中,實現一個測試專用的子類:

//myModal.spec.js

import ModalComp from 'components/ModalComp';

class TestModalComp extends ModalComp {
    constructor(props) {
        super(props);
        this._modalClass = FakeReactBootstrapModal;
    }
}

這樣測試即可順利進行,跳過了並不重要的 UI 效果,而各種邏輯都能被覆蓋了

模擬fetch請求

如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處之後分享給大家。

在單元測試的過程中,難免碰到一些需要遠程請求數據的情況,比如組件獲取初始化數據、提交變化數據等。

要注意這種測試的目的還是考察組件本身的表現,而非重點關心實際遠程數據的集成測試,所以我們無需真實的請求,可以簡單的模擬一些請求的場景。

sinon 中有一些模擬 XMLHttpRequest 請求的方法, jest 也有一些第三方的庫解決 fetch 的測試;

在我們的項目中,根據實際的用法,自己實現一個類來模擬請求的響應:

//FakeFetch.js

import { noop } from 'lodash';

const fakeFetch = (jsonResult, isSuccess=true, callback=noop)=>{

    const blob = new Blob(
        [JSON.stringify(jsonResult)],
        {type : 'application/json'}
    );

    return (...args)=>{
        console.log('FAKE FETCH', args);

        callback.call(null, args);

        return isSuccess
            ? Promise.resolve(
                new Response(
                    blob,
                    {status:200, statusText:"OK"}
                )
            )
            : Promise.reject(
                new Response(
                    blob,
                    {status:400, statusText:"Bad Request"}
                )
            )

    }
};
export default fakeFetch;


//Comp.spec.js

import fakeFetch from '../FakeFetch';

const _fc = window.fetch; //緩存“真實的”fetch

describe('test components/Comp', function() {

    let wrapper;

    afterEach(function() {
        wrapper && wrapper.unmount();
        window.fetch = _fc; //恢復
    });

    it("應該在遠程請求時響應onRemoteData", (done)=>{

        window.fetch = fakeFetch({
            brand: "GoBelieve",
            tree: {
              node: '總部',
              children: null
            }
        });

        let spy = jest.fn();

        wrapper = mount(
            <Comp onRemoteData={ spy } />
        );

        jest.useRealTimers();

        _clickTrigger(); //此時應該發起請求

        setTimeout(()=>{
            expect(wrapper.html()).toMatch(/總部/);
            expect(spy).toHaveBeenCalledTimes(1);
            done();
        }, 500);

    });

}); 

V. 總結

單元測試作爲一種經典的開發和重構手段,在軟件開發領域被廣泛認可和採用;前端領域也逐漸積累起了豐富的測試框架和方法。

單元測試可以爲我們的開發和維護提供基礎保障,使我們在思路清晰、心中有底的情況下完成對代碼的搭建和重構;

需要注意的是,世上沒有包治百病的良藥,單元測試也絕不是萬金油,秉持謹慎認真負責的態度才能從根本上保證我們工作的進行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章