react-transition-groupのTransitionを利用したコンポーネントをテストするとき

24563 ワード

react-transition-group<Transition />を利用して、アニメーションしながら表示・非表示の状態が切り替わる要素の表示状態をJestTesting Library でテストしたとき、どのようにアニメーションを確実に完了させてからテストするかを確認したメモ。

状況としては、ボタンのクリックをトリガーにしてある要素の表示・非表示の切り替えをreact-transition-group<Transition />を利用して行っているようなケース。 entering: { opacity: 0 }のようにenteringの時点では不可視にしているとボタンクリック直後はアニメーションが完了していない状態になるので、そのままexpect(element).toBeVisible()としてもテストに失敗する。

ここで言及しているのは例えば以下のようなコンポーネント。

import { useState } from "react";
import Transition from "react-transition-group/Transition";

const duration = 1000;

const defaultStyle = {
  transition: `opacity ${duration}ms ease-in-out`,
  opacity: 0,
};

const transitionStyles = {
  entering: { opacity: 0 },
  entered: { opacity: 1 },
  exiting: { opacity: 0 },
  exited: { opacity: 0 },
};

export const FadeInOut = () => {
  const [inProp, setInProp] = useState(false);

  return (
    <div>
      <Transition
        in={inProp}
        timeout={duration}
        mountOnEnter={true}
        unmountOnExit={true}
      >
        {(state) => (
          <div
            style={{
              ...defaultStyle,
              ...transitionStyles[state as keyof typeof transitionStyles],
            }}
          >
            content
          </div>
        )}
      </Transition>

      <button type="button" onClick={() => setInProp((v) => !v)}>
        toggle
      </button>
    </div>
  );
};

ボタンをクリックしたときに表示・非表示の状態がTransitionでアニメーションして切り替わるように実装しているので、ただuserEvent.click(ボタン要素)の直後にexpect(テスト対象の要素).toBeVisible()としても可視状態になっていないのでテストは失敗する。

テスト時に時間の経過を待たないようにしたいということもあるけど、このようなケースで要素の表示状態をテストするにはどうするか。
選択肢としては、

  • react-transition-group のconfig.disabledを利用する
  • 偽のタイマーを利用する
  • <Transition />コンポーネント自体をモックする

などがありそう。

react-transition-group が v4.1.0 以上の場合、config.disabledが利用できる

v4.1.0 以上であればconfig.disabledenteringexistingの途中の遷移を無効にしてテストできるようになっている。 http://reactcommunity.org/react-transition-group/testing/ を見る限りはテスト用途で使われることを想定してのもののよう。

import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { config } from "react-transition-group";
import { FadeInOut } from "./FadeInOut";

describe("config.disabled true", () => {
  beforeAll(() => {
    config.disabled = true;
  });
  afterAll(() => {
    config.disabled = false;
  });

  it("should fade in", async () => {
    render(<FadeInOut />);
    const button = await screen.findByText("toggle");
    expect(button).toBeInTheDocument();

    expect(screen.queryByText("content")).not.toBeInTheDocument();

    userEvent.click(button);

    expect(screen.queryByText("content")).toBeVisible();
  });
});

これによってアニメーション途中のことは気にせずテストを書けるようになる。

jest.useFakeTimers

react-transition-group が v4.1.0 以上のバージョンではない状況でテストするケースもある。
Jest に限らずsetTimeoutをモックすることでテスト時にタイマーを進める形がある。

describe("jest fakeTimers", () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });
  afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  it("should fade in", async () => {
    render(<FadeInOut />);
    const button = await screen.findByText("toggle");
    expect(button).toBeInTheDocument();

    expect(screen.queryByText("content")).not.toBeInTheDocument();

    userEvent.click(button);
    act(() => {
      jest.advanceTimersByTime(100);
    });

    await waitFor(() => {
      expect(screen.queryByText("content")).toBeVisible();
    });
  });
});

beforeEachでテスト前にjest.useFakeTimers()を実行してタイマーを偽物に置き換えて、タイマーを進めたいときにjest.advanceTimersByTime(...)で指定ミリ秒進めらる。

テスト後、待機中のタイマーを実行してから本物のタイマーに戻しておかないと予期しない問題が起こり得るので、それを避けるためafterEachjest.runOnlyPendingTimers()を実行してからjest.useRealTimers()を実行する。

It's important to also call runOnlyPendingTimers before switching to real timers. This will ensure you flush all the pending timers before you switch to real timers. If you don't progress the timers and just switch to real timers, the scheduled tasks won't get executed and you'll get an unexpected behavior. This is mostly important for 3rd parties that schedule tasks without you being aware of it.