iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Modern Web

React 前端工程師的兩把刷子系列 第 28

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

昨天提到可以用 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 中處理掉了。

參考資料


上一篇
[Day27] 建立 Mock Module / Function 的方式(ft. TypeScript)
下一篇
[Day29] React Testing Library 的一些實用的小技巧
系列文
React 前端工程師的兩把刷子30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
juck30808
iT邦研究生 1 級 ‧ 2021-10-14 12:04:34

恭喜即將邁入完賽啦~

我要留言

立即登入留言