Next.jsのプロジェクトでStorybookのPlay functionをJestで再利用する

この記事はニジボックスQiita記事投稿リレーの4日目の記事です🌈

StorybookのPlay functionを利用することでユーザーインタラクション後のコンポーネントの状態をStorybookで表示することができます。
各Storyに定義したPlay functionをJest側も利用することができ、これによってユーザーの挙動を模したシナリオをJestで実行し、その結果をassertできます。

最近、Next.jsを使っているプロジェクトにこのテストを導入したので、その方法を記事にしてみようと思います。

## 環境
- Next.js v13.4.7
- React v18.2.0
- Storybook v7.0.26

## 前準備

`Button` というサンプルコンポーネントを用意しました。（サンプルコードなのであまり細かいところはお気になさらず...)

```Button/index.tsx
import { useState } from "react";
import styles from "./index.module.css";

export function Button() {
  const [isClicked, setIsClicked] = useState(false);

  return (
    <>
      {isClicked && <div onClick={() => setIsClicked(false)}>Clicked!</div>}
      <button
        onClick={() => setIsClicked(true)}
        className={styles.button}
        role="button"
      >
        Click Me!
      </button>
    </>
  );
}

```

`button` 要素をクリックすると、`Clicked!`というテキストが表示され、そのテキストをクリックするとテキストは非表示になる、という振る舞いをします。

次にこのコンポーネントのStoriesファイルを用意します。
```Button/index.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";

import { Button } from ".";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Normal: Story = {};

```

Storybookで表示できることが確認できました。
![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889717/41430e2a-9480-1a50-1074-dc559ad8464e.gif)

## Play functionを実装する

次に本題の `Play function` を実装していきます。
先ほど作成した `index.stories.tsx`にbutton要素をクリックするというPlay functionを持ったStoryを追加します。

```Button/index.stories.tsx
export const ClickButton: Story = {};
ClickButton.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement);
  const button = canvas.getByRole("button");
  await userEvent.click(button);
};
```

これでクリック後の　`Button` Componentの状態を描画できます。
![スクリーンショット 2023-07-10 10.02.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889717/04327700-d277-ce9b-2544-3f0a0c4ea66e.png)

## JestでPlay functionを再利用する
先ほど実装した Play function を使って、Jest側でassertをします。

少し本題から外れますが、Next.jsを使っているプロジェクトでは `next/jest` を使うと簡単にJestの設定ができます。

CSS in JSライブラリなどを利用していると、コンパイルのためにJestのconfigに設定が必要なのですが、Next.jsに設定してあれば、それを引き回して利用することができます。

https://nextjs.org/docs/pages/building-your-application/optimizing/testing#jest-and-react-testing-library

https://nextjs.org/docs/architecture/nextjs-compiler#emotion

### JestでPlay functionを呼び出し、結果をassertする
話を戻して、 Play function をJestから呼び出して、その結果をassertするテストコードを書きます。

```Button/index.test.tsx
import { composeStories } from "@storybook/react";
import { act, findByText, render } from "@testing-library/react";
import * as ButtonStories from "./index.stories";
import "@testing-library/jest-dom";

const { ClickButton } = composeStories(ButtonStories);

describe("Button component", () => {
  it("should display clicked text after clicked", async () => {
    const { container } = render(<ClickButton />);
    await act(async () => {
      await ClickButton.play({ canvasElement: container });
    });
    const clickedText = await findByText(container, "Clicked!");
    expect(clickedText).toBeVisible();
  });
});

```

コードそのままですが、`@testing-library/react` でrenderしたStoryComponentのHTMLElementをテスト対象の`play` 関数の引数に渡して実行し、その結果（`Clicked!`というテキストが表示されていること）を `expect(clickedText).toBeVisible();`でassertしています。

とてもシンプルな例ですが、これでPlay functionをjestから呼び出すことができました。

## 最後に（業務で工夫した話）
これだけだと少し寂しいので、業務で少し工夫した点について書いて終わりにしたいと思います。

例えば、ボタンクリックしてから2秒後に要素を表示する、というように先ほどのComponentに仕様変更が入ったとします。
これをそのまま実行してしまうと、当たり前ですがJestでは２秒後の要素の描画を待たずにassertしてしまうので、テストは失敗します。
この例のようにシンプルであれば、2秒待つ、という処理をテスト側に仕込んでも良いかも知れませんが、もう少し複雑なComponentをテストするとなると、単純にn秒待つ、という処理ではテストがflakyになってしまうことを危惧しました。

幸いJestには `useFakeTimers` という`setTimeout`や`setInterval`のタイマーをmockできる関数が有り、今回はこちらを利用しました。

https://jestjs.io/ja/docs/timer-mocks

```index.test.tsx
describe("Button component", () => {
  beforeEach(() => {
    // 各テストケース実行前にタイマーをモックする
    jest.useFakeTimers();
  });

  afterEach(() => {
    // 各テストケース実行後にタイマーを通常動作に戻す
    jest.useRealTimers();
  });
  it("should display clicked text after clicked", async () => {
    const { container } = render(<ClickButton />);
    await act(async () => {
      await ClickButton.play({ canvasElement: container });
      jest.advanceTimersByTime(2000); // ここで2秒待っている
    });
    const clickedText = await findByText(container, "Clicked!");
    expect(clickedText).toBeVisible();
  });
});
```

以上、Next.jsのプロジェクトでStorybookのPlay functionをJest側で再利用する方法についてまとめてみました。

::: note
この記事はニジボックス投稿リレー企画、4日目の記事です。
次の投稿は@kalbeekatzさんです！
:::