こんにちは、FAANS部の田中です。普段は、WebのフロントエンドエンジニアとしてFAANSの開発を行なっています。
FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、WEARと連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。プラットフォームとしてはWeb、iOS、Androidが存在し、今回取り上げるWebはショップ店長をはじめとした管理者向けツールという立ち位置です。
本記事では、FAANSのWebにおけるStorybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みを紹介します。
はじめに
FAANSのWebはReact、TypeScriptで構成されています。設計に関しては、ロジックとビューの責務を分けるためにContainer Presenterパターンを採用しています。ContainerでAPIのレスポンスやユーザー情報などの共通の状態をContextから取得してPresenterに注入し、ページを表示させています。
FAANSのWebの課題
FAANSのWebでは以下の2つの課題がありました。
各状態ごとのUIを把握しきれない。
デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない。
それぞれの課題に対して取り組んできたことについて紹介します。
1. 各状態ごとのUIを把握しきれない
プロジェクトの振り返り会にて、UI改修が頻繁に発生するプロジェクトの現状において、「各状態ごとのUIを把握しきれない」という課題が挙がりました。具体的には、一覧取得APIがエラーレスポンスを返した場合のUIという具合です。
また、FAANSにはショップスタッフやショップ店長のような権限が存在します。権限によって機能が異なり、権限ごとのページのUIも把握しづらい状況でもありました。
その問題を解決するために、Storybookを導入しました。Storybookとは独立してUIコンポーネントを管理できるツールで、それを使って各状態ごとのUIを管理して一覧表示しようと試みました。
そのための設計として、以下の図のようにPages Presenterを作成し、その中でTemplates、Organisms、Molecules、Atoms層のコンポーネントを呼ぶ設計にしていました。これは、UI設計のメンタルモデルであるAtomic Designを参考にしています。
Storybook上で各storyごと、Pages Presenterのpropsに必要な値を注入して、その値に応じたページのUIを表示させていました。
ただこの「Pages Presenterに値を注入する」方法だと以下のような壁がありました。
- Storybookで各状態のページのUIを表示させるためにContainerからPages Presenterに注入している値をすべて用意する必要がある
- 各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要があるので冗長
Mock Service WorkerとMSW Storybook Addonについて
そこで、それらの壁を乗り越えるためにMock Service Worker(以下、MSW)とMSW Storybook Addonを導入しました。
MSWとはネットワークレベルでAPIのリクエストをインターセプトしてmockのレスポンスを返すライブラリです。
以下のように、handlerでAPIのパスとmockのレスポンスを定義すれば、その定義したパスにリクエストが送られて来たタイミングでmockレスポンスを返すことができます。
// handler export const handlers = { mockGetMyClosetItemsForWear: rest.get( `${process.env.REACT_MSW_DOMAIN}/v1/wear/members/@me/coordinate_items`, mockGetMyClosetItemsForWear, ), };
また、MSW Storybook Addonとはstory単位でMSWのmockのレスポンスを定義できるライブラリです。
以下のように、storyに定義すれば、MSWのhandlerで定義されているmockレスポンスと別のレスポンスを返すことが可能です。
// Storybook export const Test: Story = { parameters: { msw: { handlers: { mockGetMyClosetItemsForWear: rest.get( `${process.env.REACT_MSW_DOMAIN}/v1/wear/members/@me/coordinate_items`, (req, res, ctx) => { return res(ctx.status(500), ctx.json('')); }, ), }, }, }, };
MSWとMSW Storybook Addonの導入した結果
storyごとにmockのレスポンスを定義して、そのレスポンスに応じたページのUIをStorybook上で一覧表示させることができました。「Pages Presenterに値を注入する」方法とは違い、必要なのはmockのレスポンスになるため、Containerの値をすべてを用意する必要はなくなりました。また、各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要はなくなり、TemplatesをRouter側に書くことができました。
一部抜粋したStorybookのソースコードとしては以下のようになります。
1 // Storybook 2 const BaseStory = () => { 3 return ( 4 <MemoryRouter 5 initialEntries={['/coordinates/:coordinateId/edit/items/new/edit']} 6 > 7 <CoordinateTemplate> 8 <CoordinateProvider initialCoordinate={initialMockCoordinate}> 9 <CoordinateItemProvider initialCoordinateItem={initialMockCoordinateItem}> 10 <CoordinateItemEditContainer /> 11 </CoordinateItemProvider> 12 </CoordinateProvider> 13 </CoordinateTemplate> 14 </MemoryRouter> 15 ); 16 }; 17 18 export default { 19 title: 'pages/コーデ投稿/着用アイテム登録画面', 20 component: BaseStory, 21 }; 22 23 type Story = ComponentStoryObj<typeof BaseStory>; 24 25 export const Case1: Story = { 26 storyName: 27 'モーダルを表示させ、一覧が表示できた(200)場合', 28 play: async ({ canvasElement }) => { 29 const canvas = within(canvasElement); 30 const coordinateItemOpenModalButton = await canvas.findByTestId( 31 'coordinateItemOpenModalButton', 32 ); 33 userEvent.click(coordinateItemOpenModalButton); 34 }, 35 }; 36 37 export const Case2: Story = { 38 storyName: 39 'モーダルを表示させ、一覧が表示できなかった(500)の場合', 40 parameters: { 41 msw: { 42 handlers: { 43 mockGetMyClosetItemsForWear: rest.get( 44 `${process.env.REACT_MSW_DOMAIN}/v1/wear/members/@me/coordinate_items`, 45 (req, res, ctx) => { 46 return res(ctx.status(500), ctx.json('')); 47 }, 48 ), 49 }, 50 }, 51 }, 52 play: async ({ canvasElement }) => { 53 const canvas = within(canvasElement); 54 const coordinateItemOpenModalButton = await canvas.findByTestId( 55 'coordinateItemOpenModalButton', 56 ); 57 userEvent.click(coordinateItemOpenModalButton); 58 }, 59 };
MSWのhandlerで/v1/wear/members/@me/coordinate_items
のAPIは200レスポンス返すように定義されており、Case1ではその定義通り200を返します。よって、Case1は200を返した場合のUIを確認できます。それに対して、Case2に関しては500を返すように定義しているため、エラーのUIを確認できます。
以下、Case1とCase2のページの表示となります。
実際に動いているWebではAPIが500エラーを返すことは稀なので、このようにUI上で確認が難しいイベントを確認できるのはメリットです。
*上のページはコンポーネント間でグローバルに共有している状態をContextから取得しています。Storybookで表示させるためその状態もmockする必要があります。それはソースコードの8行目と9行目で、Provider経由でmockした状態を注入することで実現しています。
*上のページはあるボタンをクリックしてモーダルを開いた後のページです。これはStorybookのPlay functionの機能を使っており、それを使うとレンダリング後のページに対してイベントを発火させることができます。ソースコードの33行目と57行目のuserEvent.click
でクリックイベントを発火させてモーダルを開いた後のページを表示しています。
このようにMSWとStorybookを組み合わせることで「各状態ごとのUIを把握しきれない」問題を解決できました。付随して、バックエンド側でSwaggerにリクエストとレスポンスが定義されていれば、MSWがそれを元にmockレスポンスを返すため、APIが実装されていなくても並列で開発ができるメリットもありました。
2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない
デザインの修正時やライブラリのアップデート時において、UIに影響がでていないか検知する仕組みがありませんでした。
この問題を解決するためにChromaticを利用しました。ChromaticとはStorybook上のUIのコミットごとの差分を取れるツールで、GitHubのようにその差分に対してレビューできたり、StorybookのHosting機能も備わっています。Storybookで作成した多様なUIに対して、変更前と変更後でスナップショットを撮影・差分比較して予期せぬUIの影響がでていないかを確認できます。
具体的な例として、以下の画像の赤枠のように、検索窓が追加したときに他の箇所で影響が出ていないか確認してみましょう。
差分がある箇所は緑で表示されるので、確認する際は緑の箇所だけ注目すれば良くなります。確認したところ、それは検索窓の追加による差分であることが分かります。また、ヘッダーなど別の箇所をみると緑の差分は表示されていないので、検索窓を追加した前と後では予期せぬUIの影響は出ていないことが分かります。
また、同じページでモーダルを開いた後の差分を確認したところ、赤枠の箇所には緑の差分がないので、影響が出ていないのがわかります。
このChromaticの導入によって、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題を解決できました。
まとめ
「1. 各状態ごとのUIを把握しきれない」課題の解決策は以下の通りです。
- MSWでAPIのレスポンスをmockする
- グローバルな状態はProvider経由でmockした状態を注入する
- イベント発火後のUIはPlay functionを使う
これらの方法によって、Storybook上で多様なページを確認できます。 図で表すと以下の通りです。
また、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題の解決策は以下の通りです。
- 1で作成した多様なページに対して、Chromaticを使ってスナップショットを撮影・差分比較する。
この方法によって、UIの影響範囲を検知できます。
さいごに
Storybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みついて紹介しました。デザインレビューの効率化に興味がある皆さんの参考になれば幸いです。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。