[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:
Redux
、Zustand
- 路由管理,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 的過程,使前端的單元測試更好維護和撰寫