💭

【react】queryParameterをテストする

に公開

はじめに

urlにqueryParameterを付与するCustom hookのテストを行いたかったのですが、その際に少し工夫が必要だったので備忘録としてまとめます

環境

  • react 18.2.0
  • react-router-dom 6.15.0
  • vite 4.4.5
  • vitest 0.34.1
  • happy-dom 10.11.0

ベースとなる諸々

テスト用にinputに入力した値をqueryParameterにセットする簡素な仕組みを作成していきたいと思います

CRA

viteで基盤となるプロジェクトを作成していきます

$ npm create vite@latest
✔ Project name: … test-query-parameters
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in /mnt/d/project/blog-code/test-query-parameters...

Done. Now run:

  cd test-query-parameters
  npm install
  npm run dev

ディレクトリを移動してnpm installも行います

余談ですが
nodeの管理にvoltaを使用しているのですが、package.jsonにpinしておくと、他の方はnpm installするだけで
package.jsonにpinされたnodeとnpmのバージョンが適用されるので便利ですです

$ volta pin node
$ volta pin npm
{
  ...
  "volta": {
    "node": "18.17.1",
    "npm": "9.8.1"
  }
}

ライブラリインストール

queryParameterのテストを行うのに必要なライブラリを入れていきます
コンポーネントを含めたテストを行うため、happy-domもここで入れておきます

$ npm install react-router-dom
$ npm install -D vitest
$ npm install -D happy-dom
$ npm install -D @testing-library/react

実装

routerを定義します
簡素な実装となるため、特に分けたりはせずにべたで書いていきます

router.tsx
import { useRoutes } from "react-router-dom"
import App from "./App"

export const Routes = () => {
  const element = useRoutes([
    {
      path: '/',
      element: <App />
    },
  ])
  return <>{element}</>
}

main.tsxに上記Routesを適用します

main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <Routes />
    </BrowserRouter>
  </React.StrictMode>,
)

queryParameterに値をセットするCustom hookを作成します
セットにはreact-router-domから提供されているuseSearchParamsを用います
https://reactrouter.com/en/main/hooks/use-search-params

useQueryParameter.ts
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';

export const useQueryParameter = () => {
  const [searchParams, setSearchParams] = useSearchParams();
  const testValue = searchParams.get('test') ?? '';
  const handleOnChange = useCallback(
    (value: string) => {
      // 本来はここで値変換等を行った際のテストをしてます
      setSearchParams((prev) => ({
        ...Object.fromEntries(prev),
        test: value,
      }));
    },
    [setSearchParams]
  );
  return { testValue, handleOnChange };
};

最後にAppにinputを適当に設置して、上記hookから受け取った値を使用してvalueとonChangeを定義します

App.tsx
function App() {
  const [count, setCount] = useState(0)

  const { testValue, handleOnChange } = useQueryParameter()

  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <input
        type='text'
        id='test'
        name='test'
        value={testValue}
        onChange={(e) => handleOnChange(e.target.value)}
      />
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}
export default App

これでinputに入力した値がqueryParameterとしてurlに含まれるようになりました。確認してみましょ
テストインプット

いい感じですねー

最終のディレクトリ構成はこんな感じです

├── public
│   └── vite.svg
├── src
│   ├── assets
│   │   └── react.svg
│   ├── App.css
│   ├── App.tsx
│   ├── index.css
│   ├── main.tsx
│   ├── router.tsx
│   ├── useQueryParameter.ts
│   └── vite-env.d.ts
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

ここからhookのuseQueryParameterに対して、testを書いていきたいと思います

test実装

まずはテストをするためにvite.configとtsconfigの設定を変えていきます

vite.config.ts
/// <reference types="vitest" />

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'happy-dom'
  }
})
tsconfig.json
  "compilerOptions": {
    "types": [
      "vitest/globals"
    ]
  },
package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "test": "vitest"
  },

ここからhookのテストを書いていきます

