Jest & enzyme 進行react單元測試

LucasTwilight發表於2019-01-18

下面的文章會預設讀者瞭解 React及其技術棧以及基本的前端單元測試,由於本文涉及到的技術較多,故不宜一一在文中介紹,諒解。

寫在前面

在撰寫單元測試用例之前,我們需要了解到撰寫測試用例的原因。寫測試用例的目的在於保證程式碼的迭代安全,並不是為了100%的coverage或者是case pass,coverage和case僅僅是為了實現程式碼安全的因素。

單元測試(Unit Test):前端單元測試,在以前也許是一個比較陌生的工作,但是前端在經歷了這幾年的發展之後,我們對於程式碼的魯棒性要求逐漸提升,承載了更多的業務邏輯的同時,作為整個鏈路上最接近使用者的部分,系統崩潰阻塞的成本非常之高。如果你採用的是SSR,那麼直接在服務端渲染報錯則是更為致命的。

前端的單元測試能夠在一定程度上保證:

  • 在迭代過程中保證每次提交的程式碼的質量;
  • 在程式碼的重構過程中,原始功能的完整性;
  • 每次程式碼迭代的副作用可控;

相對於後端程式碼來說,前端程式碼更多地會涉及到DOM相關的內容,對於非結構化的內容如何進行測試呢?

airbnb提供了一個比較合適的React單元測試解決方案,結合Jest以及husky,可以保證每次commit的程式碼都符合規範,並且coverage內的程式碼功能完整。

UT之於library

庫對於單元測試的要求是非常高的。因為一個lib可能被多個業務線以及工程所引入,一旦這個lib出現了任何問題,影響到的範圍是非常大的。我們又不可能要求QA對於多個業務線進行迴歸(怕是他們要殺了我們祭天吧)。

為了保證lib的迭代不會影響到原有的業務功能,單元測試是一個非常好的方法。由於我們主要的技術棧還是基於React的各種解決方案,所以有比較多的業務元件以及公共元件,這些元件被多個業務線使用。lerna架構的元件工程在每次commit的時候都會跑UT,來進行功能迴歸。

UT之於業務

業務程式碼一般對於單元測試的需求並不如lib那樣高,但是在某些核心業務邏輯中接入UT,也是可以保證程式碼整體的質量的。最起碼可以保證業務程式碼在正常的渲染過程中不發生報錯。

框架

前面簡單描述了一下單元測試對於前端程式碼的重要性,很多人說現在的前端圈子和娛樂圈一樣,確實,目前可選的測試框架林林總總有很多,經歷了jasmine、mocha,現在來到了Jest。

TL;DR

9102年了,Jest可以說是目前前端最好的測試框架了。可以進行快速配置,和enzyme很好地結合,能夠保證在React技術棧中,快速跑起來一個測試用例。

但是,最吸引人的還是其內建的coverage報告,可以快速生成程式碼覆蓋率。

相比於測試框架,React的測試庫似乎沒有什麼其他的選擇了,enzyme基本可以滿足任何前端的測試需求。但是對於非同步強互動的頁面來說,撰寫測試用例的學習成本還是比較高的。

技術棧

最終我們為了各種場景下React的單元測試,整合了下面的lib:

  • Jest:單元測試框架
  • enzyme: React測試庫
  • Nock: 非同步請求模擬
  • Async-wait-until: 非同步操作結束通知
  • Husky: pre-commit階段執行單元測試

配置

Jest

Jest本身就以配置簡單著稱,而enzyme更是可以即插即用的測試庫。所以配置過程要比較輕鬆。

module.exports = {
    // 單元測試環境根目錄
    rootDir: path.resolve(__dirname),
    // 指定需要進行單元測試的檔案匹配規則
    testMatch: [
        '<rootDir>/test/**/__test__/*.js'
    ],
    // 需要忽略的檔案匹配規則
    testPathIgnorePatterns: [
        '/node/modules'
    ],
    testURL: 'http://localhost/',
    // 是否收集測試覆蓋率,以及覆蓋率檔案路徑
    collectCoverage: true,
    coverageDirectory: './coverage'
};
複製程式碼

