React單元測試,就是把React 組件渲染出來,看看渲染出來的內容符不符合我們的預期。比如組件加載的時候有loading, 那就渲染組件,看看渲染出的內容中有沒有loading. 再比如,ajax請求完成后,組件要顯示返回的數據, 那就渲染組件, 等待請求完成,然后看看渲染出來內容是不是請求返回的數據。那怎么渲染?怎么查看渲染出來內容呢?因為我們是在命令行中跑測試,而不是在瀏覽器中進行測試?渲染的話,目前有兩個測試庫enzyme和@testing-library/react, 提供了渲染方法。查看內容,則是Jest內置了jsdom, jsdom提供了DOM的無頭實現,也就是說在命令行中跑測試,在測試中仍然可以獲取到document, document.body 等DOM 元素,也就可以使用documet.getElementId() 等DOM 方法來查出內容,也可以click 來測試瀏覽器的交互形為。jsdom也稱為Jest瀏覽器環境。
enzyme和@testing-library/react,怎么選擇呢?現在傾向於@testing-library/react, -是因為它對react hooks支持比較好,二是,它的測試更符合用戶形為,渲染組件,查找元素,和用戶使用瀏覽器沒有什么區別。@testing-library/react也鼓勵我們,寫測試要把注意力放到用戶身上,測試要模擬真實用戶的形為,而不是測試組件的實現細節,這樣,測試完成后,對組件更有信心。什么是測試實現細節呢?就是測試組件狀態是不是對的,直接調用組件中的方法。如果你了解Enzyme的話,它就提供了wrapper.state方法,可以直接獲取到組件的狀態,wapper.instance可以直接調用組件的方法。為什么不測試組件的實現細節?測試組件的實現細節有什么不好嗎?有兩個不好的地方,一是使用組件時,誰管你內部是怎么實現的,用戶只管好不好用,有沒有達到預期效果,測試實現細節顯得沒有什么意義。二是維護成本太高,今天組件的狀態叫curValue, 明天可能叫currentValue, 這樣測試就要改來改去,但這樣的修改對組件來說,功能沒有受到任何的影響,按理說,測試是不需要改的。@testing-library/react並沒有提供測試實現細節的功能,只提供了getByText()等測試dom的功能。
自己配置一個測試環境,稍微有點麻煩,幸好create-react-app內置了@test-library/react,使用它創建項目,可以直接寫測試。使用create-react-app 創建項目后,就可以看一下@test-library/react庫了。React單元測試,就是渲染,查找元素,進行判斷,是不是符合預期,也就是斷言。React test libaray 提供了render()方法進行渲染,它接受一個React element, 然后把它渲染成DOM, 插入到body元素上。提供了*Text()等方法來查找元素,jest-dom提供了斷言。寫一個簡單的例子,組件加載的時候顯示loading, 然后請求數據, 展示數據。App.js修改如下
import React from 'react'; export default class App extends React.Component { state = { todo: null} componentDidMount() { fetch('https://jsonplaceholder.typicode.com/todos/1') .then(res => res.json()) .then(todo => this.setState({todo})) .catch(e => console.log(e)); } render() { return ( <React.Fragment> { this.state.todo ? <p className="title">title: {this.state.todo.title}</p> : <p className="spinner">loading</p> } </React.Fragment> ) } }
測試的第一種情況是組件有沒有顯示 loading, 這和用戶的形為是一致的。瀏覽器先渲染loading, 再渲染todo的title。用戶先看到的是loading, 再看到的是title。按照測試步驟,先渲染組件,再查找loading ,最后斷言其存在,如果測試通過,就表示組件功能沒有問題。渲染直接調用render()方法,查找則要用@testing-library/react提供的screen對象,斷言就用toBeInTheDocument()。 App.test.js 修改如下
import React from 'react'; import { render, screen } from '@testing-library/react'; import App from './App'; describe('app test', () => { test('render a loading when component shows', () => { render(<App></App>); const hello = screen.getByText(/loading/); expect(hello).toBeInTheDocument(); }); });
自己配置一個Jest環境,稍微有點麻煩,mkdir react-test && cd react-test && npm init -y 新建項目react-test,npm install jest @testing-library/react --save-dev, 還要npm install react react-dom 安裝react react-dom,由於node並不支持jsx,還要安裝babel, @babel/core, @babel/preset-env, @babel/preset-react, 並配置.babelrc
{ "presets": ["@babel/preset-env", "@babel/preset-react"] }
Jest 27版本,test environment默認值是node,而不是jest dom, 所以還要配置test environment,jest.config.js
module.exports = { testEnvironment: 'jsdom' }
寫一個Hello.js
import React from "react" export function Hello(){ return <div>Hello</div> }
新建Hello.test.js測試一下
import React from 'react'; import { render, screen } from '@testing-library/react'; import { Hello } from './Hello'; test('Hello', () => { render(<Hello></Hello>); const hello = screen.getByText(/Hello/); expect(hello).toBeTruthy(); })
npx jest, 測試成功,expect(hello).toBeTruthy()表明Hello 存在,但是語義不太清晰,如果toBeInTheDocument() 就好了,那要安裝@testing-library/jest-dom, npm install --save-dev @testing-library/jest-dom ,然后在測試中import '@testing-library/jest-dom',由於在每一個測試中都要寫這個配置比較麻煩,Jest 有一個配置項setupFilesAfterEnv,是一個路徑數組,如果把某個文件所在的路徑放到這個數組中,那么在跑測試之前Jest都會先運行這些文件中內容,路徑數組中的各個文件相當於對Jest 進行了初始配置。在跑每個測試之前,都要配置jest-dom, 所以把jest-dom的配置文件放到setupFilesAfterEnv 中,那就要配置setupFilesAfterEnv。jest.config.js
module.exports = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['<rootDir>/jest-setup.js'] }
再在項目根目錄下建jest-setup.js
import '@testing-library/jest-dom'
把Hello.test.js中import jest-dom去掉,npx jest, 測試成功。
npx jest, 報錯了,ReferenceError: fetch is not defined,為什么呢?因為跑單元測試,實際上是用node.js來執行單元測試的代碼(JS代碼)。在代碼中,render()渲染組件,調用了組件的componentDidMount() 方法,在componentDidMount() 方法中,調用了fetch去請求數據,node.js中並沒有fetch,所以報錯了。現在只能由我們自己提供fetch了。提供fecth,直接給window對象添加fetch屬性,因為現在是jest-dom環境, 在測試中,並不是真正地去請求數據,要mock fetch. 由於剛開始請求,請求是一種promise的pedding狀態,所以直接返回一個promise 對象就可以了。
test('render a loading when component shows', () => { window.fetch = jest.fn(() => { return new Promise((resolve, reject) => { }); }) render(<App></App>); const hello = screen.getByText(/loading/); expect(hello).toBeInTheDocument(); });
看一下React組件的執行過程,state.todo是null,render 方法返回<p className="spinner">loading</p>, 然后調用componentDidMount() 方法,fetch去請求數據(異步的),而在測試中,渲染出組件以后,直接斷言了(同步),並沒有等待fetch請求回來,fetch 在請求的過程中,測試已結束,所以渲染出的wrapper 只包含<p className="spinner">loading</p>, 測試通過,可以console.log(wrapper.debug()) 看組件的渲染結果。這也引出了測試的第二種情況,等待fetch請求結束,看返回的數據有沒有正確渲染出來。有兩個問題需要解決,一個是fetch, 在測試中,不是真正地去請求數據,所以要對fetch 進行mock. fetch是window 對象的屬性,所以對fetch的mock, 就是讓window.fetch = jest.fn()。 jest.fn接受一個函數作為參數,函數的返回值就是mock函數的返回值。fetch的mock如下
window.fetch = jest.fn(() => { return Promise.resolve({ status: 200, json: () => { return Promise.resolve({"title": "delectus aut autem" }) } }) })
一個是等待, 等待fetch請求成功。等待用的是Jest測試的done參數,只要一個test測試中,參數有done, Jest 在測試的時候,就會等待這個done 的調用,如果done 不調用,Jest就會停在這個測試中。那么現在的問題變成了什么時候調用done(). fetch 返回的是promise, 所有注冊的回調函數都放到異步隊列中。異步隊列的執行是node 的事件循環機制,還是無法知道,所有的回調函數什么時候執行完,什么時候調用done()。 但我們可以注冊一個回調函數,只要保證fetch中注冊的回調函數都執行完了,再執行我們注冊的回調函數就可以了。這讓我想到了setTimeout(), 同一個事件循環中,promise中的回調函數會在setTimeOut中的回調函數之前執行,那就在setTimeout 中調用done 就可以了
test('should render todo.title when fetch successfully ', (done) => { window.fetch = jest.fn(() => { return Promise.resolve({ status: 200, json: () => { return Promise.resolve({"title": "delectus aut autem" }) } }) }) const wrapper = shallow(<App></App>); setTimeout(() => { expect(wrapper.find("p.title").text()).toContain("delectus"); expect(wrapper.find(".spinner").length).toBe(0); done(); }, 10); }); });
看一下執行順序,shallow(<App></App>) -> componentDidMount() 執行 -> fetch發送請求,由於fetch是異步的,所以 shallow(<App></App>); 這一行代碼算是執行完了,但由於mock, fetch立即resolve了,在執行下一行代碼代碼之前,fetch注冊的回調函數已到異步隊列中。再執行setTimeout, 告訴node, 10ms 之后注冊斷言的回調函數。順序執行完畢,開始執行隊列。執行res => res.json(),setState(), React 重新渲染,10ms 之后,斷言的回調函數注冊並執行,由於也執行了done() 測試結束,這時測試的就是fetch 返回數據之后的組件內容。注意,這里的setTimeout的延遲10s 只是舉例,真正起作用的是事件循環隊列的micro-task 和macro-task。promise是micro-task, setTimeout是micro-task。
兩種情況都通過測試,這個React組件就算測試完成了,因為它只有這兩種情況。再稍微延伸一下,有人使用fetch的時候喜歡return
componentDidMount() { return fetch('https://jsonplaceholder.typicode.com/todos/1') .then(res => res.json()) .then(todo => this.setState({todo})) .catch(e => console.log(e)); }
或有人喜歡使用async/await
async componentDidMount() { try { const res = await fetch('https://jsonplaceholder.typicode.com/todos/1'); const todo = await res.json(); this.setState({todo}) } catch (e) { console.log(e); } }
這時componentDidMount() 調用的時候,就會返回promise, 在測試的時候,給這個promise注冊回調函數,在回調函數里面時進行測試,可以保證fetch請求結束再進行斷言。這時,你就想手動調用componentDidMount(). 在shallow 渲染下是可以的,它接受第二個參數,是個對象,對shallow進行配置,disableLifecycleMethods: true, 表示渲染組件的時候不會調用生命周期函數。它返回的wrapper 有一個instance() 方法,返回react 實例,用它調用componentDidMount(), 測試內容修改如下, 還有一點要注意,在mock數據使用完成之后,最好把mock的函數進行還原。
test('should render todo.title when fetch successfully ', (done) => { window.fetch = jest.fn(() => { return Promise.resolve({ status: 200, json: () => { return Promise.resolve({"title": "delectus aut autem" }) } }) }) const wrapper = shallow(<App></App>, {disableLifecycleMethods: true}); let didMount = wrapper.instance().componentDidMount(); didMount.then(() => { expect(wrapper.find("p.title").text()).toContain("delectus"); expect(wrapper.find(".spinner").length).toBe(0); console.log("ues"); fetch.mockClear(); // mock 還原 done(); })
fetch 還有一種使用情況,對其進行封裝,新建一個request.js 文件,定義一個getData()
export function getData(url) { return fetch(url).then(res => res.json()) }
在App.js 中就要引入getData, 然后compentDidMount() 中使用它
componentDidMount() { return getData('https://jsonplaceholder.typicode.com/todos/1') .then(todo => this.setState({todo})) .catch(e => console.log(e)); }
現在要怎么測試呢?組件依賴了另外一個模塊,如果不想受這個模塊影響,那就mock 這個模塊。jest.mock() 一個模塊,這個模塊暴露出來的函數都變成了mock 函數,再從這個模塊中引入函數,引入的都是mock函數,mock函數就可以mock實現,返回值等。
jest.mock('./request.js'); import { getData } from './request';
這時,你會發現兩個測試都報錯了。第一個測試,shallow並沒有禁止調用生命周期函數,compentDidMount會調用getData(), getData() 返回的是jest.fn() 沒有then, 所以報錯了,第二個也是如此。第一個可以禁止生命周期函數的調用。
const wrapper = shallow(<App></App>, {disableLifecycleMethods: true});
第二個對getData mock 實現
測試通過。這時,兩個測試中都有const wrapper = shallow(<App></App>, {disableLifecycleMethods: true}); 可以進行抽取,因為在每一個測試之前都會shallow, 所以使用beforeEach()。mock的還原可以使用afterEach()
import React from 'react'; import { shallow } from 'enzyme'; import App from './App'; jest.mock('./request.js'); import { getData } from './request'; describe('app test', () => { let wrapper; beforeEach(() => { wrapper = shallow(<App></App>, {disableLifecycleMethods: true}); }); afterEach(() => { getData.mockClear(); // mock 還原 }); test('render a loading when component shows', () => { expect(wrapper.find('.spinner').exists()).toBeTruthy(); }); test('should render todo.title when fetch successfully ', (done) => { getData.mockResolvedValue({ "title": "delectus aut autem", }) let didMount = wrapper.instance().componentDidMount(); didMount.then(() => { expect(wrapper.find("p.title").text()).toContain("delectus"); expect(wrapper.find(".spinner").length).toBe(0); done(); }) }); });
這里要注意,每個test之間要相互獨立,不要使用共享數據,尤其是使用window 和document 對象的時候。當把變量提升到test 外面,放到describe中的時候,使用這個變量之前,一定要先進行賦值操作,可以使用beforeEach, 也可以在每一個test 的第一句,每一個test 都要使用它自己創建的變量。testEnvironment