はじめに
学習記録アプリで「フォームに入力して登録するとレコードが追加される」テストを書こうとしたら、エラーを1つ潰すたびに次のエラーが出てくる状態になった。その過程をまとめる。
実際の実装
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>
対象コード(テスト)
it("フォームに...登録ボタンを押すと、新たに学習記録が追加されていること", async () => {
const user = userEvent.setup()
render(<App />)
await user.type(screen.getByRole('textbox', {placeholder: '学習内容'}), 'React学習')
})
対象コード(実装 App.jsx)
useEffect(() => {
const fethchTodos = async () => {
setIsLoading(true)
const todos = await getAllTodos() // ← 本物の Supabase に通信
setRecords(todos)
setIsLoading(false)
}
fethchTodos()
}, [])
// ...
{isLoading ? (
<div><p>ロード中</p></div>
) : (
<>{/* フォーム本体 */}</>
)}
原因は2つあって、
- テスト内で実際のSupabaseデータ取得処理(
getAllTodos()) に通信していて結果が不安定 - 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 / findByRole の name は、HTML の name 属性のことではなく
「アクセシブルネーム(accessible name)」 を指す。
アクセシブルネームは <label> との紐付けや aria-label などから決まるもので、
input の隣に <p>学習内容</p> や <label>学習内容</label> を ただ置いただけ では
input と紐付かず、アクセシブルネームは空のまま。だから name: "学習内容" で探しても見つからない。
対応として、<label> を input に 明示的に紐付ける。方法は2つ。
-
htmlForとidを同じ値にして結ぶ -
<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} />
最終的なソースコードとテストコード
// ※もろもろ省略
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>
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)の違いを知れた -
findByRoleのname指定の勘違いを正せた!