上面是幾個比較重要的配置項。其中大部分都是比較好理解的,而testURL這個配置項需要說明一下,這個規則表示當前測試用例所執行的URL,雖然測試的時候我們看不到完整的頁面,但是測試用例本身是掛載到一個頁面中的,而這個頁面的URL就是通過testURL指定的。

在這個Jest配置下,所有的測試用例中,如果執行location.href都會拿到http://localhost/這個URL的,這個配置項在進行需要網路請求的case中是很關鍵的。

在執行的時候,可以指定Jest的配置檔案路徑:

~ jest --config ./scripts/jest.config.js
複製程式碼

如果沒有指定檔案路徑的話,預設則是取當前檔案路徑的配置檔案。

enzyme

enzyme本身是不需要配置的,作為一個即插即用的React測試庫,也算是讓我們前端脫離了配置工程師的苦海。

但是基於React進行開發,則需要安裝對應的React Adapter,比如如果你需要使用static getDerivedStateFromProps方法,那麼就需要引入enzyme-adapter-react-16的庫來保證enzyme渲染的版本和你使用的版本是一致的。

Jest在進行UT的過程中,會首先檢查工程是否有配置.babelrc檔案,如果配置了,則會自動根據這個檔案來進行babel編輯,然後執行測試用例。

一個隨手搭建的演示環境的依賴:

  "dependencies": {
    "react": "^16.7.0",
    "react-dom": "^16.7.0"
  },
  "devDependencies": {
    "babel-plugin-transform-async-to-generator": "^6.24.1",
    "babel-plugin-transform-class-properties": "^6.24.1",
    "babel-preset-env": "^1.7.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1",
    "babel-preset-stage-3": "^6.24.1",
    "enzyme-adapter-react-16": "^1.7.1",
    "enzyme": "^3.8.0",
    "jest": "^23.6.0"
  },
  "scripts": {
    "test": "jest --config ./jest.config.js"
  }
複製程式碼
// ./__test__/index.js
import Test from '../src';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
    adapter: new Adapter()
});
複製程式碼

而enzyme的adapter是需要進行初始化的,通過Enzyme.configure指定需要引入的adapter例項。

這樣就完成了一個Enzyme + React + Jest的環境。

撰寫一個簡單的測試用例

斷言

目前,各種測試框架的斷言已經開始收斂,Jest採用的斷言語法和我們之前使用的mocha語法類似。

一個test suite可以用describe來描述,一個test suite可以包含多個case,來測試各種場景下的元件渲染結果。

我們先給出一個非常簡單的React元件:

import React from 'react';

export default class Text extends React.Component {
    render() {
        return (<div className="test-container" />)
    };
}
複製程式碼

對於這個元件,我們需要判斷是否成功渲染出來了div元素,並且元素的類名是test-container

這是一個極簡版本的case:

describe('test suite: Test component', () => {
    it('case: expect Test render a div with className: test-container', () => {
        const wrapper = shallow(<Test />);

        expect(wrapper.find('.test-container').length).toEqual(1);
    });
});
複製程式碼

執行npm run test,可以得到下面的結果:

測試1

可以看到suites和cases的通過情況,以及各種覆蓋率結果。其實前端單元測試也可以這麼簡單的。

關於enzyme的三個核心渲染方法,mount、render以及shallow,網上有很多文章介紹三者之間的區別,這裡就不班門弄斧了。mount應該是我寫測試用例最常用的方法吧,畢竟大部分元件的邏輯都需要真實掛載出來,才能夠進行用例測試。

測試用例也可以很複雜

最近有一個比較複雜的元件,需要接入單元測試,當時在開發的時候太天真,現在想起來真的是追悔莫及。元件內部包含:fetch請求、時間獲取、history操作,並且含有非常多的人機互動邏輯

這樣的元件現在想起來是非常不規範的,但是為了保證以後修改的時候,業務邏輯的魯棒,也不得不強行為其新增單元測試。

下面有很多case,大部分case都是在實際coding過程中遇到的,希望能夠幫助到有同樣需求的人。

history和Date.now()

在業務程式碼中,很多時候我們都需要進行頁面的跳轉,或者hash的修改。所有對於location的操作都會落在window.location的物件上。

enzyme實際上為我們構建了一個虛擬的DOM環境,我們可以拿到對應的DOM元素以及windowdocument物件來進行DOM操作。

