📷

Chromaticのスナップショットに写らない要素があるときの対策 [Storybook]

2025/03/07に公開

はじめに

Storybook + Chromaticを使っていて、「ローカルでは表示されているのに、Chromaticのスナップショットには写っていない」という事象に出会うことがありました。

原因として考えられるパターンはいくつかありますが、今回はその中でもMUIのポップアップ系コンポーネント(Tooltip, Popoverなど)で特に起きやすいケースについて調べてみました。

前提

この記事は次のような方に向けています。

  • Storybook + Chromaticでビジュアルリグレッションテスト(VRT)を使っている
  • MUIを使っていて、モーダルやポップアップの表示を確認したい
  • テストが通っているのに、Chromaticのスナップショットには写っていないという状況に困っている

簡単におさらい

  • Storybook:コンポーネントを単体で動かしてデザイン・動作を確認できる開発支援ツール
    • → UIの表示に必要なデータやAPIアクセスをモックしたり、E2Eテストができます!!
  • Chromatic:Storybookと連携し、表示状態を画像で記録・比較するVRTサービス
    • → 意図しないUIの変化を自動で検知したり、UIに対するレビューがしやすくなります!!

状況整理

発生していたのは、次のような状況です。

  • ボタンを押すとポップアップ(Popper)が表示されるコンポーネントを作成
  • Storybookにポップアップを開いた状態のストーリーを作成
    • play関数の中でボタンをクリックし、表示された要素に対してexpectで表示確認する
    • このexpectは通っていて、ポップアップが「表示されていること」は確認済み
  • Chromaticでもポップアップが開いた状態の表示を確認したいが、表示されない

ChromaticのCanvas画面ではポッパーを開いた状態の表示が確認できたのですが、Chromaticのスナップショット画像にはそのポップアップが写っていないという状態でした。スナップショットに表示されないと、VRTでの差分検知ができなくなってしまいます。

Canvas (表示されている) Snapshot (表示されない)

最初に試したこと

  • waitForで要素表示を待機
  • chromatic.delay[1]で撮影タイミングを調整

どれも効果がなく、表示チェックは通っているのにスナップショットに写らない、という状況が続きました。

原因

結論から言うと、MUIのポップアップがStorybookのcanvas(プレビュー領域)外に描画されていたことが原因です。

Chromaticが撮影する範囲

ChromaticはStorybookのプレビューエリア(canvas)内のみをスナップショット対象にします。開発者ツールで DOM をみてみると id="storybook-root" が割り当てられている要素の中身がプレビューの対象範囲となるようです。

MUIのポップアップ系コンポーネントの仕様

MUIのPopperTooltipは、デフォルトで**document.body直下に表示**されます。

これは、親要素のoverflow: hiddenなどの影響を受けにくくするための設計のようです。その結果、Chromaticのスナップショット範囲外に表示される=スナップショットには写らないという問題が発生していました。

対策

1. ポータルを無効化(最もシンプルそう)

MUIのPopperTooltipには、描画先を親要素直下に固定できるdisablePortalというオプションがあります。これをtrueにすることで、Storybookのcanvas内に描画でき、Chromaticのスナップショットにも反映されます。

  // simplePopper.tsx
- <Popper open={open} anchorEl={anchorEl}>
+ <Popper disablePortal={true} open={open} anchorEl={anchorEl}>
サンプルコード

元の参考サンプル: https://mui.com/material-ui/react-popper

// simplePopper.tsx
import { Box, Popper } from "@mui/material";
import React from "react";

export const SimplePopper = () => {
  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorEl(anchorEl ? null : event.currentTarget);
  };

  const open = Boolean(anchorEl);
  const id = open ? "simple-popper" : undefined;

  return (
    <div>
      <button aria-describedby={id} type="button" onClick={handleClick}>
        Toggle Popper
      </button>
      <Popper disablePortal={true} open={open} anchorEl={anchorEl}>
        <Box sx={{ border: 1, p: 1, bgcolor: "background.paper" }}>
          The content of the Popper.
        </Box>
      </Popper>
    </div>
  );
};
// simplePopper.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent, screen } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
import { SimplePopper } from "./simplePopper";

const meta: Meta<typeof SimplePopper> = {
  title: "Example/SimplePopper",
  component: SimplePopper,
};

export default meta;

type Story = StoryObj<typeof SimplePopper>;

export const Default: Story = {};

export const Open: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // ボタンをクリックしてPopperを表示
    const button = canvas.getByRole("button", { name: "Toggle Popper" });
    await userEvent.click(button);

    // Popper内の文字列を確認
    const popperContent = await screen.findByText("The content of the Popper.");
    expect(popperContent).toBeVisible();
  },
};

2. 描画範囲を指定する(私はこれでやりました)

MUIのPopperは、ポップアップの表示位置を計算する際に親要素のサイズや位置を基準にしますが、状況によっては「親要素を無視してdocument.body直下に直接ポータル表示する」という処理になることがあるようです。

この結果、Storybookのcanvas外にポップアップが描画され、Chromaticのスナップショットに写らなくなることがあります。これの対策として、コンポーネントを特定のサイズを持つ親要素(Boxなど)で囲み、描画範囲を明示的に指定する方法があります。

  // simplePopper.stories.tsx
  const meta: Meta<typeof SimplePopper> = {
    title: "Example/SimplePopper",
    component: SimplePopper,
+   decorators: [
+     (Story) => (
+       <Box width={"300px"} height={"300px"}>
+         <Story />
+       </Box>
+     ),
+   ],
  };
サンプルコード
// simplePopper.tsx
import { Box, Popper } from "@mui/material";
import React from "react";

export const SimplePopper = () => {
  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorEl(anchorEl ? null : event.currentTarget);
  };

  const open = Boolean(anchorEl);
  const id = open ? "simple-popper" : undefined;

  return (
    <div>
      <button aria-describedby={id} type="button" onClick={handleClick}>
        Toggle Popper
      </button>
      <Popper id={id} open={open} anchorEl={anchorEl}>
        <Box sx={{ border: 1, p: 1, bgcolor: "background.paper" }}>
          The content of the Popper.
        </Box>
      </Popper>
    </div>
  );
};
// simplePopper.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent, screen } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
import { SimplePopper } from "./simplePopper";
import { Box } from "@mui/material";

const meta: Meta<typeof SimplePopper> = {
  title: "Example/SimplePopper",
  component: SimplePopper,
  decorators: [
    (Story) => (
      <Box width={"300px"} height={"300px"}>
        <Story />
      </Box>
    ),
  ],
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Open: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // ボタンをクリックしてPopperを表示
    const button = canvas.getByRole("button", { name: "Toggle Popper" });
    await userEvent.click(button);

    // Popper内の文字列を確認
    const popperContent = await screen.findByText("The content of the Popper.");
    expect(popperContent).toBeVisible();
  },
};

このように親要素のサイズを具体的に指定することで、本体側の実装を変更せずにポッパーを表示範囲に含めることができました。

おわりに

この問題はMUIに限らず、ポータル系コンポーネント全般に起こり得ます。「表示されているのに写らない」と感じたら、表示位置がchromaticのcanvas内かどうかをまず疑ってみると、原因特定がスムーズかもしれません。最後までお読みいただきありがとうございました。

脚注
  1. https://www.chromatic.com/docs/delay/ ↩︎

Discussion