2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Testing Library でフォーム入力テストにハマった記録(ReferenceError: input is not defined)

2
Posted at

はじめに

学習記録アプリで「フォームに入力して登録するとレコードが追加される」テストを書こうとしたら、エラーを1つ潰すたびに次のエラーが出てくる状態になった。その過程をまとめる。

実際の実装

App.tsx
  return (
    <>
      <h1>学習記録一覧</h1>
      {isLoading ? (
        <div>
          <p>ロード中</p>
        </div>
      ) : (
        <>
          <div>
            <p>学習内容</p>
            <input
              id="title-id"
              type="text"
              onChange={onChangeTitle}
              value={title}
            />
          </div>
          <div>
            <p>学習時間</p>
            <input
              id="study-time-id"
              type="number"
              onChange={onChangeStudyTitle}
              placeholder="学習時間"
              value={studyTime}
            />
          </div>
          <div>
            <p>入力されている学習内容{title}</p>
            <p>入力されている学習時間{studyTime}時間</p>
          </div>
          {records.map((record, index) => (
            <div
              key={record.id + index}
              style={{ display: "flex", alignItems: "center" }}
            >
              <p>
                {record.title} {record.time}時間
              </p>
              <button
                onClick={() => onClickRemove(record.id)}
                style={{ marginLeft: "8px", padding: "4px 8px" }}
              >
                削除
              </button>
            </div>
          ))}

問題

render(<App />) してテキストボックスに入力したいのに、レンダー時に Supabase からの
取得処理が走って「ロード中」画面になり、テキストボックスが見つからない。

最初のエラー

TestingLibraryElementError: Unable to find an accessible element with the role "textbox"

<body>
  <div>
    <h1>学習記録一覧</h1>
    <div>
      <p>ロード中</p>   ← フォームではなくロード画面が出ている
    </div>
  </div>
</body>

対象コード(テスト)

App.test.jsx
it("フォームに...登録ボタンを押すと、新たに学習記録が追加されていること", async () => {
  const user = userEvent.setup()
  render(<App />)

  await user.type(screen.getByRole('textbox', {placeholder: '学習内容'}), 'React学習')
})

対象コード(実装 App.jsx)

App.jsx
useEffect(() => {
  const fethchTodos = async () => {
    setIsLoading(true)
    const todos = await getAllTodos()   // ← 本物の Supabase に通信
    setRecords(todos)
    setIsLoading(false)
  }
  fethchTodos()
}, [])

// ...
{isLoading ? (
  <div><p>ロード中</p></div>
) : (
  <>{/* フォーム本体 */}</>
)}

原因は2つあって、

  1. テスト内で実際のSupabaseデータ取得処理( getAllTodos()) に通信していて結果が不安定
  2. fetch は非同期なので、render 直後はまだ「ロード中」で、フォームが描画されていない

解決方法

ステップ1:Supabase 通信をモックする

テスト内で本物の通信を走らせないよう、モジュールをモックして固定データを返すようにした。

※参考にした記事:

vi.mock("../lib/clients/supabase", () => {
  return {
    getAllTodos: vi.fn(() => [
      { id: 1, title: "React学習", time: 3, created_at: "2026-07-20 ..." },
    ]),
  }
})

ステップ2:非同期表示を「待つ」クエリに変える

今度はこんなエラーが。

TestingLibraryElementError: Unable to find an accessible element with the role "textbox" and name "学習内容"

どうやらtextboxの「学習内容」という要素が見つからないらしい。

原因は、getByXXX系(同期)を使用していたから。
モックで即データを返しても、state 更新 → 再レンダリングは次のタイミングなので、まだ画面が完全に描画されていないのにgetByXXX系(同期)ですぐ要素を探しに行こうとして「そんな要素存在しないよう」というものらしい。

なので、getByRole(同期)ではなく、
要素が現れるのを待つ findByRole(非同期)に変更した。

※参考にした記事:

// getByRole -> findByRoleに変更
await screen.findByRole("textbox", { name: "学習内容" })

ステップ3:それでも「学習内容」要素が紐づかない

findByRole にして「待つ」ようにしたのに、まだ同じエラーが消えない。

Unable to find role="textbox" and name "学習内容"

原因は name オプションの勘違いだった。

    await user.type(                    // ↓これ
      await screen.findByRole("textbox", { name: "学習内容" }),
      "英語学習",
    )

getByRole / findByRolename は、HTML の name 属性のことではなく
「アクセシブルネーム(accessible name)」 を指す。
アクセシブルネームは <label> との紐付けや aria-label などから決まるもので、
input の隣に <p>学習内容</p><label>学習内容</label>ただ置いただけ では
input と紐付かず、アクセシブルネームは空のまま。だから name: "学習内容" で探しても見つからない。

対応として、<label> を input に 明示的に紐付ける。方法は2つ。

  • htmlForid を同じ値にして結ぶ
  • <label><input> を囲む

textbox として拾えるのは type="text" の input。type="number"spinbutton ロールに
なるので、「学習時間」側は findByRole("spinbutton", { name: "学習時間" }) で取る点にも注意)

// `htmlFor` と `id` で結ぶ、または label で input を囲む。
<label htmlFor="title">学習内容</label>
<input id="title" type="text" onChange={onChangeTitle} value={title} />

最終的なソースコードとテストコード

App.jsx
// ※もろもろ省略
  return (
    <>
      <h1>学習記録一覧</h1>
      {isLoading ? (
        <div>
          <p>ロード中</p>
        </div>
      ) : (
        <>
          <div>
            {/** ↓// 紐付けのため、明示的にhtmlFor と idを指定 */}
            <label htmlFor="title-id">学習内容</label> 
            <input
              id="title-id"
              type="text"
              onChange={onChangeTitle}
              value={title}
            />
          </div>
          <div>
            <label htmlFor="study-time-id">学習時間</label>
            <input
              id="study-time-id"
              type="number"
              onChange={onChangeStudyTitle}
              placeholder="学習時間"
              value={studyTime}
            />
          </div>

App.test.jsx
  it("フォームに学習内容と時間を入力して登録ボタンを押すと、新たに学習記録が追加されていること", async () => {
    // Given: テスト対象画面レンダリング用意
    const user = userEvent.setup()
    render(<App />)

    // Then: フォームに学習内容と時間を入力して登録ボタンを押す
    await user.type(
      await screen.findByRole("textbox", { name: "学習内容" }),
      "英語学習",
    )
    await user.type(
      await screen.findByRole("spinbutton", { name: "学習時間" }),
      "2",
    )
    await user.click(await screen.findByText("登録"))
    expect(screen.getByText("英語学習 2時間")).toBeInTheDocument()
  })

おわりに

  • 要素の取得方法(getByRole/ queryByRole/ findByRole)の違いを知れた
  • findByRolename指定の勘違いを正せた!
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?