Date也是類似的,也是一個全域性的物件,以前我們通過整合js-dom來進行模擬,而現在enzyme和Jest為我們做好了這些工作。

看下面這個元件:

class Time extends React.Component {
    static propTypes = {
        time: PropTypes.number
    };

    constructor(props) {
        super(props);
        this.state = {
            before: Date.now() < props.time
        }
    }
    
    render() {
        const { before } = this.state;
        const { time } = this.props;
        
        if (before) {
            return (
                <div className="before">
                    {`now is before time: ${time}`}
                </div>
            );
        } else {
            return (
                <div className="after">
                    {`now is after time: ${time}`}
                </div>
            );
        }
    }
}

複製程式碼

在撰寫單元測試的時候,我們會發現,由於當前時間的不一致,所以作為props傳入的時間在和Date.now()進行比較,得到的結果是不一致的,這樣會導致測試用例的結果不可控。

為了保證Date.now()得到的值是一致的,我們需要改寫DOM上的Date物件。

describe('test suite: Time component', () => {
    const NOW_TO_CACHE = global.Date.now;
    const NOW_TO_USE = jest.fn(() => 1547717952668);

    beforeEach(() => {
        global.Date.now = NOW_TO_USE;
    });
    afterEach(() => {
        global.Date.now = NOW_TO_CACHE;
    });
    it('case: now is less than props\' time', () => {
        const wrapper = shallow(<Time time={1547717952669} />);

        console.log(Date.now())

        expect(wrapper.find('.before').length).toEqual(1);
    });

    it('case: now is greater than props\' time', () => {
        const wrapper = shallow(<Time time={1547717952667} />);

        console.log(Date.now())

        expect(wrapper.find('.after').length).toEqual(1);
    })
});
複製程式碼

beforeEachafterEach兩個hook在每一個case執行之前或者之後,會分別執行,在每個case之前,進行global.Date.now的改寫,然後在case結束之後,將global.Date.now恢復為原本的方法。

jest.fn會生成一個Mock函式,這個函式和其他函式不一樣的地方在於,這個函式會記錄到其被執行的一些資訊,比如:

  • 函式被執行的次數
  • 函式每次被執行時的引數
  • 甚至是函式每次被呼叫時的this指向

Date.now

可以看到,對於所有的Date.now()方法,得到的當前時間都被複寫成了一個確定的數字,這樣就可以保證你的測試用例的時間無關性。

對於historyDate.now這類掛載到window或者document上面的例項物件,我們都可以通過jest.fn來複寫其方法,保證這些方法被呼叫的順序以及呼叫結果的正確性,我們也可以在jest.fn內部進行斷言,從而判斷每次執行的過程中是否發生錯誤。

fetch請求

前端作為View,部分場景下比較依賴後端提供的Model來進行渲染,API的正確性很多時候會直接影響到整個頁面的渲染結果是否正確。

並且部分場景中,某些程式碼也許是在Promiseresolve了之後才會被呼叫。

所以我們需要模擬fetch請求,來保證在請求回撥中的程式碼被單元測試覆蓋到。

這裡就需要用到:

Nock:HTTP server mocking and expectations library for Node.js

Async-wait-until:Wait while predicate completes and resolve a Promise

這兩個庫了。

首先,看下面這個元件:

import React from 'react';
import fetch from 'isomorphic-fetch';

export default class AsyncComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            user: {}
        }
    }

    componentDidMount() {
        this.fetchUser()
            .then(res => {
                this.setState({user: res});
            });
    }

    fetchUser = () => {
        return fetch(`${location.origin}/api/user/get`, {
            method: 'GET'
        }).then(ret => {
            return ret.json();
        }).catch(err => {
            console.error(err);
        });
    }

    render() {
        const { user } = this.state;
        return (
            <div className="user-profile">
                <p className="name">{user.name}</p>
                <p className="age">{user.age}</p>
            </div>
        );
    }
}
複製程式碼

元件內部在componentDidMount階段進行了一次fetch請求,來在客戶端渲染的時候獲取資料,填充到頁面中。

同步的測試工作非常簡單,根據前面的幾個例子,相信你可以對於渲染進行很好地測試了。

Q & A:

Q:其一:如何測試網路請求的回撥呢?

