はじめに
本記事では、Storybookのplay関数を使ってユーザーの動作をStorybook上で再現し、そのstoryを使ったインタラクティブなテストの書き方を説明します。
(ここでのインタラクティブとは、ユーザーの行う動作とその動作によってUIが変化する一連の流れを表します。)
通常のJavaScriptのテストの実装は、画面で確認できない状態でユーザーの動きをコード上で再現し、テストするという点で視覚的にわかり辛いです。
一方でStorybookを利用したテストの実装は、ユーザーの動きをブラウザ上で再現し、それをそのままテストに使うことができます。ブラウザで確認できる点で目で見てわかりやすく、とても有用的だと思っています。
今回はそんなテストの実装方法を説明するためにTodoListを用意しました。
そしてTodoListを使って、Storybookでplay関数を使用した動きのあるstoryを作成→storyを使ってインタラクティブなテストを作成するところまで実装しています。作成したTodoListはGitHub上で公開していますので、コードが気になる方は見てみてください。(Storybookやテストは実際に触ってみた方が理解しやすいと思います。)
TodoListの説明
用意したTodoListはシンプルな機能を持っています。
- Todoのチェック。
- Todoの追加。
- Todoの編集。
- Todoの削除。
今回はTodoの追加とTodoの編集について紹介します。(その他の機能についてもstoryとテストは実装しているので気になる方はクローンして触ってみてください)
コンポーネントのstoryを作成する
Storybookは公式ドキュメントに沿って導入します。
TodoListアプリのTodoList
コンポーネントのstoryを実装するために、TodoList.stories.ts
を作成しました。
以下は公式にも記載されているstoryを作成するための変数や型定義です。(おまじないです)
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型で型定義します。
// const meta = {
// ~~おまじない~~
// type Story = StoryObj<typeof meta>;
export const Default: Story = {}; // Defaultのstory
これでTodoList
コンポーネントをStorybookで表示できました。
それではTodoを追加する・Todoを編集する機能についてstoryを作成していきます。
Todoを追加するstory
Todoを追加するためにはユーザーは以下の動作が必要です。
- テキストボックスに追加するTodoの文字列を入力する。
- Add Todoのボタンをクリックする。
このユーザーの動作をstory上で再現するためにStorybookのplay関数を使用します。
play関数を使用するために公式ドキュメントに沿っていくつかライブラリをインストールします。
テストに利用するためのstory作成なので、@storybook/testing-library
というTesting LibraryをStorybookで使用するためのライブラリをインストールしています。
それではTodoを追加するAddTodo
storyを作成します。
以下の変数AddTodo
のように、Storybookのplay関数はstoryのプロパティの一つとして設定します。valueにはasync関数で非同期処理を実装することができます。
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が追加されるようになりました。
ではplay関数の中身を説明します。
ここでは動作を再現するために、@storybook/testing-library
のwithin
とuserEvent
を使用しています。
play関数の引数のcanvasElement
にはそのstoryに描画するHTMLElementが入ります。そこでimportしたwithin
にcanvasElement
を渡すことでそのstoryのHTMLElementを操作できるようになります。
const canvas = within(canvasElement);
続いてuseEvent
を使用します。ここではtype()
によってユーザーの入力を、click()
によってユーザーのクリックを再現しています。
await userEvent.type(canvas.getByRole("textbox"), "勉強する"); // テキストボックスに文字を入力
await userEvent.click(canvas.getByRole("button", { name: "Add Todo" })); // Add Todoをクリック
それぞれの引数にはcanvas.getByRole()
が使われ、storyから対象のHTMLElementを取得しています。(getByRoleは対象のHTMLElementからroleで絞り込んで取得します。詳細は公式を参照ください。)
この一連の動作は順に行われるようにawaitを先頭に付けています。
このようにStorybookのplay関数を用いることで、ユーザーの動作を再現したstoryを作成することができました。次にもうひとつ、Todoを編集するstoryを作成します。
Todoを編集するstory
Todoを編集するためには以下の動作が必要です。
- 編集したいTodoのEditボタンをクリックする。
- 編集用のテキストボックスに文字列を入力して編集する。
- Doneボタンをクリックする。
この順番で動作させるためには以下のようにplay関数を実装します。
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ではこのように動作します。
EditTodo
ではAddTodo
にはなかったfireEvent
を使用しています。
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を使って実装したテストの全体像になります。
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を使うための関数に渡しています。
import { composeStories } from "@storybook/react";
import * as stories from "@/stories/TodoList.stories";
const { Default } = composeStories(stories);
@testing-library/jest-domというライブラリをimportしていますが、これはコンポーネントをテストするときに、便利なカスタムマッチャーと呼ばれるメソッド(toBeInTheDocument()
など)を使えるようにするためにimportしています。
import "@testing-library/jest-dom";
テストで使っているコンポーネントの中のElementをテストするには、render()
とscreen()
を使います。
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で既に「ユーザーの動作」は再現できているので、その動作を行なった時に期待する値が取得できるかテストするだけです。
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を編集するテストは以下のように実装しました。
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関数を使ったことない方はぜひ触ってみてください。