884 Log

作成日: 2023年9月27日最終更新日: 2023年9月27日
プログラミング
React.js

外部でstateを管理しているコンポーネントのテストを@testing-library/reactを使って書く

はじめに

こんにちは〜

最近Reactで@testing-library/reactを使用してテストを書くことが増えて来たこの頃です。

ハマったこと

早速ですが、題名の通りハマったことを書いていきます・

コンポーネント内部で状態管理している場合は、そのままuserEventなりでイベントを発火させることでUIを変更することができるので、特に気にすることはないのですが、

コンポーネント外で管理しているstateをpropsで渡している場合においてちょっとハマりました...

うまくいかなかったテスト

import React, { useState } from "react";

interface Props {
 isOpen: boolean;
 onClose: boolean;
}

export const Modal = ({ isOpen, onClose }: Props) => {
  return (
    { isOpen && (<div>
      <button onClick={onClose}>閉じる</button>
       モーダルです
      </div>)
    }
  );
};
const renderModal = ({ isOpen, onClose = vi.fn() }: { isOpen: boolean; onClose?: () => void }) => {
  return render(
    <Modal isOpen={isOpen} onClose={onClose} />
  );
};

describe('', () => {
  it('ボタンを押すとモーダルが画面上から消えること', async () => {
    let isOpen = true;
    const onClose = vi.fn(() => {
      isOpen = false;
    });
    const user = userEvent.setup();
    renderModal({ isOpen, onClose });
    const button = screen.getByRole('button', { name: '' });
    await user.click(button);
    expect(screen.queryByText('モーダルです')).not.toBeVisible();
    expect(onClose).toHaveBeenCalled();
  });
});
const button = screen.getByRole('button', { name: '' });
await user.click(button);

の部分でモーダルのボタンをクリックすることで、コンポーネントに渡したonCloseが発火し、isOpenがfalseに変わる事によって、「モーダルです」の表示が消えるはずだったのですが、何度やっても消えてくれません。

何が問題だったか

結論としては、propsに渡している引数の状態を更新してもコンポーネントは再レンダリングしてくれません。

rerender関数を使用して、擬似的に再レンダリングさせてあげる必要がありました。

describe('', () => {
  it('ボタンを押すとモーダルが画面上から消えること', async () => {
    let isOpen = true;
    const onClose = vi.fn(() => {
      isOpen = false;
    });
    const user = userEvent.setup();
    const { rerender } = renderModal({ isOpen, onClose });
    const button = screen.getByRole('button', { name: '' });
    await user.click(button);
    rerender(<Modal isOpen={isOpen} onClose={onClose} />)
    expect(screen.queryByText('モーダルです')).not.toBeVisible();
    expect(onClose).toHaveBeenCalled();
  });
});

rerenderによって、コンポーネントがisOpenがfalseの状態で再レンダリングさせることができる様になり、外部でstateを管理していてもテストを記述することができるようになりました。

おまけ

renderの返り値に入っているrerender関数を愚直に実行してもいいのですが、以下のような便利関数を作ってその手間を減らすこともおすすめです。

setProps関数が実行されると、渡したpropsの情報とrender時に渡しているelementでReact.cloneElementして、引数を変えてrerenderしています。

import { RenderOptions, render as baseRender } from "@testing-library/react/pure";
import React from "react";
export * from "@testing-library/react";

export function render(element: JSX.Element, options?: Omit<RenderOptions, "queries">) {
  const result = baseRender(element, options);
  return {
    ...result,
    setProps<T extends Record<string, unknown>>(props: Partial<T>) {
      return result.rerender(React.cloneElement(element, props as T));
    },
  };
}

参考: https://github.com/reactjs/react-transition-group/blob/master/test/utils.js

これを作ることで、先程のコードでrerenderしたときに再度コンポーネントを渡す必要がなくなります!

改善後のコード

実際使ってみます。

import { render } from 'path/to/utils.ts' // 先程作ったrender関数を呼ぶ

const renderModal = ({ isOpen, onClose = vi.fn() }: { isOpen: boolean; onClose?: () => void }) => {
  return render(
    <Modal isOpen={isOpen} onClose={onClose} />
  );
};

describe('', () => {
  it('ボタンを押すとモーダルが画面上から消えること', async () => {
    let isOpen = true;
    const onClose = vi.fn(() => {
      isOpen = false;
    });
    const user = userEvent.setup();
    const { setProps } = renderModal({ isOpen, onClose }); // 先程定義したsetPropsが返り値に含まれるので使用する
    const button = screen.getByRole('button', { name: '' });
    await user.click(button);
    setProps({ isOpen, onClose })
    expect(screen.queryByText('モーダルです')).not.toBeVisible();
    expect(onClose).toHaveBeenCalled();
  });
});

renderModalの返り値にsetPropsが追加され、引数を渡すだけでコンポーネントを再描画してくれるようになりました!便利!

setProps({ isOpen, onClose })

最後に

駆け足になってしまいましたが、なんとかブログを更新することができました。

今後もReactのテスト頑張っていきたいです!