🥩

Chakra UI v3のSelectのoptionの表示確認テストでつまづいた

に公開

はじめに

業務でChakra UI v3を使っていて、テストはStorybookで書いています。
その中でSelect要素を使用し、そのoption(選択肢)が表示されているかを確認するテストでつまづいたのでそのログを残しておきます。

実装とテストコード

実装は公式どおりの使い方をしていました。

"use client"

import { Portal, Select, createListCollection } from "@chakra-ui/react"

const Demo = () => {
  return (
    <Select.Root collection={frameworks} size="sm" width="320px">
      <Select.HiddenSelect />
      <Select.Label>Select framework</Select.Label>
      <Select.Control>
        <Select.Trigger>
          <Select.ValueText placeholder="Select framework" />
        </Select.Trigger>
        <Select.IndicatorGroup>
          <Select.Indicator />
        </Select.IndicatorGroup>
      </Select.Control>
      <Portal>
        <Select.Positioner>
          <Select.Content>
            {frameworks.items.map((framework) => (
              <Select.Item item={framework} key={framework.value}>
                {framework.label}
                <Select.ItemIndicator />
              </Select.Item>
            ))}
          </Select.Content>
        </Select.Positioner>
      </Portal>
    </Select.Root>
  )
}

const frameworks = createListCollection({
  items: [
    { label: "React.js", value: "react" },
    { label: "Vue.js", value: "vue" },
    { label: "Angular", value: "angular" },
    { label: "Svelte", value: "svelte" },
  ],
})

この中のframework.label

<Select.Item item={framework} key={framework.value}>
    {framework.label}
    <Select.ItemIndicator />
</Select.Item>

が表示されているかを確認するテストを次のように書いていました。

export const Default: Story = {
  play: async ({ canvasElement }) => {
    const { findByRole } = within(canvasElement);

    // selectを開く
    const select = await findByRole("combobox", {
      name: "Framework",
    });
    await userEvent.click(select);

    // 選択肢の確認
    const listbox = await findByRole("listbox", {
      name: "Framework",
    });
    const reactOption = await findByRole("option", {
      name: "React",
    });
    expect(listbox).toBeVisible();
    expect(reactOption).toBeVisible();
  }
}

これでテストを実行したところ、listboxをfindByRoleで取得できませんでした。

調査

まずはrole=listboxとなっているかを検証ツールから確認したところ、ちゃんとroleは設定されていました。

「なぜだ…?」と悩んでいたときに、選択肢が表示されている要素がDOM上では一番下に位置していて、以下のようになっていることに気づきました。

<body>
    <div id="storybook-root">...</div>
    <div data-scope="select" data-part="positioner" ...>...</div>
</body>

あれ?もしかしてcanvasElementの範囲外にある…?

解決策

ということで、試しにlistboxの取得をscreen.findByRoleに変えてみました。

import { expect, screen, within } from "@storybook/test"; // screenを追加
// ...
    const listbox = await screen.findByRole("listbox", {
      name: "Framework",
    });

すると、listboxを取得することができました!しかし、その後のtoBeVisibleでは落ちてしまっていました。エラーには Received element is not visible:というログが出ており、要素は取得できているけどvisibleではないことがわかりました。

そこでtoBeInTheDocumentに変えたところテストが通りました。

// このtoBeVisible→toBeInTheDocumentに変更
    expect(listbox).toBeInTheDocument();
    expect(reactOption).toBeInTheDocument();

原因

「DOMの最後に表示されるのってモーダルに似た挙動だな?」と思い、実装の該当箇所見たところ、Portalでoption部分がラップされているというそれらしき箇所がありました。

      <Portal> // ←ここ
        <Select.Positioner>
          <Select.Content>
            ...
          </Select.Content>
        </Select.Positioner>
      </Portal>

Portalは公式にも以下のように記載されていました。

Used to render an element outside the DOM hierarchy.

実際にPortalを消してみると、within(canvasElement).findByRoleでも取得でき、DOMツリー上でもSelectBoxの直下に存在していました。

検索の仕方が下手くそだったこともあり、解決に1,2時間くらいかかってしまいましたが、同じような調査をしている方もいて「これ見ればよかったじゃん」と思っていますが、まあいいでしょう。いい勉強になりました。

そもそもなんでPortalでラップ

Portalにしなくても普通に動作していたので、いらなくないかと思って調べてみたのですが、Perprexity AI曰く、「親(ここでいうとselect box)のスタイルに干渉しなくて良き」らしい。が、本当にそれが理由なのかわからないので、もしわかる方いらっしゃったらコメントください!

最後に

今回みたいな些細なテストで詰まるとイライラしますが、ちゃんと調べていくと勉強になることが実感できました。イライラせずに調査しましょう。

Discussion