我們不可能直接將UT的請求直接打到後臺的介面裡,這樣在沒有網路的環境下,UT是通過不了的。所以必須要在本地模擬到近似於真實的網路請求。

A:Nock

Q: 其二:網路請求時非同步的,如果撰寫非同步的測試用例呢?

元件View的更新是在非同步的請求resolve之後進行的,而測試用例的執行是同步的,這樣就會出現時序問題,所以我們需要將斷言和元件的fetch同步執行。

A: async-wait-until

這就是我們引入這兩個庫的原因了。具體如何結合這兩個庫來進行非同步渲染的單元測試,看下面這個test suite。

import Async from '../src/async';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import nock from 'nock';
import waitUntil from 'async-wait-until';

Enzyme.configure({
    adapter: new Adapter()
});

describe('test suite: Async component', () => {
    beforeAll(() => {
        nock('http://localhost/api/user')
            .get('/get')
            .reply(200, {
                "name": "lucas",
                "age": 20
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect component did mount will trigger re-render', async () => {
        const wrapper = mount(<Async />);

        await waitUntil(() => wrapper.state('user').name === 'lucas');

        expect(wrapper.find('.name').text()).toBe('lucas');
        expect(wrapper.find('.age').text()).toBe('20');
    });
});
複製程式碼

上面的這個測試用例的核心在於模擬fetch請求,並且等在請求結束再執行對應的斷言。

首先,我們為這個test suite增加了兩個hook,beforeAll會在這個suite的所有case執行之前執行一次,而afterAll則會在所有的case全部執行完之後,執行一次。

beforeAll中,我們通過nock模擬了元件中fetch請求的請求結果,給到了一個resolve的響應。

當React執行到componentDidMount的時候,會進行fetch請求,這個請求會被打到nock中。這裡注意到,我們fetch的URL是http://localhost/api/user/get,這就是之前提到的,Jest配置項中設定testURL的作用。testURL指定的URL會作為測試頁面的location.origin

由於fetch是一個非同步的過程,我們需要等待fetch被resolve之後,才能夠進行斷言。

所以,這裡用到了waitUntil,這個函式接受一個函式作為引數,這個函式會返回一個bool值,當bool值為true的時候,表示非同步呼叫結束,可以開始執行後面的邏輯了,當然,我們也可以封裝一個自己的waitUntil,其本質就是封裝一個Promise。

結束了這一個suite之後,程式碼邏輯會走到afterAll的hook中。這裡面呼叫了nock.cleanAll(),用於對之前mock的介面進行清理,也就是規範這個mock的作用域僅僅位於當前的suite中。

這時,我們再跑一次npm run test,可以得到下面的測試結果:

test async

結合上面的test suite,在單元測試中成功進行了fetch,並且渲染出了正確的結果。

但是細心的小夥伴可能會發現,coverage報告中有一行程式碼沒有被這個test suite覆蓋到,這行程式碼可以定位到fetch的reject中,因為我們僅僅測試了fetch resolve的情況。

為了測試reject的情況,我們需要一個新的suite,在這個suite中,我們mock一個reject響應的介面:

describe('test suite: Async component', () => {
    let resolve = false;
    beforeAll(() => {
        nock('http://localhost/api/user')
            .get('/get')
            .reply(400, () => {
                resolve = true;
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect component fetch error will not block rendering', async () => {
        const wrapper = mount(<Async />);

        await waitUntil(() => resolve);

        expect(wrapper.find('.name').text()).toBe('');
        expect(wrapper.find('.age').text()).toBe('');
    });
});
複製程式碼

由於請求是非同步的,並且與resolve的情況不同,我們不知道何時請求會被reject,所以我們需要給nock傳入一個回撥,來標識fetch結束,請求被reject。

這樣就可以測試到reject情況下頁面是否成功渲染了,保證了各種condition下,頁面或者元件的穩定。

async error test

互動模擬

作為鏈路中toC的部分,前端程式碼中有許多地方是需要進行人機互動的。在互動過程中,javascript主要以註冊事件的方式進行互動響應。

人機互動不僅僅是非同步的,並且還包含事件的觸發以及回撥。這部分測試,enzyme提供了很多有意思的API,來幫助我們完成人機互動過程的單元測試。

考慮下面的這個元件:

import React from 'react';
import fetch from 'isomorphic-fetch';

export default class Text extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            value: ''
        };
    }

    onInputChanged = (e) => {
        this.setState({
            value: e.target.value
        });
    }

    onClicked = () => {
        const { value } = this.state;
        this.postValue(value)
            .then(res => {
                this.setState({
                    value: ''
                });
            });
    }

    postValue = (value) => {
        return fetch(`${location.origin}/api/value`, {
            method: 'POST',
            body: JSON.stringify({value}),
        }).then(ret => {
            return ret.json();
        });
    }

    render() {
        const { value } = this.state;
        return (
            <div className="form">
                <input value={value} onChange={this.onInputChanged} />
                <button className="submit" onClick={this.onClicked}>提交</button>
            </div>
        )
    }
}
複製程式碼

這是一個常見的React輸入框,我們將輸入框的value繫結到state上面。期望能夠通過使用者輸入來改變元件狀態,在使用者點選提交的時候,可以從頁面中取到這個值,並且POST到服務端,在得到了正確的回撥之後,清空掉輸入框中的內容。

這種需求比較普遍,現在需要為這樣一個需求新增一組單元測試,保證這個元件能夠穩定執行。

考慮到幾個重點:

  1. 觸發輸入框onchange事件
  2. 等待輸入框輸入事件結束
  3. 觸發按鈕點選事件
  4. 進行fetch
  5. 等待fetch結束
  6. 回撥中清理input內容

enzyme提供了一些觸發事件的方法。當我們使用mount將一個元件掛載到虛擬DOM上的時候,可以通過wrapper.simulate()方法來觸發各種DOM事件。

首先,先測試元件是否正確完成渲染:

it('case: expect input & click operation correct', async () => {
    const wrapper = mount(<Interaction />);

    const input = wrapper.find('input').at(0);
    const button = wrapper.find('button').at(0);

    expect(input.exists());
    expect(button.exists());
});
複製程式碼

然後需要觸發input的onchange事件,來改變當前的state:

input.simulate('change', {
    target: {
        value: 'lucas'
    }
});

expect(wrapper.state('value')).toBe('lucas');
複製程式碼

接著,觸發按鈕的點選事件,進行fetch請求,然後在響應返回之後,清理掉state中的內容。

button.simulate('click');
複製程式碼

這樣就完成了整個元件的操作流程的UT了,執行這個單元測試,可以發現我們的測試已經完全覆蓋了所有程式碼的所有分支了。

下面是完成的test suite:

import Interaction from '../src/interaction';
import Enzyme, { shallow, render, mount } from 'enzyme';
import React from 'react';
import Adapter from 'enzyme-adapter-react-16';
import nock from 'nock';
import waitUntil from 'async-wait-until';

Enzyme.configure({
    adapter: new Adapter()
});

describe('test suite: Async component', () => {
    let resolve = false;
    beforeAll(() => {
        nock('http://localhost/api')
            .post('/value')
            .reply(200, () => {
                resolve = true;
                return {};
            });
    });

    afterAll(() => {
        nock.cleanAll();
    });

    it('case: expect input & click operation correct', async () => {
        const wrapper = mount(<Interaction />);

        const input = wrapper.find('input').at(0);
        const button = wrapper.find('button').at(0);

        expect(input.exists());
        expect(button.exists());

        input.simulate('change', {
            target: {
                value: 'lucas'
            }
        });

        expect(wrapper.state('value')).toBe('lucas');

        button.simulate('click');

        await waitUntil(() => resolve);

        expect(wrapper.state('value')).toBe('')
    });
});
複製程式碼

整個測試用例完全pass,並且coverage為100%

interaction test

最後

洋洋灑灑又是一個大長篇,有很多博主會將enzyme、nock、jest這類庫分開來講,但是在實際使用過程中,這幾個庫卻是密不可分的。

單元測試是前端工程化的一個不可避免的階段性工作,無論是開源工作還是業務工作,保證在每次迭代過程中程式碼的安全性於人於己都有很大的好處。

最後還是要說,撰寫測試用例的時候,一定要切記,單元測試並不是堆砌覆蓋率,而是保證每一個功能細節都被覆蓋到,不要捨本逐末了。

相關文章