Material-UI を普通に使うと Storyshots がうまく使えない

以前の記事で Storyshots の導入を行いました。そこでは触れられませんでしたが、 CSS in JS のライブラリによっては story の snapshot に依存関係が生まれて一つのコンポーネントを修正すると以降の story のスナップショットテストがすべて落ちてしまうことがあります。

今回は Material-UI を例に対処方法をまとめました。

原因と対処の概要

先に何が起きているのかと、その対処方法をまとめます。

  • 原因: Storyshots 全体で className に連番が振られており、1箇所 className の数が変わると以降のコンポーネントの className がずれる
  • 対処方法: 連番を story 単位でリセットする。(もしくは連番をしない)

そもそもなぜ連番が振られるかという話ですが、多くの CSS in JS ライブラリはコンポーネントの一意性を高めるためにコンポーネントごとの className に連番を振ったり一意なハッシュを付与することで、意図せずグローバルなスタイルがあたってしまったりコンポーネント間でスタイルの衝突が起きたりしないようにしています。
その仕組みを維持するために今回は連番を止めず、 Story 単位で完結する形で対応します。

現象の確認

前準備

まずは現象が起こる環境を用意しました。
今回のデモは下記PRにまとめています。コードの詳細など気になる点はこちらを御覧ください。

準備の概要

スナップショット作成

この状態でまずはスナップショットの更新を行います。
当然ですが通常通り問題なくスナップショットが更新できます。

Storyshots で snapshot が正常に更新されているターミナルのスクリーンショット

Story を足すと…

この状態であらたに makeStyles でスタイルの上書きをしている Breadcrumbs.story.tsx を追加すると下記のように storyshots のスナップショットテストが落ちてしまいます。

Storyshots で関係ない story のスナップショットテストが落ちている様子のスクリーンショット。 Breadcrums 以降の story がテストに失敗している
Storyshots で関係ない story のスナップショットテストが落ちている様子のスクリーンショットの後半。 合計で3件のスナップショットテストが失敗している

本来であれば、Breadcrumbs story を追加しても影響を受けるはずがない下記のスナップショットテストが失敗しています。

  • Material-UI Button Default
  • Material-UI CircularProgress Default
  • Material-UI Tabs Default

これらの Story はすべて準備段階で makeStyles を使っていたコンポーネントです。

テストを見てみると下記のように className の連番がずれています。
ひとつの story で className が増減したり story を追加するだけで以降の makeStyles を使用しているスナップショットテストがすべて落ちてしまいます。

    Snapshot name: `Storyshots Material-UI Button Default 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -18,11 +18,11 @@
        <span
          className="MuiButton-label"
        >
          Hello 
          <span
    -       className="makeStyles-labelHighlight-1"
    +       className="makeStyles-labelHighlight-2"
          >
            World
          </span>
        </span>
      </button>

対処

ということは、各 story ごとで連番をリセットすれば story 同士の依存関係が解消されます。
下記コミットのように Storybook に StylesProvider をかまし、自前の generateClassName を渡すことで className の制御ができます。

import { addParameters, addDecorator } from "@storybook/react";
import { ThemeProvider, createMuiTheme } from "@material-ui/core/styles";
import { StylesProvider } from "@material-ui/core/styles";
import CssBaseline from "@material-ui/core/CssBaseline";

// NOTE: Story 単位で makeStyles の className 連番をリセットしたい
const createGenerateId = () => {
  let counter = 0;

  return (rule, styleSheet) =>
    `${styleSheet.options.classNamePrefix}-${rule.key}-${counter++}`;
};

const theme = createMuiTheme({
// 【中略】
});

addDecorator((story) => (
  <StylesProvider generateClassName={createGenerateId()}>
    <ThemeProvider theme={theme}>
      <CssBaseline />
      {story()}
    </ThemeProvider>
  </StylesProvider>
));
// 【後略】

これで Story 単位の連番が付与されるようになり平穏がおとずれました 🎉
連番がリセットされている様子がわかる snapshot の diff は下記です。

※ Material-UI のコンポーネントが持つ className にも採番されるようになってしまいますが、 Storybook や Storyshots を利用する上で問題はないので今回はよしとしています。

ちょっと余談

Material-UI では CSS in JS ライブラリに JSS を使用しています。
JSS には createGenerateIdgenerateId というAPIが提供されていて className を操作できます。
今回はこの仕組みを利用しました。

ただ、JSS v9.x まではこれらの API は下記のような名前でした。

  • createGenerateId → createGenerateClassName
  • generateId → generateClassName

しかし執筆時点の最新である @material-ui/core@4.11.0 の StylesProvider では古い API の名前のままなので、諸々調べるのに少しハマるかもしれません。
経緯を知っていればサクッと解決できるのですが、ヒントを見つけるまで苦戦した記憶があるのでここに残しておきます。
また JSS 以外でもスナップショットテストで似たような現象が起きたら同様のアプローチが取れないか検討してみると良いかもしれません。

開発のお悩み、フロントエンドから解決しませんか?

あなたのチームのお悩みはなんですか? 「腕の良いエンジニアに重要でない作業まで任せてしまっている」「腕の良いデザイナーに主業務以外も任せてしまっている」「すべての手が足りず細かいことまで手が回らない」などなど… 。

そんなときは、相談相手としてGaji-Laboにお気軽にお声がけください。あなたの開発チームに足りていない役割や領域を適切に捉えてカバーすることで、チーム全体の生産性と品質をアップさせるお手伝いをします。

オンラインでのヒアリングとフルリモートでのプロセス支援に対応していますので、リモートワーク対応可のパートナーをお探しの場合もぜひ弊社にお問い合わせください!

お悩み相談はこちらから!


投稿者 原田 直貴

受託と事業会社の両方を経験し、沢山の事業を見てみたい気持ちで Gaji-Labo を共同創業。普段は雑用やったりプロジェクトマネジメントやったり、たまにフロントエンドのコードを書いたり。直近は Gaji-Labo をデザイン会社に転換していく課題に挑戦中。期待値コントロールにステ全振り。