Storybook でインタラクションテストを作成して Jest で再利用する
Storybook の Component Story Format 3.0 では新機能として play()
関数が追加されました。
play()
関数は Storybook 上で ユーザーのクリックやフォーム入力のようなインタラクションな操作を表現することができます。
play()
関数の大きな特徴としては Component Story Format の移植性の高さを利用して Storybook 上で定義したインタラクションを Jest
など他の領域においても再利用できることです。
この記事では Vite に Storybook を導入してインタラクションテストを作成して Jest で再利用するまでの手順をやってみたいと思います。
Vite プロジェクトの作成
まずは以下コマンドで Vite プロジェクトを作成します。
npm init vite@latest my-vue-app -- --template vue-ts
cd my-vue-app
npm install
Storybook のインストール
続いて以下コマンドで Storybook の雛形を作成します。
なお、CSF3.0 を利用するには Storybook version が 6.4.0 以降である必要があります。
npx sb init
Storybook 内でインタラクションを実行できるようにするために以下のアドオンをインストールします。
npm i -D @storybook/addon-interactions @storybook/jest @storybook/testing-library
インストールが完了したら .storybook/main.js
にアドオンを追加します。
module.exports = {
addons: ['@storybook/addon-interactions'],
};
コンポーネントの作成
準備が整いましたので Storybook で描画する対象のコンポーネントを簡単に作成しましょう。作成するコンポーネントは以下の仕様を持つこととします。
- 新しいタスクを input 要素の入力できる
- 「追加」ボタンをクリックすると input 要素の入力をクリアして新たにタスクリストを表示する
まずは必要最低限の実装のみを行い Storybook 上で描画を確認できるようにします。
<template>
<form @submit.prevent>
<div class="form-group">
<label for="task-name">タスク名</label>
<input type="text" id="task-name" />
<button type="submit">追加</button>
</div>
</form>
</template>
Story の作成
それでは作成したコンポーネントの Story を作成して描画を確認してみましょう。
import type { Story, Meta } from '@storybook/vue3'
import TaskList from "./TaskList.vue";
export default {
title: "TaskList",
component: TaskList,
argTypes: {}
} as Meta;
const Template: Story = (args) => ({
components: { TaskList },
setup() {
return { args }
},
template: "<TaskList />",
});
export const Default = Template.bind({});
Default.args = {}
以下コマンドで Storybook を起動します。
npm run storybook
インタラクションを追加する
それでは本題のインタラクションを追加してみましょう。新たに InputFilled
というStory を追加して play
関数を定義します。
import { userEvent, within, waitFor } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
// 省略...
export const InputFilled = Template.bind({});
InputFilled.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText("タスク名"), "牛乳を買う");
await userEvent.click(canvas.getByRole('button', { name: '追加' }));
await waitFor(() => {
const items = canvas.getAllByRole('listitem');
expect(items.length).toBe(1);
})
}
play
関数は StoryContext
という型の引数を受け取り分割代入で canvasElement
を取得しています。この canvasElement
を使用することでコンポーネントのルートから実行を開始するようにインタラクションを調整することができます。@storybook/testing-library
からインポートした screen
オブジェクトを用いるとトップレベルの要素から要素を取得します。
前述の canvas
から対象の要素を取得して @storybook/testing-library
からインポートした useEvent
オブジェクトを利用して入力やクリックなどのイベントを発生させることができます。要素の取得方法は testing-library の Query とほぼ同じですので普段テストを記述しているときと変わらない感じで play
関数を記述できます。
さらに @storybook/jest
の expect
関数を用いることによって jest
さながらテストを記述することも可能です。
play
関数は Story がレンダリングされた後に実行されます。実際に Story を確認すると input 要素に play
関数でタイプした「牛乳を買う」という値が入力されています。
Interactions タブを確認してみると <li>
要素が存在しないためエラーが発生していることが確認できます。このように Storybook を通じてイベントが壊れていないかどうかを確認することができます。
インタラクションが成功するように実装を修正しましょう。
<script setup lang="ts">
import { ref } from 'vue'
const taskName = ref("");
const taskList = ref<string[]>([]);
const onSubmit = () => {
taskList.value.push(taskName.value);
taskName.value = "";
}
</script>
<template>
<form @submit.prevent=onSubmit"">
<div class="form-group">
<label for="task-name">タスク名</label>
<input type="text" id="task-name" v-model="taskName" />
<button type="submit">追加</button>
<ul>
<li v-for="task in taskList">{{ task }}</li>
</ul>
</div>
</form>
</template>
全てのインタラクションが成功していることが確認できます。
インタラクションをデバッグする
Storybook 上で UI の状態を巻き戻してデバッグすることができます。この機能を有効にするには .storybook/main.js
において features.interactionsDebugger
を true
にする必要があります。
module.exports = {
// ..
features: {
interactionsDebugger: true,
},
};
Storybook を再起動すると Interactions タブにデバッグバーが追加されいます。
Jest のインストール
Storybook 上でインタラクションを確認するのは人間による目視によるものですので Jest と組み合わせて自動でインタラクションをテストできるようにします。
まずは Jest
を実行するために必要なパッケージをインストールします。
npm -D install jest @types/jest ts-jest vue-jest@next @testing-library/vue@next @testing-library/jest-dom @storybook/testing-vue3
@storybook/testing-vue3 は Storybook で定義した story をテストにおいて再利用するためのパッケージです。
インストールが完了したら設定ファイルを作成します。
module.exports = {
moduleFileExtensions: ["js", "ts", "json", "vue"],
transform: {
"^.+\\.ts$": "ts-jest",
"^.+\\.vue$": "vue-jest",
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};
{
"compilerOptions": {
// ..
"types": ["@types/jest"],
},
}
さらに Storybook でグローバルデコレータを使用している場合には Jest 実行時においても適用させるためにセットアップファイルを用意します。
import { setGlobalConfig } from '@storybook/testing-vue3';
import * as globalStorybookConfig from './.storybook/preview';
setGlobalConfig(globalStorybookConfig);
jest
コマンド実行時に作成した setupFile.js
をセットアップファイルとして指定するようにします。
"scripts": {
"test": "jest --setupFilesAfterEnv ./setupFile.ts"
}
テストファイルの作成
それではテストファイルを作成しましょう。通常のコンポーネントのテストを書く時と異なりコンポーネントを直接インポートするのではなく、作成した Storybook のファイルから Story をインポートして composeStories
関数を使用することで作成した Story を Jest で再利用することができます。
対象の Story の play
関数を呼び出すことが Jest で改めてイベントを記述せずとも実行してくれます。
import { render } from '@testing-library/vue';
import { composeStories } from '@storybook/testing-vue3';
import * as Stories from './TaskList.stories';
const { InputFilled, Default } = composeStories(Stories)
test('タスクが存在しないときリストは表示されない', () => {
const { queryAllByRole } = render(Default())
expect(queryAllByRole('listitem').length).toBe(0)
})
test('タスク名を入力して追加ボタンをクリックするとリストに追加される', async () => {
const { container } = render(InputFilled());
await Stories.InputFilled.play?.({ canvasElement: container } as any);
});
テストを実行して、全てのテストが成功していることが確認できました。
npm run test
> my-vue-app@0.0.0 test
> jest --setupFilesAfterEnv ./setupFile.ts
PASS src/components/TaskList.spec.ts
✓ タスクが存在しないときリストは表示されない (55 ms)
✓ タスク名を入力して追加ボタンをクリックするとリストに追加される (109 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.637 s, estimated 10 s
Ran all test suites.
試しにボタン要素を削除してテストが失敗することも確認してみましょう。
<script setup lang="ts">
import { ref } from "vue";
const taskName = ref("");
const taskList = ref<string[]>([]);
const onSubmit = () => {
taskList.value.push(taskName.value);
taskName.value = "";
};
</script>
<template>
<form @submit.prevent="onSubmit">
<div class="form-group">
<label for="task-name">タスク名</label>
<input type="text" id="task-name" v-model="taskName" />
- <button type="submit">追加</button>
<ul>
<li v-for="task in taskList">{{ task }}</li>
</ul>
</div>
</form>
</template>
テストを実行すると想定通りにテストが失敗します。
npm run test
> my-vue-app@0.0.0 test
> jest --setupFilesAfterEnv ./setupFile.ts
FAIL src/components/TaskList.spec.ts
✓ タスクが存在しないときリストは表示されない (92 ms)
✕ タスク名を入力して追加ボタンをクリックするとリストに追加される (166 ms)
● タスク名を入力して追加ボタンをクリックするとリストに追加される
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "追加"
Here are the accessible roles:
textbox:
Name "タスク名":
<input
id="task-name"
type="text"
/>
--------------------------------------------------
list:
Name "":
<ul />
--------------------------------------------------
Ignored nodes: comments, <script />, <style />
<div>
<form>
<div
class="form-group"
>
<label
for="task-name"
>
タスク名
</label>
<input
id="task-name"
type="text"
/>
<ul>
</ul>
</div>
</form>
</div>
26 |
27 | await userEvent.type(canvas.getByLabelText("タスク名"), "牛乳を買う");
> 28 | await userEvent.click(canvas.getByRole('button', { name: '追加' }));
| ^
29 |
30 | await waitFor(() => {
31 | const items = canvas.getAllByRole('listitem');
at Object.getElementError (node_modules/@testing-library/dom/dist/config.js:38:19)
at node_modules/@testing-library/dom/dist/query-helpers.js:90:38
at node_modules/@testing-library/dom/dist/query-helpers.js:62:17
at node_modules/@testing-library/dom/dist/query-helpers.js:111:19
at Function.Object.<anonymous>.exports.InputFilled.play (src/components/TaskList.stories.ts:28:32)
at Object.<anonymous> (src/components/TaskList.spec.ts:14:3)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 6.032 s
Ran all test suites.
感想
Storybook 上でインタラクションをテストできるようになることで更に活用の幅を広げることができるようになりました。実際に描画内容を目視しながらテストをすることができるので testing-library でテストを記述するのと比べてより取り組みやすくなっていると思います。
Story を再利用可能なところも嬉しい点ですね。
サンプルコードは以下のレポジトリから確認できます。
参考
Discussion