用Jest來給React完成一次妙不可言的~單元測試
- 2020 年 3 月 30 日
- 筆記
引言
在2020的今天,構建一個 web 應用對於我們來說,並非什麼難事。因為有很多足夠多優秀的的前端框架(比如 React
,Vue
和 Angular
);以及一些易用且強大的UI庫(比如 Ant Design
)為我們保駕護航,極大地縮短了應用構建的周期。
但是,互聯網時代也急劇地改變了許多軟件設計,開發和發佈的方式。開發者面臨的問題是,需求越來越多,應用越來越複雜,時不時會有一種失控的的感覺,並在心中大喊一句:「我太南了!」。嚴重的時候甚至會出現我改了一行代碼,卻不清楚其影響範圍情況。這種時候,就需要測試的方式,來保障我們應用的質量和穩定性了。
接下來,讓我們學習下,如何給 React
應用寫單元測試吧?
需要什麼樣的測試
軟件測試是有級別的,下面是《Google軟件測試之道》一書中,對於測試認證級別的定義,摘錄如下:
•級別1
•使用測試覆蓋率工具。•使用持續集成。•測試分級為小型、中型、大型。•創建冒煙測試集合(主流程測試用例)。•標記哪些測試是非確定性的測試(測試結果不唯一)。
•級別2
•如果有測試運行結果為紅色(失敗❌)就不會發佈。•每次代碼提交之前都要求通過冒煙測試。(自測,簡單走下主流程)•各種類型的整體代碼覆蓋率要大於50%。•小型測試的覆蓋率要大於10%。
•級別3
•所有重要的代碼變更都要經過測試。•小型測試的覆蓋率大於50%。•新增重要功能都要通過集成測試的驗證。
•級別4
•在提交任何新代碼之前都會自動運行冒煙測試。•冒煙測試必須在30分鐘內運行完畢。•沒有不確定性的測試。•總體測試覆蓋率應該不小於40%。•小型測試的代碼覆蓋率應該不小於25%。•所有重要的功能都應該被集成測試驗證到。
•級別5
•對每一個重要的缺陷修復都要增加一個測試用例與之對應。•積極使用可用的代碼分析工具。•總體測試覆蓋率不低於60%。•小型測試代碼覆蓋率應該不小於40%。
小型測試,通常也叫單元測試,一般來說都是自動化實現的。用於驗證一個單獨的函數,組件,獨立功能模塊是否可以按照預期的方式運行。
而對於開發者來說,重要的是進行了測試的動作。本篇文章主要圍繞着React組件單元測試展開的,其目的是為了讓開發人員可以站在使用者的角度考慮問題。通過測試的手段,確保組件的每一個功能都可以正常的運行,關注質量,而不是讓用戶來幫你測試。
在編寫單元測試的時候,一定會對之前的代碼反覆進行調整,雖然過程比較痛苦,可組件的質量,也在一點一點的提高。
技術棧選擇
當我們想要為 React
應用編寫單元測試的時候,官方推薦是使用 React Testing Library[1] + Jest[2] 的方式。Enzyme[3] 也是十分出色的單元測試庫,我們應該選擇哪種測試工具呢?
下面讓我們看一個簡單的計數器的例子,以及兩個相應的測試:第一個是使用 Enzyme[4] 編寫的,第二個是使用 React Testing Library[5] 編寫的。
counter.js
// counter.js import React from "react"; class Counter extends React.Component { state = { count: 0 }; increment = () => this.setState(({ count }) => ({ count: count + 1 })); decrement = () => this.setState(({ count }) => ({ count: count - 1 })); render() { return ( <div> <button onClick={this.decrement}>-</button> <p>{this.state.count}</p> <button onClick={this.increment}>+</button> </div> ); } } export default Counter;
counter-enzyme.test.js
// counter-enzyme.test.js import React from "react"; import { shallow } from "enzyme"; import Counter from "./counter"; describe("<Counter />", () => { it("properly increments and decrements the counter", () => { const wrapper = shallow(<Counter />); expect(wrapper.state("count")).toBe(0); wrapper.instance().increment(); expect(wrapper.state("count")).toBe(1); wrapper.instance().decrement(); expect(wrapper.state("count")).toBe(0); }); });
counter-rtl.test.js
// counter-rtl.test.js import React from "react"; import { render, fireEvent } from "@testing-library/react"; import Counter from "./counter"; describe("<Counter />", () => { it("properly increments and decrements the counter", () => { const { getByText } = render(<Counter />); const counter = getByText("0"); const incrementButton = getByText("+"); const decrementButton = getByText("-"); fireEvent.click(incrementButton); expect(counter.textContent).toEqual("1"); fireEvent.click(decrementButton); expect(counter.textContent).toEqual("0"); }); });
比較兩個例子,你能看出哪個測試文件是最好的嘛?如果你不是很熟悉單元測試,可能會任務兩種都很好。但是實際上 Enzyme
的實現有兩個誤報的風險:
•即使代碼損壞,測試也會通過。•即使代碼正確,測試也會失敗。
讓我們來舉例說明這兩點。假設您希望重構組件,因為您希望能夠設置任何count值。因此,您可以刪除遞增和遞減方法,然後添加一個新的setCount方法。假設你忘記將這個新方法連接到不同的按鈕:
counter.js
// counter.js export default class Counter extends React.Component { state = { count: 0 }; setCount = count => this.setState({ count }); render() { return ( <div> <button onClick={this.decrement}>-</button> <p>{this.state.count}</p> <button onClick={this.increment}>+</button> </div> ); } }
第一個測試(Enzyme
)將通過,但第二個測試(RTL
)將失敗。實際上,第一個並不關心按鈕是否正確地連接到方法。它只查看實現本身,也就是說,您的遞增和遞減方法執行之後,應用的狀態是否正確。這就是代碼損壞,測試也會通過。
現在是2020年,你也許聽說過 React Hooks
,並且打算使用 React Hooks
來改寫我們的計數器代碼:
counter.js
// counter.js import React, { useState } from "react"; export default function Counter() { const [count, setCount] = useState(0); const increment = () => setCount(count => count + 1); const decrement = () => setCount(count => count - 1); return ( <div> <button onClick={decrement}>-</button> <p>{count}</p> <button onClick={increment}>+</button> </div> ); }
這一次,即使您的計數器仍然工作,第一個測試也將被打破。Enzyme
會報錯,函數組件中無法使用state
:
ShallowWrapper::state() can only be called on class components
接下來,就需要改寫單元測試文件了:
counter-enzyme.test.js
import React from "react"; import { shallow } from "enzyme"; import Counter from "./counter"; describe("<Counter />", () => { it("properly increments and decrements the counter", () => { const setValue = jest.fn(); const useStateSpy = jest.spyOn(React, "useState"); useStateSpy.mockImplementation(initialValue => [initialValue, setValue]); const wrapper = shallow(<Counter />); wrapper .find("button") .last() .props() .onClick(); expect(setValue).toHaveBeenCalledWith(1); // We can't make any assumptions here on the real count displayed // In fact, the setCount setter is mocked! wrapper .find("button") .first() .props() .onClick(); expect(setValue).toHaveBeenCalledWith(-1); }); });
而使用 React Testing Library
編寫的單元測試還是可以正常運行的,因為它更加關注應用的事件處理,以及展示;而非應用的實現細節,以及狀態變化。更加符合我們對於單元測試的原本訴求,以及最佳實踐。
可遵循的簡單規則
也許上文中使用 React Testing Library
編寫的單元測試示例,還會給人一種一頭霧水的感覺。下面,讓我們使用 AAA 模式來一步一步的拆解這部分代碼。
AAA模式:編排(Arrange),執行(Act),斷言(Assert)。
幾乎所有的測試都是這樣寫的。首先,您要編排(初始化)您的代碼,以便為接下來的步驟做好一切準備。然後,您執行用戶應該執行的步驟(例如單擊)。最後,您對應該發生的事情進行斷言。
import React from "react"; import { render, fireEvent } from "@testing-library/react"; import Counter from "./app"; describe("<Counter />", () => { it("properly increments the counter", () => { // Arrange const { getByText } = render(<Counter />); const counter = getByText("0"); const incrementButton = getByText("+"); const decrementButton = getByText("-"); // Act fireEvent.click(incrementButton); // Assert expect(counter.textContent).toEqual("1"); // Act fireEvent.click(decrementButton); // Assert expect(counter.textContent).toEqual("0"); }); });
編排(Arrange)
在編排這一步,我們需要完成2項任務:
•渲染組件•獲取所需的DOM的不同元素。
渲染組件可以使用 RTL's API 的 render
方法完成。簽名如下:
function render( ui: React.ReactElement, options?: Omit<RenderOptions, 'queries'> ): RenderResult
ui
是你要加載的組件。options
通常不需要指定選項。官方文檔在這裡[6],如果要指定的話,如下值是對官方文檔的簡單摘錄:
•container:React Testing庫將創建一個div並將該div附加到文檔中。而通過這個參數,可以自定義容器。•baseElement:如果指定了容器,則此值默認為該值,否則此值默認為document.documentElement。這將用作查詢的基本元素,以及在使用debug()時打印的內容。•hydrate:用於服務端渲染,使用 ReactDOM.hydrate
加載你的組件。•wrapper:傳遞一個組件作為包裹層,將我們要測試的組件渲染在其中。這通常用於創建可以重用的自定義 render 函數,以便提供常用數據。•queries:查詢綁定。除非合併,否則將覆蓋DOM測試庫中的默認設置。
基本上,這個函數所做的就是使用ReactDOM呈現組件。在直接附加到document.body的新創建的div中呈現(或為服務器端呈現提供水合物)。因此,可以從DOM測試庫和其他一些有用的方法(如debug、rerender或unmount)獲得大量查詢。
文檔:https://testing-library.com/docs/dom-testing-library/api-queries#queries
但你可能會想,這些問題是什麼呢?有些實用程序允許您像用戶那樣查詢DOM:通過標籤文本、佔位符和標題查找元素。以下是一些來自文檔的查詢示例:
•getByLabelText:搜索與作為參數傳遞的給定文本匹配的標籤,然後查找與該標籤關聯的元素。•getByText:搜索具有文本節點的所有元素,其中的textContent與作為參數傳遞的給定文本匹配。•getByTitle:返回具有與作為參數傳遞的給定文本匹配的title屬性的元素。•getByPlaceholderText:搜索具有佔位符屬性的所有元素,並找到與作為參數傳遞的給定文本相匹配的元素。
一個特定的查詢有很多變體:
•getBy:返回查詢的第一個匹配節點,如果沒有匹配的元素或找到多個匹配,則拋出一個錯誤。•getAllBy:返回一個查詢中所有匹配節點的數組,如果沒有匹配的元素,則拋出一個錯誤。•queryBy:返回查詢的第一個匹配節點,如果沒有匹配的元素,則返回null。這對於斷言不存在的元素非常有用。•queryAllBy:返回一個查詢的所有匹配節點的數組,如果沒有匹配的元素,則返回一個空數組([])。•findBy:返回一個promise,該promise將在找到與給定查詢匹配的元素時解析。如果未找到任何元素,或者在默認超時時間為4500毫秒後找到了多個元素,則承諾將被拒絕。•findAllBy:返回一個promise,當找到與給定查詢匹配的任何元素時,該promise將解析為元素數組。
執行(Act)
現在一切都準備好了,我們可以行動了。為此,我們大部分時間使用了來自DOM測試庫的fireEvent,其簽名如下:
fireEvent(node: HTMLElement, event: Event)
簡單地說,這個函數接受一個DOM節點(您可以使用上面看到的查詢查詢它!)並觸發DOM事件,如單擊、焦點、更改等。您可以在這裡找到許多其他可以調度的事件。
我們的例子相當簡單,因為我們只是想點擊一個按鈕,所以我們只需:
fireEvent.click(incrementButton); // OR fireEvent.click(decrementButton);
斷言(Assert)
接下來是最後一部分。觸發事件通常會觸發應用程序中的一些更改,因此我們必須執行一些斷言來確保這些更改發生。在我們的測試中,這樣做的一個好方法是確保呈現給用戶的計數已經更改。因此,我們只需斷言textContent屬性的計數器是遞增或遞減:
expect(counter.textContent).toEqual("1"); expect(counter.textContent).toEqual("0");
恭喜你,到這裡你已經將我們的示例拆解成功。?
注意:這個AAA模式並不特定於測試庫。事實上,它甚至是任何測試用例的一般結構。我在這裡向您展示這個是因為我發現測試庫如何方便地在每個部分中編寫測試是一件很有趣的事情。
8個典型的例子
到這裡,就進入實戰階段了,接下來請先下載示例:rts-guide-demo[7] 。
安裝依賴的同時可以簡單看下我們的項目。src/test
目錄下存放了所有單元測試相關的文件。讓我們清空這個文件夾,再將下面的示例依次手過一遍。?(CV也是可以的?)
1.如何創建測試快照
快照,顧名思義,允許我們保存給定組件的快照。當您進行更新或重構,並希望獲取或比較更改時,它會提供很多幫助。
現在,讓我們看一下 App.js
文件的快照。
App.test.js
import React from 'react' import {render, cleanup} from '@testing-library/react' import App from '../App' afterEach(cleanup) it('should take a snapshot', () => { const { asFragment } = render(<App />) expect(asFragment()).toMatchSnapshot() })
要獲取快照,我們首先必須導入 render
和 cleanup
。這兩種方法將在本文中大量使用。
render
,顧名思義,有助於渲染React組件。cleanup
作為一個參數傳遞給 afterEach
,以便在每次測試後清理所有東西,以避免內存泄漏。
接下來,我們可以使用 render
呈現App組件,並從方法中獲取 asFragment
作為返回值。最後,確保App組件的片段與快照匹配。
現在,要運行測試,打開您的終端並導航到項目的根目錄,並運行以下命令:
npm test
因此,它將創建一個新的文件夾 __snapshots__
和一個文件 App.test.js
:
App.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`should take a snapshot 1`] = ` <DocumentFragment> <div class="App" > <h1> Testing Updated </h1> </div> </DocumentFragment> `;
如果,你在 App.js
中做出更改,測試將失敗,因為快照將不再匹配。更新快照可以按 u
,或者將對應快照文件刪除即可。
2.測試DOM元素
要測試DOM元素,首先必須查看TestElements.js
文件。
TestElements.js
import React from 'react' const TestElements = () => { const [counter, setCounter] = React.useState(0) return ( <> <h1 data-testid="counter">{ counter }</h1> <button data-testid="button-up" onClick={() => setCounter(counter + 1)}> Up</button> <button disabled data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button> </> ) } export default TestElements
在這裡,您唯一需要保留的是 data-testid
。它將用於從測試文件中選擇這些元素。現在,讓我們完成單元測試:
測試計數器是否為0,以及按鈕的禁用狀態:
TestElements.test.js
import React from 'react'; import "@testing-library/jest-dom/extend-expect"; import { render, cleanup } from '@testing-library/react'; import TestElements from '../components/TestElements' afterEach(cleanup); it('should equal to 0', () => { const { getByTestId } = render(<TestElements />); expect(getByTestId('counter')).toHaveTextContent(0) }); it('should be enabled', () => { const { getByTestId } = render(<TestElements />); expect(getByTestId('button-up')).not.toHaveAttribute('disabled') }); it('should be disabled', () => { const { getByTestId } = render(<TestElements />); expect(getByTestId('button-down')).toBeDisabled() });
正如您所看到的,語法與前面的測試非常相似。唯一的區別是,我們使用 getByTestId
選擇必要的元素(根據 data-testid
)並檢查是否通過了測試。換句話說,我們檢查 <h1 data-testid="counter">{ counter }</h1>
中的文本內容是否等於0。
這裡,像往常一樣,我們使用 getByTestId
選擇元素和檢查第一個測試如果按鈕禁用屬性。對於第二個,如果按鈕是否被禁用。
如果您保存文件或在終端紗線測試中再次運行,測試將通過。
3.測試事件
在編寫單元測試之前,讓我們首先看下 TestEvents.js
是什麼樣子的。
import React from 'react' const TestEvents = () => { const [counter, setCounter] = React.useState(0) return ( <> <h1 data-testid="counter">{ counter }</h1> <button data-testid="button-up" onClick={() => setCounter(counter + 1)}> Up</button> <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button> </> ) } export default TestEvents
現在,讓我們編寫測試。
當我們點擊按鈕時,測試計數器的增減是否正確:
import React from 'react'; import "@testing-library/jest-dom/extend-expect"; import { render, cleanup, fireEvent } from '@testing-library/react'; import TestEvents from '../components/TestEvents' afterEach(cleanup); it('increments counter', () => { const { getByTestId } = render(<TestEvents />); fireEvent.click(getByTestId('button-up')) expect(getByTestId('counter')).toHaveTextContent('1') }); it('decrements counter', () => { const { getByTestId } = render(<TestEvents />); fireEvent.click(getByTestId('button-down')) expect(getByTestId('counter')).toHaveTextContent('-1') });
可以看到,除了預期的文本內容之外,這兩個測試非常相似。
第一個測試使用 fireEvent.click()
觸發一個 click
事件,檢查單擊按鈕時計數器是否增加到1。
第二個檢查當點擊按鈕時計數器是否減為-1。
fireEvent
有幾個可以用來測試事件的方法,因此您可以自由地深入文檔了解更多信息。
現在我們已經知道了如何測試事件,接下來我們將在下一節中學習如何處理異步操作。
4. 測試異步操作
異步操作是需要時間才能完成的操作。它可以是HTTP請求、計時器等等。
現在,讓我們檢查 TestAsync.js
文件。
import React from 'react' const TestAsync = () => { const [counter, setCounter] = React.useState(0) const delayCount = () => ( setTimeout(() => { setCounter(counter + 1) }, 500) ) return ( <> <h1 data-testid="counter">{ counter }</h1> <button data-testid="button-up" onClick={delayCount}> Up</button> <button data-testid="button-down" onClick={() => setCounter(counter - 1)}>Down</button> </> ) } export default TestAsync
這裡,我們使用 setTimeout()
將遞增事件延遲0.5秒。
測試計數器在0.5秒後判斷是否增加:
TestAsync.test.js
import React from 'react'; import "@testing-library/jest-dom/extend-expect"; import { render, cleanup, fireEvent, waitForElement } from '@testing-library/react'; import TestAsync from '../components/TestAsync' afterEach(cleanup); it('increments counter after 0.5s', async () => { const { getByTestId, getByText } = render(<TestAsync />); fireEvent.click(getByTestId('button-up')) const counter = await waitForElement(() => getByText('1')) expect(counter).toHaveTextContent('1') });
要測試遞增事件,我們首先必須使用 async/await
來處理操作,因為如前所述,完成它需要時間。
接下來,我們使用一個新的助手方法 getByText()
。這類似於getByTestId()
。getByText()
選擇文本內容,而不是id。
現在,在單擊按鈕之後,我們等待 waitForElement(() => getByText('1')
來增加計數器。一旦計數器增加到1,我們現在可以移動到條件並檢查計數器是否等於1。
也就是說,現在讓我們轉向更複雜的測試用例。
你準備好了嗎?
5.測試 React Redux
讓我們檢查一下 TestRedux.js
是什麼樣子的。
TestRedux.js
import React from 'react' import { connect } from 'react-redux' const TestRedux = ({counter, dispatch}) => { const increment = () => dispatch({ type: 'INCREMENT' }) const decrement = () => dispatch({ type: 'DECREMENT' }) return ( <> <h1 data-testid="counter">{ counter }</h1> <button data-testid="button-up" onClick={increment}>Up</button> <button data-testid="button-down" onClick={decrement}>Down</button> </> ) } export default connect(state => ({ counter: state.count }))(TestRedux)
store/reducer.js
export const initialState = { count: 0, } export function reducer(state = initialState, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1, } case 'DECREMENT': return { count: state.count - 1, } default: return state } }
正如您所看到的,沒有什麼特別的。它只是一個由 React Redux
處理的基本計數器組件。
現在,讓我們來編寫單元測試。
測試初始狀態是否為0:
import React from 'react' import "@testing-library/jest-dom/extend-expect"; import { createStore } from 'redux' import { Provider } from 'react-redux' import { render, cleanup, fireEvent } from '@testing-library/react'; import { initialState, reducer } from '../store/reducer' import TestRedux from '../components/TestRedux' const renderWithRedux = ( component, { initialState, store = createStore(reducer, initialState) } = {} ) => { return { ...render(<Provider store={store}>{component}</Provider>), store, } } afterEach(cleanup); it('checks initial state is equal to 0', () => { const { getByTestId } = renderWithRedux(<TestRedux />) expect(getByTestId('counter')).toHaveTextContent('0') }) it('increments the counter through redux', () => { const { getByTestId } = renderWithRedux(<TestRedux />, {initialState: {count: 5} }) fireEvent.click(getByTestId('button-up')) expect(getByTestId('counter')).toHaveTextContent('6') }) it('decrements the counter through redux', () => { const { getByTestId} = renderWithRedux(<TestRedux />, { initialState: { count: 100 }, }) fireEvent.click(getByTestId('button-down')) expect(getByTestId('counter')).toHaveTextContent('99') })
我們需要導入一些東西來測試 React Redux
。這裡,我們創建了自己的助手函數 renderWithRedux()
來呈現組件,因為它將被多次使用。
renderWithRedux()
作為參數接收要呈現的組件、初始狀態和存儲。如果沒有存儲,它將創建一個新的存儲,如果它沒有接收初始狀態或存儲,它將返回一個空對象。
接下來,我們使用render()
來呈現組件並將存儲傳遞給提供者。
也就是說,我們現在可以將組件 TestRedux
傳遞給 renderWithRedux()
來測試計數器是否等於0。
測試計數器的增減是否正確:
為了測試遞增和遞減事件,我們將初始狀態作為第二個參數傳遞給renderWithRedux()
。現在,我們可以單擊按鈕並測試預期的結果是否符合條件。
現在,讓我們進入下一節並介紹 React Context。
6. 測試 React Context
讓我們檢查一下 TextContext.js
是什麼樣子的。
import React from "react" export const CounterContext = React.createContext() const CounterProvider = () => { const [counter, setCounter] = React.useState(0) const increment = () => setCounter(counter + 1) const decrement = () => setCounter(counter - 1) return ( <CounterContext.Provider value={{ counter, increment, decrement }}> <Counter /> </CounterContext.Provider> ) } export const Counter = () => { const { counter, increment, decrement } = React.useContext(CounterContext) return ( <> <h1 data-testid="counter">{ counter }</h1> <button data-testid="button-up" onClick={increment}> Up</button> <button data-testid="button-down" onClick={decrement}>Down</button> </> ) } export default CounterProvider
現在,通過 React Context 管理計數器狀態。讓我們編寫單元測試來檢查它是否按預期運行。
測試初始狀態是否為0:
TextContext.test.js
import React from 'react' import "@testing-library/jest-dom/extend-expect"; import { render, cleanup, fireEvent } from '@testing-library/react' import CounterProvider, { CounterContext, Counter } from '../components/TestContext' const renderWithContext = ( component) => { return { ...render( <CounterProvider value={CounterContext}> {component} </CounterProvider>) } } afterEach(cleanup); it('checks if initial state is equal to 0', () => { const { getByTestId } = renderWithContext(<Counter />) expect(getByTestId('counter')).toHaveTextContent('0') }) it('increments the counter', () => { const { getByTestId } = renderWithContext(<Counter />) fireEvent.click(getByTestId('button-up')) expect(getByTestId('counter')).toHaveTextContent('1') }) it('decrements the counter', () => { const { getByTestId} = renderWithContext(<Counter />) fireEvent.click(getByTestId('button-down')) expect(getByTestId('counter')).toHaveTextContent('-1') })
與前面的React Redux部分一樣,這裡我們使用相同的方法,創建一個助手函數renderWithContext()
來呈現組件。但是這一次,它只接收作為參數的組件。為了創建新的上下文,我們將CounterContext
傳遞給 Provider。
現在,我們可以測試計數器最初是否等於0。那麼,計數器的增減是否正確呢?
正如您所看到的,這裡我們觸發一個 click
事件來測試計數器是否正確地增加到1並減少到-1。
也就是說,我們現在可以進入下一節並介紹React Router。
7. 測試 React Router
讓我們檢查一下 TestRouter.js
是什麼樣子的。
TestRouter.js
import React from 'react' import { Link, Route, Switch, useParams } from 'react-router-dom' const About = () => <h1>About page</h1> const Home = () => <h1>Home page</h1> const Contact = () => { const { name } = useParams() return <h1 data-testid="contact-name">{name}</h1> } const TestRouter = () => { const name = 'John Doe' return ( <> <nav data-testid="navbar"> <Link data-testid="home-link" to="/">Home</Link> <Link data-testid="about-link" to="/about">About</Link> <Link data-testid="contact-link" to={`/contact/${name}`}>Contact</Link> </nav> <Switch> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> <Route path="/about:name" component={Contact} /> </Switch> </> ) } export default TestRouter
這裡,將測試路由對應的頁面信息是否正確。
TestRouter.test.js
import React from 'react' import "@testing-library/jest-dom/extend-expect"; import { Router } from 'react-router-dom' import { render, fireEvent } from '@testing-library/react' import { createMemoryHistory } from 'history' import TestRouter from '../components/TestRouter' const renderWithRouter = (component) => { const history = createMemoryHistory() return { ...render ( <Router history={history}> {component} </Router> ) } } it('should render the home page', () => { const { container, getByTestId } = renderWithRouter(<TestRouter />) const navbar = getByTestId('navbar') const link = getByTestId('home-link') expect(container.innerHTML).toMatch('Home page') expect(navbar).toContainElement(link) }) it('should navigate to the about page', ()=> { const { container, getByTestId } = renderWithRouter(<TestRouter />) fireEvent.click(getByTestId('about-link')) expect(container.innerHTML).toMatch('About page') }) it('should navigate to the contact page with the params', ()=> { const { container, getByTestId } = renderWithRouter(<TestRouter />) fireEvent.click(getByTestId('contact-link')) expect(container.innerHTML).toMatch('John Doe') })
要測試React Router,我們首先必須有一個導航歷史記錄。因此,我們使用 createMemoryHistory()
來創建導航歷史。
接下來,我們使用助手函數 renderWithRouter()
來呈現組件,並將歷史記錄傳遞給路由器組件。這樣,我們現在就可以測試在開始時加載的頁面是否是主頁。以及導航欄是否加載了預期的鏈接。
測試當我們點擊鏈接時,它是否用參數導航到其他頁面:
現在,要檢查導航是否工作,我們必須觸發導航鏈接上的單擊事件。
對於第一個測試,我們檢查內容是否等於About頁面中的文本,對於第二個測試,我們測試路由參數並檢查它是否正確通過。
現在我們可以進入最後一節,學習如何測試Axios請求。
8. 測試HTTP請求
讓我們檢查一下 TestRouter.js
是什麼樣子的。
import React from 'react' import axios from 'axios' const TestAxios = ({ url }) => { const [data, setData] = React.useState() const fetchData = async () => { const response = await axios.get(url) setData(response.data.greeting) } return ( <> <button onClick={fetchData} data-testid="fetch-data">Load Data</button> { data ? <div data-testid="show-data">{data}</div>: <h1 data-testid="loading">Loading...</h1> } </> ) } export default TestAxios
正如您在這裡看到的,我們有一個簡單的組件,它有一個用於發出請求的按鈕。如果數據不可用,它將顯示一個加載消息。
現在,讓我們編寫測試。
來驗證數據是否正確獲取和顯示:
TextAxios.test.js
import React from 'react' import "@testing-library/jest-dom/extend-expect"; import { render, waitForElement, fireEvent } from '@testing-library/react' import axiosMock from 'axios' import TestAxios from '../components/TestAxios' jest.mock('axios') it('should display a loading text', () => { const { getByTestId } = render(<TestAxios />) expect(getByTestId('loading')).toHaveTextContent('Loading...') }) it('should load and display the data', async () => { const url = '/greeting' const { getByTestId } = render(<TestAxios url={url} />) axiosMock.get.mockResolvedValueOnce({ data: { greeting: 'hello there' }, }) fireEvent.click(getByTestId('fetch-data')) const greetingData = await waitForElement(() => getByTestId('show-data')) expect(axiosMock.get).toHaveBeenCalledTimes(1) expect(axiosMock.get).toHaveBeenCalledWith(url) expect(greetingData).toHaveTextContent('hello there') })
這個測試用例有點不同,因為我們必須處理HTTP請求。為此,我們必須在jest.mock('axios')
的幫助下模擬axios請求。
現在,我們可以使用axiosMock並對其應用get()
方法。最後,我們將使用Jest函數mockResolvedValueOnce()
來傳遞模擬數據作為參數。
現在,對於第二個測試,我們可以單擊按鈕來獲取數據並使用async/await
來解析它。現在我們要測試三件事:
•如果HTTP請求已經正確完成•如果使用url完成了HTTP請求•如果獲取的數據符合期望。
對於第一個測試,我們只檢查加載消息在沒有數據要顯示時是否顯示。
也就是說,我們現在已經完成了八個簡單的步驟來測試你的React應用程序。
更多例子請參考React Testing Library官方文檔[8]。
結語
React Testing Library
是用於測試 React 應用的一大利器。它為我們提供了訪問 jest-dom
匹配器的機會,以及最佳實踐,使得我們可以使用它來更有效地測試我們的組件。希望這篇文章是有用的,它將幫助您在未來構建更加健壯的 React 應用程序。
參考文章
•React 官方文檔[9]•React Testing Library 官方文檔[10]•How to Start Testing Your React Apps Using the React Testing Library and Jest[11]•Test React apps with React Testing Library[12]
References
[1]
React Testing Library: https://testing-library.com/docs/react-testing-library/intro [2]
Jest: https://jestjs.io/ [3]
Enzyme: https://github.com/enzymejs/enzyme [4]
Enzyme: https://github.com/enzymejs/enzyme [5]
React Testing Library: https://testing-library.com/docs/react-testing-library/intro [6]
官方文檔在這裡: https://testing-library.com/docs/react-testing-library/api#render-options [7]
rts-guide-demo: https://github.com/jokingzhang/rts-guide-demo [8]
React Testing Library官方文檔: https://testing-library.com/docs/example-input-event [9]
React 官方文檔: https://zh-hans.reactjs.org/docs/testing.html [10]
React Testing Library 官方文檔: https://testing-library.com/docs/example-input-event [11]
How to Start Testing Your React Apps Using the React Testing Library and Jest: https://www.freecodecamp.org/news/8-simple-steps-to-start-testing-react-apps-using-react-testing-library-and-jest/ [12]
Test React apps with React Testing Library: https://thomlom.dev/test-react-testing-library/