ダメな例

今回react-router-domのuseSearchParamsを用いているので、Routerでwrapしてあげないと怒られてしまいます

useQueryParameter.test.tsx
describe('useQueryParameterのテスト', () => {
  test('testのクエリパラメータが存在しない場合は空文字', () => {
    const { result } = renderHook(() => useQueryParameter());
    expect(result.current.testValue).toBe('');
  });
});
 ❯ src/useQueryParameter.test.tsx (1)
   ❯ useQueryParameterのテスト (1)
     × testのクエリパラメータが存在しない場合は空文字

FAIL  src/useQueryParameter.test.tsx > useQueryParameterのテスト > testのクエリパラメータが存在しない場合は空文字
Error: useLocation() may be used only in the context of a <Router> component.

renderHookの際にwrapするには、第二引数のoptionsにWrapperコンポーネントを指定してあげることで実現できます
https://testing-library.com/docs/react-testing-library/api/#renderhook-options

また、react-router-domにはテストに最適なMemoryRouterというコンポーネントがあるためこちらを使ってwrapしてあげます
https://reactrouter.com/en/main/router-components/memory-router#memoryrouter

良い例

上記2つを踏まえてよい例の最終的なテストコードは以下となります

useQueryParameter.test.tsx
import { renderHook, act } from '@testing-library/react';
import { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { useQueryParameter } from './useQueryParameter';

const Wrapper = ({
  children,
  params,
}: {
  children: ReactNode;
  params?: string;
}) => (
  <MemoryRouter initialEntries={params ? [`/?${params}`] : undefined}>
    {children}
  </MemoryRouter>
);

describe('useQueryParameterのテスト', () => {
  test('testのクエリパラメータが存在しない場合は空文字', () => {
    const { result } = renderHook(() => useQueryParameter(), {
      wrapper: ({ children }) => <Wrapper>{children}</Wrapper>,
    });
    expect(result.current.testValue).toBe('');
  });
  test('testのクエリパラメータが存在する場合、testValueが同値となっている', () => {
    const { result } = renderHook(() => useQueryParameter(), {
      wrapper: ({ children }) => (
        <Wrapper params='test=value'>{children}</Wrapper>
      ),
    });
    expect(result.current.testValue).toBe('value');
  });
  test('inputの値を変更した際、クエリパラメータに反映されている', () => {
    const { result } = renderHook(() => useQueryParameter(), {
      wrapper: ({ children }) => (
        <Wrapper params='test=value'>{children}</Wrapper>
      ),
    });
    expect(result.current.testValue).toBe('value');
    act(() => {
      result.current.handleOnChange('お酒飲みたい');
    });
    expect(result.current.testValue).toBe('お酒飲みたい');
  });
});

renderHookのwrapperに、今回用に定義したWrapperを渡してあげます
parameterの初期値を変更できるようにpropsで受け取れるようにします

ちなみに、ドキュメントに

NOTE: When using renderHook in conjunction with the wrapper and initialProps options, the initialProps are not passed to the wrapper component.

と書かれているように2つ同時には使えないようです

上記テストを実行してみます

 ✓ src/useQueryParameter.test.tsx (3)
   ✓ useQueryParameterのテスト (3)
     ✓ testのクエリパラメータが存在しない場合は空文字
     ✓ testのクエリパラメータが存在する場合、testValueが同値となっている
     ✓ inputの値を変更した際、クエリパラメータに反映されている

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  00:15:37
   Duration  2.70s (transform 104ms, setup 0ms, collect 432ms, tests 43ms, environment 293ms, prepare 226ms)

うまく通りましたねー良い感じってやつです

おわりに

renderHook時に今回ケースのように、何かしらのコンポーネント配下でテストを行いたいケースはわりかしありそうだなぁと思いました

ソースコードはgithubにあげてありますー
https://github.com/takap-sandbox/react-test-query-parameters

Discussion