[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 中处理掉了。

参考资料


<<:  【後转前要多久】# Day28 Angular - 四种资料系结 Binding

>>:  ios app 上架流程

D20 - TiDB数据效验

sync_diff_inspector是TiDB提供的数据效验的工具。 可以用来比对TiDB与MyS...

[Day30] Flutter - App Icon(final)

前言 Hi, 我是鱼板伯爵今天铁人赛的最後一天了感谢大家的支持,不知道大家对这个架构是不是有一点感觉...

Windows Server 2022 整合 Azure Arc 简易教学示范

Azure Arc on Server功能列表 升级适用於云端的 Microsoft Defende...

认识JavaScrip

JavaScript(通常缩写为JS)是可以内嵌於网页中,是一个成熟的动态程序语言,在网站里加入互动...

[DAY 2] 网路应用架构演进

今天来简单说明网路架构的演进 在浏览器出现以前,使用的是C/S(Client-Server mode...