ymmr
Recent Articles
    公開日: 2023/10/23

    テストごとに SWR キャッシュをクリアする

    MSW を使用して API からのデータ取得を含むテストを書くとします。異常系のテストなどでレスポンスのデータを切り替えたい場面があるのですが、SWR のキャッシュがテスト間で共有されてしまう事態に直面しました。今回は問題となる挙動の原因を明らかにして、その対策を記事にします。

    問題となる挙動

    レスポンスとして受け取った名前に対して挨拶するコンポーネントがあったとします。

    /components/Greeting.tsx
    import axios from 'axios';
    import useSWR from 'swr';
    
    const Greeting = () => {
      const { data } = useSWR<{ name: string }>('/api/name', (url: string) =>
        axios.get<{ name: string }>(url).then((res) => res.data),
      );
    
      return data ? <div>{`Hello, ${data.name}!`}</div> : null;
    };
    
    export default Greeting;
    

    モックハンドラーを定義することで、特定のレスポンスを期待したテストを書くことができます。

    /mocks/handlers.ts
    import { rest } from 'msw';
    
    export const handlers = [
      rest.get('/api/name', (_req, res, ctx) => {
        return res(ctx.json({ name: 'Bob' }));
      }),
    ];
    
    /components/Greeting.test.tsx
    import Greeting from './Greeting';
    import { render, screen } from '@testing-library/react';
    
    test('sample test 1', async () => {
      render(<Greeting />);
      const greetingMessage = await screen.findByText('Hello, Bob!');
      expect(greetingMessage).toBeInTheDocument();
    });
    

    ここまでは万事順調です。しかし、続くテストでは別のレスポンスを受け取る必要が出てきました。テスト内で server.use を呼び出して、モックハンドラーを上書きすればよいはずです。

    /components/Greeting.test.tsx
    @@ -1,8 +1,21 @@
     import Greeting from './Greeting';
    +import { server } from '@/mocks/server';
     import { render, screen } from '@testing-library/react';
    +import { rest } from 'msw';
    
     test('sample test 1', async () => {
       render(<Greeting />);
       const greetingMessage = await screen.findByText('Hello, Bob!');
       expect(greetingMessage).toBeInTheDocument();
     });
    +
    +test('sample test 2', async () => {
    +  server.use(
    +    rest.get('/api/name', (_req, res, ctx) => {
    +      return res(ctx.json({ name: 'Jennifer' }));
    +    }),
    +  );
    +  render(<Greeting />);
    +  const greetingMessage = await screen.findByText('Hello, Jennifer!');
    +  expect(greetingMessage).toBeInTheDocument();
    +});
    

    追加したテストが通りません 😐
    コンソールに出力された DOM を見ると Hello, Jennifer! と表示されるべきところが Hello, Bob! と表示されています。デバッグを続けると、SWR のキャッシュによって引き起こされた不具合であることがわかりました。

    問題の原因と対策

    SWR のドキュメントで問題の原因が言及されていました。SWR はデフォルトでグルーバルなキャッシュを使うため、テストを跨いで同じキャッシュを参照してしまいます。対策として挙げられていたのが、<SWRConfig> というコンテキストでコンポーネントをラップする方法です。

    /components/Greeting.test.tsx
    @@ -2,6 +2,7 @@ import Greeting from './Greeting';
     import { server } from '@/mocks/server';
     import { render, screen } from '@testing-library/react';
     import { rest } from 'msw';
    +import { SWRConfig } from 'swr';
    
     test('sample test 1', async () => {
       render(<Greeting />);
    @@ -15,7 +16,11 @@ test('sample test 2', async () => {
           return res(ctx.json({ name: 'Jennifer' }));
         }),
       );
    -  render(<Greeting />);
    +  render(
    +    <SWRConfig value={{ provider: () => new Map() }}>
    +      <Greeting />
    +    </SWRConfig>,
    +  );
       const greetingMessage = await screen.findByText('Hello, Jennifer!');
       expect(greetingMessage).toBeInTheDocument();
     });
    

    これでテスト対象のコンポーネントがマウントされる度にキャッシュを提供する Map オブジェクトが初期化されるため、テスト単位でキャッシュはクリアされるようになりました。しかし、毎回ラッパーを使うのは面倒なので、Testing Library の custom render を作成することでキャッシュの問題を隠蔽します。

    /mocks/test-utils.tsx
    /* eslint-disable import/export */
    import { render } from '@testing-library/react';
    import { SWRConfig } from 'swr';
    
    import type { RenderOptions, RenderResult } from '@testing-library/react';
    
    type Props = {
      children: React.ReactNode;
    };
    
    const SWRConfigProvider = ({ children }: Props) => (
      <SWRConfig value={{ provider: () => new Map() }}>{children}</SWRConfig>
    );
    
    const customRender = (
      ui: React.ReactElement,
      options?: Omit<RenderOptions, 'wrapper'>,
    ): RenderResult => render(ui, { wrapper: SWRConfigProvider, ...options });
    
    export * from '@testing-library/react';
    
    export { customRender as render };
    

    加えてテストファイルが @testing-library/react ではなく custom render を参照するように変更しましょう。

    /components/Greeting.test.tsx
     import Greeting from './Greeting';
     import { server } from '@/mocks/server';
    -import { render, screen } from '@testing-library/react';
    +import { render, screen } from '@/mocks/test-utils';
     import { rest } from 'msw';
    
     test('sample test 1', async () => {
    

    おわりに

    SWR のキャッシュに対する理解が浅く、はじめは jest.setup.js 内でモックハンドラーをリセットしているのに、なぜキャッシュが消えないのかわかりませんでした。テストの独立性を維持するためにも、この挙動は意識しなければなりません。この記事の内容が何かの参考になれば幸いです。