跳至主要内容

[Day28] 測試依賴外層 Context Provider 的 React 元件:客製化 render 函式

keywords: testing, react-testing-library, jest

昨天提到可以用 Mock Module 的方式來模擬函式或套件的回傳值,但有些時候情況沒那麼單純,例如當我們有使用 react-router-dom、redux、styled-components 的 ThemeProvider 等作為外層元件(Wrapper)的情況,當有用了這些 wrapper 後,就可以在自己的元件中取得它們所提供的方法。

具體來說,以 react-router-dom 為例,在 App 的最外層會使用 <BrowserRouter /> 來把所有的元件包起來,類似像這樣:

// index.tsx
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Router>
<Switch>
<Route path="/">
<App />
</Route>
</Switch>
</Router>
);

接著我們就可以在 <Router> 元件內的其他組件取得由 react-router-dom 提供的方法,例如 useLocation

import { useLocation } from 'react-router-dom';

function App() {
const location = useLocation();

return (
<div className="App">
<header className="App-header">Your current path is {location.pathname}</header>
</div>
);
}

export default App;

透過 useLocation 就能夠在 App 元件中取得當前路由的 location,但要能夠使用 useLocation 有一個前提是 <App /> 這個元件需要被包在 <Router /> 元件中。

React 中類似用法的例子還很多,例如在 styled-components 中,需要使用 <ThemeProvider /> 包起來後才能在內層元件中取得定義好的主題配色;在 recoil 中,需要使用 <RecoilRoot /> 包起來後才能在內層元件使用到它提供的 useRecoilState 這個方法。

撰寫測試時也要記得 Wrap 起來

回到 react-router-dom 的例子,前面有提過要在 <App /> 中使用 useLocation 的前提是:<App /> 需要是 <Router /> 的子元件時才能使用,也就是至少要像這樣:

<Router>
<App />
</Router>

現在我們要針對 <App /> 元件進行測試時,如果在還沒有使用 react-router-dom 之前,原本的寫法會像是這樣,並且能夠正確執行:

import { render, screen } from '@testing-library/react';
import App from './App';

test('render user data successfully', async () => {
render(<App />);

const textElement = screen.getByText(/Your current path is/);
expect(textElement).toBeInTheDocument();
});

但現在因為我們在 App 元件中用了 useLocation 這個方法,所以上面這樣的測試會噴錯,這個錯誤通常會像是毀天滅地一般:

Screen Shot 2021-10-13 at 11.10.34 PM

但細看這個錯誤訊息就會發現,它說的是 "TypeError: Cannot read property 'location' of undefined",有經驗的開發者很快就會知道,這個錯誤的意思是我們試著從 undefined 中想要去拿出 location 這個屬性,於是就壞了。

在原本 <App /> 元件中,可以看到是使用了 const location = useLocation(); 來取出 location,也就是說現在的 useLocation() 是 undefined 的意思,所以才會報出剛剛那個錯誤。

要解決這個問題很簡單,只需要在 render App 元件的地方把 <App /> 外也包上 <Router /> 就可以了,像是這樣:

import { render, screen } from '@testing-library/react';
import App from './App';

import { BrowserRouter as Router } from 'react-router-dom';

test('render user data successfully', async () => {
// 因為 App 中用到了 react-router-dom 的 useLocation
// 所以需要在 render App 的地方先把 <App /> 用 <Router /> 包起來
render(
<Router>
<App />
</Router>,
);

const textElement = screen.getByText(/Your current path is/);
expect(textElement).toBeInTheDocument();
});

這時候測試就會順利的通過了:

Screen Shot 2021-10-13 at 11.17.36 PM

一般來說,不論是 react-router-dom 或是先前提到的 redux、styled-components、recoil 等這類套件,都可以透過這樣的方式來解決,這也是在做整合測試(integration testing)時很常用到的方式。

但是,讀者應該會發現這麼做雖然可以解決問題但非常麻煩,因為這類的 Provider(例如,<Router />)通常都是包在最外層,也就是很多元件都會直接使用它們提供的方法,如果每次測試元件時,都還需要把欲測試的元件一一包起來,實作有點冗餘,要是用的工具比較多時可能還會很大一包,例如:

import { render, screen } from '@testing-library/react';

test('render user data successfully', async () => {
render(
<Router>
<RecoilRoot>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</RecoilRoot>
</Router>,
);

const textElement = screen.getByText(/Your current path is/);
expect(textElement).toBeInTheDocument();
});

有沒有辦法不要每次測試元件時都要寫這些重複的 wrapper 呢?當然有!

客製化 react-testing-library render 函式

在測試時要轉譯(render)元件是透過 react-testing-library 提供的 render 方法,因此如果想要省去重複撰寫這些 wrapper 的話,就可以從這個 render 下手。

在 react-testing-library 中提供了客製化 render 函式的方法,讓開發者可以把想要包起來的 wrapper 都先寫在 render 函式中。作法其實不會很難,根據官方的範例可以建立一支 custom-testing-library.tsx

// custom-testing-library.tsx
import React, { FC, ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter as Router } from 'react-router-dom';

const AllTheProviders: FC = ({ children }) => {
return <Router>{children}</Router>;
};

const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: AllTheProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

接著在撰寫測試的時候,就不要直接 import testing-library 的 render 方法,而是從這個 custom-testing-library.tsx 拿,同時由於 custom-testing-library 有直接 export 了原本的 @testing-library/react,所以一樣可以從 custom-testing-library 中取得 react-testing-library 中原本的內容。

現在就可以把原本的測試改成這樣:

import { render, screen } from './custom-testing-library';
import App from './App';

test('render user data successfully', async () => {
render(<App />);

const textElement = screen.getByText(/Your current path is/);
expect(textElement).toBeInTheDocument();
});

這時候,我們就不需要在 render 裡面在額外包其他哩哩摳摳的各種 Wrapper 了,因為都已經在客製化的 render 中處理掉了。

參考資料