ymmr
Recent Articles
    公開日: 2024/01/20

    React のみで global state を扱う場合のテスト手法

    小規模のアプリケーションであれば、React が提供する Context API と Hooks だけで global state を管理できることが知られています。本記事ではこのパターンを採用した場合のテスト手法を紹介します。

    React のみで global state を扱う

    createContextuseState または 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');
      });
    });
    

    おわりに

    改めてカスタムフックによる再利用性とテスト容易性の向上を実感しました。適切に処理を切り出すことができれば、その恩恵を存分に受けられそうです 👾

    参考

    How to use React Context effectivelyHow to create and expose React Context providers and consumerskentcdodds.com