小規模のアプリケーションであれば、React が提供する Context API と Hooks だけで global state を管理できることが知られています。本記事ではこのパターンを採用した場合のテスト手法を紹介します。
React のみで global state を扱う
createContext
と useState
または useReducer
を組み合わせて使います。例えば、サイトテーマを global state として管理するとしましょう。
/contexts/theme/index.ts
import { createContext, useContext, useState } from 'react';
type Theme = 'light' | 'dark';
type ThemeContextType = {
theme: Theme;
toggle: () => void;
};
// ThemeProvider から初期値を注入するため、宣言時は null を代入しておく。
const ThemeContext = createContext<ThemeContextType | null>(null);
// Context Provider
export const ThemeProvider = ({
defaultTheme,
children,
}: React.PropsWithChildren<{
defaultTheme: Theme;
}>) => {
// ThemeProvider 配下のコンポーネントからテーマを更新できるように、global state は useState で宣言する。
const [theme, setTheme] = useState<Theme>(defaultTheme);
const toggle = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
};
// Custom Hook for context consumers
export const useTheme = () => {
const context = useContext(ThemeContext);
// ThemeProvider 内でなければ例外を発生させる。
if (!context) {
throw new Error('useTheme must be used within ThemeProvider.');
}
// 型が絞り込まれて null が排除されるため、配下のコンポーネントは `value={{ theme, toggle }` を受け取れる。
return context;
};
ポイントは Context Provider とカスタムフックを 1 つのファイルに閉じ込めることで、凝集性を高めてテストし易くしているところです。Context Provider に当たる ThemeProvider
は次のように使います。
/pages/_app.tsx
import Layout from '@/components/Layout';
import { ThemeProvider } from '@/contexts/theme';
import type { AppProps } from 'next/app';
import '@/styles/globals.scss';
const App = ({ Component, pageProps }: AppProps) => (
<ThemeProvider defaultTheme="light">
{/* ^^^^^ すべてのページで共有できるように、`_app.tsx` 内で使用する。 */}
<Layout>
<Component {...pageProps} />
</Layout>
</ThemeProvider>
);
export default App;
ThemeProvider
配下のコンポーネントでは、useTheme
を使用して global state を受け取れます。
/components/Layout/index.tsx
type Props = React.PropsWithChildren;
import { useTheme } from '@/contexts/theme';
import styles from './index.module.scss';
const Layout = ({ children }: Props) => {
const { theme } = useTheme();
return (
<div className={`${theme === 'light' ? styles.light : styles.dark}`}>
{children}
</div>
);
};
export default Layout;
React Testing Library によるテスト
React Testing Library によるカスタムフックのテストでは、render
の代わりに renderHook を用いてフックを擬似的に実行します。フックの返り値に対して期待する動作を記述するだけですが、state の更新処理などは act で囲むという注意点があります。
/contexts/theme/index.test.tsx
import { ThemeProvider, useTheme } from '.';
import { act, renderHook } from '@testing-library/react';
describe('useTheme', () => {
test('should return current theme and function to toggle theme', () => {
const { result } = renderHook(useTheme, {
wrapper: ({ children }) => (
// ^^^^^ wrapper オプションで ThemeProvider を設定する。
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
),
});
expect(result.current.theme).toBe('light');
// state の更新は act 内で実行することで、後続のアサーション (expect) が処理の結果を待ってから実行される。
act(() => {
result.current.toggle();
});
expect(result.current.theme).toBe('dark');
});
});
おわりに
改めてカスタムフックによる再利用性とテスト容易性の向上を実感しました。適切に処理を切り出すことができれば、その恩恵を存分に受けられそうです 👾