跳至主要内容

[Unit Test] 2.1 - 與 Provider 的測試

前情提要:Provider pattern

(如果對 Redux 等 Provider 已經熟悉,可以跳過此段)

在現今的前端框架,當我們有多個 Components 需要共用一些設定 or 狀態時,由於 Components 是呈樹狀結構,我們會將共用的設定 or 狀態放在這些 Components 共同且最近的 Parent node 進行統一個管理, 再由該 Parent node 將該設定 or 狀態一層層的傳遞下來,如下圖所示


當 2 個不同的 Child components 需要用到相同的設定 or 狀態







我們會放到 2 個元件的最近共同的 Parent node,並在藉由 Parent node 將狀態 or 設定一層層的傳遞下來, 好處是讓狀態 / 設定同步







但是這樣會延伸一個問題,當樹狀結構越來越龐大時,且當 2 個元件相距很遠時, 共用的 Parent node 深度 (depth) 與接受狀態的子元件深度差就會非常多, 我們就要傳遞非常多層的元件才能將狀態 / 設定傳遞到我們的目標子元件 ( 前端稱此問題為 Prop-drilling ),如下圖







此時,前端就發展出一個模式,稱作 Provider Pattern, 其形式為,將資料統一封裝在一個 Provider 裏,包在 root node 的上層, 只要是 child node,就可以直接透過 Provider 的 API,取得儲存在 Provider 的狀態 / 設定了, 不用再一層層的傳下來






像是

  • 樣式設定,ex:Style Provider (Tonic UI)
  • 狀態管理,ex:ReduxZustand
  • 路由管理,ex:React router


在單元測試中,Provider 造成的問題

但 Provider 模式,在單元測試中也會產生一些問題, 在單元測試中,我們只想要測試單一元件, 但是在此元件中,我們可能會用到不同 Provider 提供的狀態 / 設定, 這時,我們測試時,就需要另外將此元件包在 Providers 中,例如


  • 使用 Redux
import { Provider } from 'react-redux';

<Provider store={store}>
<TargetComponent />
</Provider>

  • 使用 Style Provider
import { TonicUiProvider } from '@tonic-ui/react';

<TonicUiProvider>
<TargetComponent />
</TonicUiProvider>

不然會產生以下的錯誤


  • Redux



  • Style Provider



但這時,當我們想要測試單一 Component 時,我們在每個 test case 都需要這樣包裝, 造成

  • 撰寫測試非常麻煩,冗長
  • 其他人難以閱讀測試

import { Provider } from 'react-redux';
import { TonicProvider } from '@tonic-ui/react';
import { render } from '@testing-library/react';


describe('TargetComponent', () => {
test('when under some scenaior, should perform some behavior', () => {
// Arrange
const store = createReduxStore();

// Act
const { getByTestId } = render(
<Provider store={store}>
<TonicProvider>
<TargetComponent />
</TonicProvider>
</Provider>
);
const senderEmailInput = getByTestId('tagetId');

// Assert
expect(senderEmailInput).toHaveValue('benson_chen@trendmicro.com');
});
});



想像一下如果我們每一個 test case 都需要這樣撰寫,那會是非常麻煩的步驟, 這也違反了 AOUT 中 Ch.8 提到 好的單元測試 的準則,包括:

  • 可讀性 ( Readability ) 被測試的 Component 被多層 Provider 包覆,不易閱讀
  • 可維護性 ( Maintainability ) 撰寫和改寫都不方便,會造成轉寫時程長,大家不想維護

因此,React Testing Library 的作者 Kent C Dodds. 和 Redux 官網推薦了一些改善的方案



主要的核心方法就是:改寫原本的 render 方法,讓 render 時預設帶入原本的 Provider



覆寫原生的 render

我們先將所有的 Providers 都集合在一起,然後再客製化 React Testing Library 的 render function,將 Providers 傳入 render 第 2 個參數,做一些初始化設定


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

// example Providers
import { ThemeProvider } from 'my-ui-lib'
import { TranslationProvider } from 'my-i18n-lib'
import defaultStrings from 'i18n/en-x-default'

const AllTheProviders = ({children}) => {
return (
<ThemeProvider theme="light">
<TranslationProvider messages={defaultStrings}>
{children}
</TranslationProvider>
</ThemeProvider>
)
};

const customRender = (ui, options) => (
render(ui, { wrapper: AllTheProviders, ...options });
)

// re-export everything
export * from '@testing-library/react';

// override render method
export { customRender as render };


這時,我們就可以使用原本 Provider 提供的 api,而不需要另外多包一層 Provider 了


const TargetComponent = () => {
const { color } = useTheme();

return (
<Box color={color.blue}>
I'm target
</Box>
);
};

describe('TargetComponent', () => {
test('when under some scenario, should perform some behaviors', () => {
...
const { getByTestId, debug } = render(<TargetComponent />);
...
});
})


Testing render with Redux

Redux 也可以採用類似的做法, 我們可以將偽造的 redux state 傳入 render 的第 2 個參數 option 中, ( option 本來就可以傳入一些預設值,像是 initialProps,所以我覺得把 mock redux state 放在這裡是合理的 )

import React from 'react'  
import { render } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
// As a basic setup, import your same slice reducers
import userReducer from '../features/users/userSlice'

const renderWithRedux = (
ui: ReactElement,
{
mockReduxState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(mockReduxState),
...renderOptions
}: ExtendedRenderOptions = {},
) => {
const Wrapper = function Wrapper({ children }) {
return (
<Provider store={store}>
<DefaultProviders>
{children}
</DefaultProviders>
</Provider>
);
};

return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
};

Usage

const mockReduxState = {
...state,
account: {
...state.account,
profile: {
...state.account.profile,
email: 'test@testdomain.com',
},
},
};

const { getByTestId, debug } = renderWithRedux(
<TargetComponent />, { mockReduxState }
);

總結

  • 前端的 Provider 方式雖然解決了很多 React 樹狀結構的節點需共享狀態的問題,但也造成單元測試上很大的困難, 我們可以利用預設帶有一些常用的 Provider,來簡化撰寫 Unit test 的過程,使前端的單元測試更好維護和撰寫

參考文獻