Reactを書いていてStorybookは便利だけどargsを作るのは面倒。Reactコンポーネントのpropsの構成や型が変わるたびにこれらを更新していくのがいかにも骨が折れそうだ。それにせっかくStorybookを使うなら、propsに渡すのは人工的なものではなく実地のもの、せめてアプリケーションが作ったものであってほしい。うまくコンポーネントの設計ができてれば問題はないのだけど、現実にありえないpropsを与えることができることはままあるから……。
そういうわけでPlaywrightを使ったE2EテストからReactコンポーネントのpropsを生成する方法を探る。
キレイなやり方を狙うなら React Devtools も利用する __REACT_DEVTOOLS_GLOBAL_HOOK__
を使いたいものだけど、これはまだそんなに使いやすいわけではない(将来に期待)。なので今回は汚いやり方でいく。
Playwrightを使うと書いたが、そもそもPlaywrightが以下のようにReactコンポーネントを特定する方法もわりと汚いので悪いことをしてる気分にはならないね。
await page.locator('_react=BookItem').click();
いつからかは知らないけど少なくともReact 18ではReactが生成したDOMには __reactFiber$648z12axv7
のようなプロパティが生えていて、ここを辿ると元のpropsが得られるっぽい(観察による)。
$0.__reactFiber$648z12axv7._debugOwner.memoizedProps
ということはこれをJSONにでもして保存してしまえば、Storybookから再利用できる生きたデータが得られるわけだ。
E2Eテストを走らせるたびにpropsファイルが更新されるのは嬉しくないので、Playwrightのsnapshot生成の仕組み(--update-snapshot
周辺)に乗っかることにする。ライフサイクル的には適してるだろう。Playwrightのテスト中、いまスナップショットをアップデートするモードかどうかは test.info()
.config.updateSnapshots
で知ることができる。
そういうわけでこういうユーティリティ関数をPlaywrightのテストから呼べるようにしてやる。
import { existsSync, writeFileSync } from "fs"; import { test, Page } from "@playwright/test"; async function recordReactComponentProps( page: Page, componentName: string, path: string ) { const mode = test.info().config.updateSnapshots; if (mode === "none") { return; } if (mode === "missing") { if (existsSync(path)) { return; } } const props = await page .locator(`_react=${componentName}`) .evaluate((el): unknown => { const reactFiberKey = Object.keys(el).find((k) => /^__reactFiber/.test(k) ); if (!reactFiberKey) { throw new Error("Could not find reactFiberKey"); } return el[reactFiberKey]?._debugOwner?.memoizedProps; }); writeFileSync(path, JSON.stringify(props, null, 2)); }
あとはテスト中、よい状態になったところで
await recordReactComponentProps( page, "MyComponent", "./src/components/MyComponent.storyprops.json" );
などとして保存して、Storybookから読み出せばいいだろう。更新したくなったら playwright test -u
すればいい。Reactのバージョンが上がったら壊れる可能性はぜんぜんあるが、仕組みが壊れてもスナップショット自体は有用なはず。