Storybookのplay関数とTesting Libraryを使ったインタラクティブなテストの書き方

# はじめに
本記事では、**Storybookのplay関数**を使ってユーザーの動作をStorybook上で再現し、そのstoryを使った**インタラクティブなテスト**の書き方を説明します。
（ここでのインタラクティブとは、ユーザーの行う動作とその動作によってUIが変化する一連の流れを表します。）

通常のJavaScriptのテストの実装は、画面で確認できない状態でユーザーの動きをコード上で再現し、テストするという点で視覚的にわかり辛いです。
一方でStorybookを利用したテストの実装は、ユーザーの動きをブラウザ上で再現し、それをそのままテストに使うことができます。ブラウザで確認できる点で目で見てわかりやすく、とても有用的だと思っています。

今回はそんなテストの実装方法を説明するためにTodoListを用意しました。
そしてTodoListを使って、Storybookでplay関数を使用した動きのあるstoryを作成→storyを使ってインタラクティブなテストを作成するところまで実装しています。作成したTodoListは[GitHub上で公開](https://github.com/26krkz/todolist "TodoListのリポジトリ")していますので、コードが気になる方は見てみてください。（Storybookやテストは実際に触ってみた方が理解しやすいと思います。）
# TodoListの説明
こちらが用意したTodoListの全体です。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/80c12781-a50c-47df-5c10-bf0a9366b0c5.png">

用意したTodoListはシンプルな機能を持っています。

- Todoのチェック。
- Todoの追加。
- Todoの編集。
- Todoの削除。

今回は**Todoの追加**と**Todoの編集**について紹介します。（その他の機能についてもstoryとテストは実装しているので気になる方はクローンして触ってみてください）

# コンポーネントのstoryを作成する
Storybookは[公式ドキュメント](https://storybook.js.org/docs/react/get-started/install/ "Storybookの公式ドキュメント")に沿って導入します。
TodoListアプリの``TodoList``コンポーネントのstoryを実装するために、``TodoList.stories.ts``を作成しました。
以下は公式にも記載されているstoryを作成するための変数や型定義です。（おまじないです）
```typescript:TodoList.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { TodoList } from "@/components/TodoList";

const meta = {
  title: "TodoList",
  component: TodoList,
  parameters: {
    layout: "centered",
  },
} satisfies Meta<typeof TodoList>;

export default meta;
type Story = StoryObj<typeof meta>;
```
続いて``Default``のstoryを作成します。storyには用意したStory型で型定義します。

```typescript:TodoList.stories.ts
// const meta = {
// ~~おまじない~~
// type Story = StoryObj<typeof meta>;

export const Default: Story = {}; // Defaultのstory
```
これで``TodoList``コンポーネントをStorybookで表示できました。
<img width="550" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/53c61498-5908-d10f-3371-8a8e78ac0983.png">
それではTodoを追加する・Todoを編集する機能についてstoryを作成していきます。

## Todoを追加するstory

Todoを追加するためにはユーザーは以下の動作が必要です。
1. テキストボックスに追加するTodoの文字列を入力する。
2. Add Todoのボタンをクリックする。

このユーザーの動作をstory上で再現するためにStorybookのplay関数を使用します。
play関数を使用するために[公式ドキュメント](https://storybook.js.org/docs/react/writing-stories/play-function "Storybookの公式ドキュメント")に沿っていくつかライブラリをインストールします。
テストに利用するためのstory作成なので、``@storybook/testing-library``という[Testing Library](https://testing-library.com/ "testing-libraryの公式")をStorybookで使用するためのライブラリをインストールしています。

それではTodoを追加する``AddTodo``storyを作成します。
以下の変数``AddTodo``のように、Storybookのplay関数はstoryのプロパティの一つとして設定します。valueにはasync関数で非同期処理を実装することができます。

```typescript:TodoList.stories.ts
import { within, userEvent } from "@storybook/testing-library"; // ←追加

// const meta = {
// ~~おまじない~~
// type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const AddTodo: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByRole("textbox"), "勉強する"); // テキストボックスに文字を入力
    await userEvent.click(canvas.getByRole("button", { name: "Add Todo" })); // Add Todoをクリック
  },
};
```
``AddTodo``にplay関数を実装することで、このstoryでは、自動でTodoが追加されるようになりました。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/8e01a68e-f33a-5c1d-4453-1d30a1900faf.gif">

ではplay関数の中身を説明します。
ここでは動作を再現するために、``@storybook/testing-library``の``within``と``userEvent``を使用しています。

play関数の引数の``canvasElement``にはそのstoryに描画するHTMLElementが入ります。そこでimportした``within``に``canvasElement``を渡すことでそのstoryのHTMLElementを操作できるようになります。
```typescript
const canvas = within(canvasElement);
```
続いて``useEvent``を使用します。ここでは``type()``によって**ユーザーの入力**を、``click()``によって**ユーザーのクリック**を再現しています。
```typescript
await userEvent.type(canvas.getByRole("textbox"), "勉強する"); // テキストボックスに文字を入力
await userEvent.click(canvas.getByRole("button", { name: "Add Todo" })); // Add Todoをクリック
```
それぞれの引数には``canvas.getByRole()``が使われ、storyから対象のHTMLElementを取得しています。（getByRoleは対象のHTMLElementからroleで絞り込んで取得します。詳細は[公式](https://testing-library.com/docs/queries/byrole/)を参照ください。）
この一連の動作は順に行われるようにawaitを先頭に付けています。

このようにStorybookのplay関数を用いることで、ユーザーの動作を再現したstoryを作成することができました。次にもうひとつ、Todoを編集するstoryを作成します。

## Todoを編集するstory
Todoを編集するためには以下の動作が必要です。
1. 編集したいTodoのEditボタンをクリックする。
2. 編集用のテキストボックスに文字列を入力して編集する。
3. Doneボタンをクリックする。

この順番で動作させるためには以下のようにplay関数を実装します。
```typescript: TodoList.stories.ts
import { within, userEvent, fireEvent } from "@storybook/testing-library";

// const meta = {
// ~~おまじない~~
// type Story = StoryObj<typeof meta>;

export const Default: Story = {};
export const AddTodo: Story = {...};

export const EditTodo: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const editButton = canvas.getAllByRole("button", { name: "Edit" })[0];
    await userEvent.click(editButton); // Editボタンをクリック
    const textbox = canvas.getByDisplayValue("買い物に行く");
    fireEvent.change(textbox, { target: { value: "勉強する" } }); // 修正文字列を入力
    await userEvent.click(canvas.getByRole("button", { name: "Done" })); // Doneボタンをクリック
  },
};
```
上記のようにplay関数を実装することで、``EditTodo``storyではこのように動作します。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/c1f2116b-7ec3-690e-192e-836d7c9ed789.gif">
``EditTodo``では``AddTodo``にはなかった``fireEvent``を使用しています。
```typescript
await userEvent.click(editButton); // Editボタンをクリック
const textbox = canvas.getByDisplayValue("買い物に行く");
fireEvent.change(textbox, { target: { value: "勉強する" } }); // 修正文字列を入力
await userEvent.click(canvas.getByRole("button", { name: "Done" })); // Doneボタンをクリック
```
``fireEvent``と``userEvent``の違いですが、``fireEvent``は「DOMイベントを発火させるため」の関数で、``userEvent``は「ユーザーが実際に行う操作を表現するため」の関数です。``fireEvent``と``userEvent``のどちらでも実装できるという場合がありますが、公式では「ユーザーの動作をテストする場合には、できるだけ``userEvent``を使うことが推奨する」と記載されています。（ただ今回はテキストボックスの文字列を変更するのに``fireEvent``の方が実装しやすかったためこのように実装しています。）

これで、テストに使用するためのstoryの作成が完了しました。
次項ではメインである、Storybookのplay関数を使用したインタラクティブなテストを実装していきます。

# play関数を使ってテストを作成する
テストを書くための準備ができたので次にテストを実装していきます。
play関数を使ったテストを紹介する前に``Default``storyをベースに、Storybookを取り入れた一般的なReactコンポーネントテストの実装方法を説明します。
## Storybookを使ったテストの書き方
こちらが``Default``storyを使って実装したテストの全体像になります。
```typescript:TodoList.test.tsx
import { render, screen } from "@testing-library/react";
import { composeStories } from "@storybook/react";
import * as stories from "@/stories/TodoList.stories";
import "@testing-library/jest-dom";

const { Default } = composeStories(stories);

describe("TodoList.tsxのテスト", () => {
  test("TodoListにTodoが表示される。", () => {
    render(<Default />);
    expect(screen.getAllByRole("listitem")[0]).toBeInTheDocument();
  });
}
```
``TodoList.stories.tsx``からimportしたstoryは``composeStories``というテストでstoryを使うための関数に渡しています。
```typescript:TodoList.test.tsx
import { composeStories } from "@storybook/react";
import * as stories from "@/stories/TodoList.stories";

const { Default } = composeStories(stories);
```
@testing-library/jest-domというライブラリをimportしていますが、これはコンポーネントをテストするときに、便利なカスタムマッチャーと呼ばれるメソッド（``toBeInTheDocument()``など）を使えるようにするためにimportしています。
```typescript
import "@testing-library/jest-dom";
```

テストで使っているコンポーネントの中のElementをテストするには、``render()``と``screen()``を使います。
```typescript:TodoList.test.tsx
import { render, screen } from "@testing-library/react";

describe("TodoList.tsxのテスト", () => {
  test("TodoListにTodoが表示される。", () => {
    render(<Default />);
    expect(screen.getAllByRole("listitem")[0]).toBeInTheDocument();
  });
}
```
testの中身ですが、まず``Default``コンポーネントをレンダリング（``render()``）し、次にその画面からElementにアクセスして（``screen.getAllByRole()``)テストしています。
これで``Default``のstoryはテストできました。
続いてplay関数を使ったstoryをテストしていきます。

## Todoを追加するテスト
Todoを追加する動作をテストするには以下のように実装します。
storyで既に「ユーザーの動作」は再現できているので、その動作を行なった時に期待する値が取得できるかテストするだけです。
```typescript:TodoList.test.tsx
test("フォームにテキストを入れて「Add Todo」ボタンをクリックするとTodoが追加される。", async () => {
  const { container } = render(<AddTodo />);
  expect(screen.getAllByRole("listitem")).toHaveLength(3); // 初期表示のTodoの個数を確認
  await AddTodo.play({ canvasElement: container }); // play関数を実行。
  expect(screen.getAllByRole("listitem")).toHaveLength(4); // Todoが4個になっていることを確認
});
```
ここでの注目すべき点は、``AddTodo.play()``です。storyで作成したplay関数は各storyのプロパティとして参照できます。``Story.play()``を実行するだけでstoryに登録されたplay関数（ここではTodoを追加する動作）を実行することができます。たったこれだけでテスト内で再現したい動作を実装することができます。
初見の人でもplay関数の存在を知っていれば、
「storyで作成したplay関数を使って特定の動作を実行してるんだな。」と容易にテスト内容を理解でき、実際のstoryの方で動きを確認することもできます。
ではもう一つのTodoを編集するテストを見ていきましょう。

## Todoを編集するテスト
Todoを編集するテストは以下のように実装しました。
```typescript:TodoList.test.tsx
test("TodoListの「Edit」ボタンをクリックするとTodoが編集できる", async () => {
  const { container } = render(<EditTodo />);
  expect(screen.getByRole("checkbox", { name: "買い物に行く" })).toBeInTheDocument(); // 初期表示のTodoを確認
  await EditTodo.play({ canvasElement: container }); // play関数の実行
  expect(screen.getByRole("checkbox", { name: "勉強する" })).toBeInTheDocument()); // 編集後のTodoを確認
});
```
こちらのテストでもTodoを追加するテストと同様に、まずはコンポーネント上に「買い物に行く」という編集前のTodoがあることを確認し、次にplay関数を実行、最後にplay関数を実行後編集したTodoがコンポーネント上に存在することを確認しています。

このように、storybook側のplay関数であらかじめ動作を含めたstoryを作成することで、ちょっと複雑なユーザーの動きをテスト上で再現することなく、シンプルなテストコードにまとめることができました。

以上がStorybookのplay関数とTesting Libraryを使ったインタラクティブなテストの書き方になります。

# さいごに
Storybookのplay関数を使ったテストの実装方法は、テストを見た時に使われているstoryを参照し、実際の動作を目で確認することができる、そしてこのテスト項目が何をテストしているのか容易に理解できることが最大のメリットだと思います。

個人的には、テスト側でユーザーの動作を再現するよりも、Storybook側で先にユーザーの動作を再現する方がブラウザで表示して目で見て確認しながら実装できるのでコードを書き易かったです。まだplay関数を使ったことない方はぜひ触ってみてください。