- バックエンド / リーダー候補
- Project Manager
- Webエンジニア(シニア)
- Other occupations (19)
- Development
- Business
Storyshotsを止めてスナップショットテストの仕組みを自作した話
Photo by Iwan Shimko on Unsplash
Storybook はWebフロントエンド向けのUIモックアップのフレームワークです。
あらかじめUIコンポーネントをデータストア非依存で作っておけば、そのモックアップを作ることができます。(モックアップといっても、バックエンドに繋がっていないだけで、ユーザーの操作には反応するというものを考えています。) Storybookでは、このようなモックアップのことをStoryと呼んでいます。Storyがあらわすコンポーネントの粒度もまちまちで、汎用のボタン1つに対するStoryもあれば、ページ全体に対するStoryも考えられます。
モックアップを動かしながら作業することで、余計な手間をかけずにUIの動作確認をできるというのがStorybookの基本的な使い方です。
Storyshotsとは
Storyshots はStorybookをユニットテストで利用する方法のひとつです。
Storyshotsは、プロジェクト内にあるStoryを全て収集し、それぞれをオンメモリのレンダラー (Reactであればreact-test-rendererやjsdomなど) で実行します。実行して得られたDOMツリーを文字列に起こし、前回の実行結果と相違ないことを確認するという方法です。
Storyshotsの問題 (1) 偽陽性と偽陰性
Storyshotsの良いところは何といっても、追加コストがほぼかからないところです。もちろんStory (UIモックアップ) を用意する必要はありますが、それさえあればテスト自体には追加のコストをかけなくてもよいというのは、テストカバレッジを確保するための最初の手段としては有効です。少なくとも以下のような点ではStoryshotsは有益です。
- 初回描画が成功する、という最低限の性質は少なくとも担保される。 (いわゆるスモークテスト)
- コードの変更によって意図せず動作が変わった場合、DOMのスナップショットが変わることで問題に気付ける可能性がある。
一方、Storyshotsによって提供されるテストには、実際に保証したい性質とのずれも見られます。
Storyshotsの偽陽性
UIモックアップに対するテストでは、最終的にユーザーが目にするインタラクションや見た目が期待通りであることを保証したいのに対し、Storyshotsが保証するのは DOM (HTML) 出力までです。
自動テストでテストしたい性質と実際にテストする内容を完全に一致させることはできませんが、この差異が大きいとテストとしての有効性が低下し、コストに見合わなくなる可能性があります。
たとえば、UIコンポーネントライブラリをリファクタリングして、ボタン内でのテキストの折り返しの挙動を変更したとします。このとき以下のような結果になることがあります。
- ボタンを利用する全ての箇所でCSSやDOMのスナップショットに差分が発生する。
- しかし、実際の見た目が変わるのはごく一部 (テキストが長くなる箇所) だけ。
この場合、実際のユーザーへの影響を考えると、後者のように見た目が変わった箇所がどこであるかを知るほうが有益であると考えられます。
Storyshotsの偽陰性
逆に、本来チェックするべき内容がStoryshotsだけではテストできないという側面もあります。
Storyshots自体はUIに対する操作 (クリック、入力など) をしないので、何らかの操作をしてはじめて通る実行経路はそのままではテストされません。
DOMスナップショットに代わる方法
このように、DOMスナップショットを使ったは最低限の動作保証を提供する一方、アプリケーションを安心してリファクタリングするのに十分とは言えません。
実は、StoryshotsはあくまでStorybookを使ったテスト方法のひとつにすぎません。むしろ次に挙げるようなテストが書ける点こそがStorybookの強みだと言えるでしょう。
- ひとつはユーザーの行動をHTMLのアクセシビリティー仕様に沿って記述し、正常に操作を完了することを確認するというものです。
- これはStoryのインポート機能を使ってテスト環境内でStoryを呼び出し、testing-libraryというテストフレームワークでテストを記述することで実現できます。
- さらに、このようにして記述されたユーザーの行動自体をUIモックアップの一部として取り込む、Interactionsという機能もあります。Interactionはテスト環境ではInteraction testingとして実行できるほか、VRTや実際のWebブラウザでの動作確認にも使えます。
- もうひとつはヘッドレスブラウザでスクリーンショットを撮り、見た目が変わっていないことを確認するというものです。これはVRT (Visual regression tests) と呼ばれています。
これらのテストを組み合わせれば、DOMのスナップショットを撮るよりも有意義な検査を実現できるでしょう。ただ、特にインタラクションのテストを書くにはそれなりのコストが必要で、すでに大量にあるStoryのテストを一朝一夕にtesting-libraryに移行できるわけではありません。
Storyshotsの問題 (2) 並列性
StoryshotsはテストフレームワークであるJestと組み合わせて使います。そのJestは以下のように2種類の並列性を提供しています。
- プロセス並列性。Jestはデフォルトで複数のワーカープロセスを立ち上げ、テストの実行をそれらのワーカーに移譲します。これはファイル単位で行われます。
- --runInBand, --maxWorkers で制御できます。
- タスク並列性。Jestのワーカーはデフォルトではテストケースを逐次的に実行しますが、非同期テストケースが `test.concurrent` のように並行実行可能であると明示されているときは、先行するテストケースの完了を待たずに次のテストケースを開始します。これはテストケース単位で行われます。
- --maxConcurrency で制御できます。
ところが、Storyshotsは1つのテストファイル内で全てのテストケースを生成するという使い方が想定されています。そのため、Storyshotsの実行は1つのワーカーに偏って割り振られてしまい、テスト時間の短縮効果が薄れてしまいます。
Storyshotsの問題 (3) Native ESMとの相性問題
Storyshotsは内部でNode.jsやJestのモジュール関連APIを呼んでいます。
Node.jsやJestはCommonJSとES Modulesの両方をサポートしていますが、これは内部的には2種類の異なる実装があるような状態になっていて、そのためAPIもCommonJS用とES Modules用で実質的に別になっています。
StoryshotsはCommonJS用のAPIに依存しているので、プロジェクトがNode.jsのNative ESMを有効にしている場合はうまく動きません。
Storyshotsをやめる
これらの問題を解決するために、WantedlyではStoryshotsをやめることにしました。
その際、testing-libraryとVRTの組み合わせでテスト対象全体をカバーできるのが理想ですが、コスト観点を考慮するとDOMスナップショットという選択肢も残しておくのが望ましいです。またそもそも移行コストの問題で、一気に移行するのは困難です。そこで、いきなりtesting-library + VRTに移行するのではなく、まずはStoryshotsと同じことを自力で行うことにしました。
以降ではUIフレームワークとしてReactを使っていることを仮定して説明します。
テストファイルを配置する
まず、Storyが書かれたファイルごとに、以下のようなテストファイルを配置します。
// MyComponent.test.tsx
import React from "react";
import { render } from "@testing-library/react";
import { composeStories } from "@storybook/testing-react";
import * as stories from "./MyComponent.stories";
const { ...otherStories } = composeStories(stories);
describe("TextArea", () => {
const testCases = Object.values(otherStories).map((Story) => [Story.storyName, Story]);
test.each(testCases)("renders %s", (_storyName, Story) => {
const tree = render(<Story />);
expect(tree.baseElement).toMatchSnapshot();
});
});
これがやっていることは簡単です。
- Storybookのインポート機能 (@storybook/testing-react) を使って、Storyをテストコードから呼び出せるようにする。
- ファイル内にStoryが複数存在する場合は、Storyごとにテストケースを自動生成する。
- testing-libraryを使って各Storyをレンダーする。特にインタラクションは行わない。
- 生成されたDOMを文字列に起こして、前回の結果と比較する。
これはStoryshotsが行っていることと非常によく似ています。ただ、StoryshotsのReactサポートではデフォルトでreact-test-rendererを使っているのに対して、今回はtesting-libraryをレンダリングに使っているという点は注目に値します。
- react-test-rendererはあくまで仮想DOMの範囲内で仮想DOMツリーを作り、それを出力します。そのため、実際のDOMにまつわる操作は基本的にできず、モックして対応することになります。
- 一方、testing-libraryは実際のブラウザで使われるreact-domを呼び出します。react-domは実際のWebブラウザのDOM APIのかわりに、Node.js内で動作するjsdomというモックAPIを呼び出します。jsdomはDOM APIのかなりの部分を再現しているので、react-test-rendererに比べると多くの操作が正しく動作します。
テストファイルの配置を自動化する
Storyshotsではテストファイルをいちいち作らず、毎回Storyの一覧を検出しますが、今回の方法ではテストファイルをわざわざ生成しています。生成したテストファイルはgitにコミットし、プログラマーが自分で編集してOKという形式をとっています。 (この理由は後述します)
ただ、これだと新しいStoryファイルが増えたときにStoryshotsと違って自動的にテストが増えないので、全てのStoryファイルにテストファイルが付随していることを強制する仕組みを入れます。
それが以下のコードです。
import path from "path";
import glob from "glob";
import util from "node:util";
import fs from "node:fs";
test("each story file has a corresponding test file", async () => {
const cwd = path.resolve(__dirname, "../../..");
const storyPaths = await util.promisify(glob)("frontend/**/*.stories.tsx", { cwd });
const missingTests: string[] = [];
for (const storyPath of storyPaths) {
const testPath = storyPath.replace(/\.stories\.tsx$/, ".test.tsx");
if (!fs.existsSync(testPath)) {
const name = path.basename(storyPath, ".stories.tsx");
const content = `import React from "react";
import { render } from "@testing-library/react";
import { composeStories } from "@storybook/testing-react";
import * as stories from "./${name}.stories";
const {
...otherStories
} = composeStories(stories);
describe("${name}", () => {
const testCases = Object.values(otherStories).map((Story) => [Story.storyName, Story]);
test.each(testCases)("renders %s", (_storyName, Story) => {
const tree = render(<Story />);
expect(tree.baseElement).toMatchSnapshot();
});
});
`;
if (!process.env.CI) {
await fs.promises.writeFile(testPath, content, "utf-8");
}
missingTests.push(testPath);
}
}
if (missingTests.length > 0) {
if (process.env.CI) {
throw new Error(`Test file does not exist:\n${missingTests.join("\n")}`);
} else {
throw new Error(`Generated missing test files, please rerun:\n${missingTests.join("\n")}`);
}
}
});
これ自体がテストになっているので、Jestを実行したときに自動的にテストファイルが増えるようになっています。
もちろん、最初にテストファイルを配置するときも実際にはこのコードで自動化して作業しました。
テストを漸進的に置き換える
先ほど掲載したテストコードには意図的に以下のようなobject spreadを残していました。
const { ...otherStories } = composeStories(stories);
これは、テストを漸進的に置き換えるための仕組みです。
自動的にスナップショットテストが行われるのはあくまで移行措置にすぎないので、できるだけスナップショットテストを使わずにtesting-libraryを使ったよりよいテストを書けるようにこのようにしています。手動でテストを書くときは
const {
// この2つは手動でテストを書く
MyStory1,
MyStory2,
...otherStories
} = composeStories(stories);
としてobject spreadから取り出します。そうすると、既定で生成されていたスナップショットテストは消滅するというわけです。
まとめ
- Storyshotsを使うとStorybookで作ったStory (UIモックアップ) に対するスナップショットテストを自動で行える。
- しかし、Storyshotsのスナップショットテストはfalse positiveが多く、Storybookが提供する他のテスト (testing-libraryやVRT) を書く余裕があればそちらで置き換えるのが望ましい。
- また、Jestの並列化を妨げる問題や、Native ESMとの相性問題もある。
- これらの問題を解決するため、Storyshotsの仕組みを解体し、漸進的にテストを移行できる環境を整備した。