フロントエンドテスト入門 Part 2 — Reactコンポーネントを実際にテストしてみよう
このシリーズはフレッシャーFEがテストをゼロから学ぶ記録です。
Part 1では「テストとは何か・環境構築」を扱いました。まだ読んでいない方はそちらからどうぞ。
はじめに
Part 1では「なぜテストが必要か」と「環境セットアップ」を学びました。今回はいよいよ手を動かす編です。
実際にReactコンポーネントを作って、テストを書いて、「ああ、こういうことか」という感覚を掴んでいきましょう。
この記事を読み終えると:
- シンプルなコンポーネントのテストが書ける
-
render・screen・expectの使い方がわかる - ユーザーのクリックや入力をシミュレートするテストが書ける
- 条件付きレンダリングのテストが書ける
RTLの基本的な考え方をおさらい
コードを書く前に、React Testing Library(RTL)の哲学を一つだけ頭に入れておきましょう。
「ユーザーが使うように、テストを書く」
これが全てです。ユーザーはDOMのclassNameやidを見ません。ユーザーはテキストを読んで、ボタンを押して、入力欄に文字を打ちます。
RTLはそのユーザーの行動をそのままコードで表現できるように設計されています。このことを意識しながら読み進めてください。
今回テストするコンポーネント一覧
この記事では以下の3つのコンポーネントを作ってテストしていきます。難易度順に並んでいます。
| コンポーネント | 学べること |
|---|---|
Greeting |
基本的なrender・propsのテスト |
Counter |
クリックイベント・状態変化のテスト |
UserCard |
条件付きレンダリングのテスト |
1. Greetingコンポーネント — 基本のrender・propsテスト
コンポーネントを作る
まず、テスト対象のコンポーネントを作ります。
src/components/Greeting.tsx
type Props = {
name: string
isLoggedIn?: boolean
}
export const Greeting = ({ name, isLoggedIn = false }: Props) => {
return (
<div>
<h1>こんにちは、{name}さん!</h1>
{isLoggedIn && <p>ログイン中です</p>}
</div>
)
}
シンプルなコンポーネントです。name を受け取って挨拶を表示し、isLoggedIn が true のときだけ「ログイン中です」を表示します。
テストを書く
src/components/Greeting.test.tsx
import { render, screen } from '@testing-library/react'
import { Greeting } from './Greeting'
describe('Greeting', () => {
it('nameを受け取って正しく表示する', () => {
// Arrange
render(<Greeting name="田中" />)
// Assert
expect(screen.getByText('こんにちは、田中さん!')).toBeInTheDocument()
})
it('isLoggedInがtrueのとき「ログイン中です」を表示する', () => {
render(<Greeting name="田中" isLoggedIn={true} />)
expect(screen.getByText('ログイン中です')).toBeInTheDocument()
})
it('isLoggedInがfalseのとき「ログイン中です」を表示しない', () => {
render(<Greeting name="田中" isLoggedIn={false} />)
expect(screen.queryByText('ログイン中です')).not.toBeInTheDocument()
})
})
実行してみましょう:
npm run test
✓ src/components/Greeting.test.tsx (3)
✓ Greeting (3)
✓ nameを受け取って正しく表示する
✓ isLoggedInがtrueのとき「ログイン中です」を表示する
✓ isLoggedInがfalseのとき「ログイン中です」を表示しない
全部グリーン!
コードを解説する
render() — コンポーネントをDOMにレンダリングします。ブラウザを開かなくても、jsdomという仮想ブラウザ上でコンポーネントが展開されます。
screen — レンダリングされたDOMを検索するためのオブジェクトです。screen.getByText()・screen.getByRole()など様々なクエリメソッドを持っています。
getByText vs queryByText — ここが重要なポイントです。
| メソッド | 要素が見つからない場合 | 使いどころ |
|---|---|---|
getByText |
エラーを投げる | 「存在するはず」の要素を取得するとき |
queryByText |
nullを返す |
「存在しないはず」を確認するとき |
findByText |
Promiseを返す(非同期) | 非同期でDOMに現れる要素を待つとき |
「存在しないこと」を確認したいときは queryByText を使い、.not.toBeInTheDocument() でアサーションします。getByText で存在しない要素を取得しようとするとエラーになるので注意してください。
2. Counterコンポーネント — クリックイベント・状態変化のテスト
コンポーネントを作る
src/components/Counter.tsx
import { useState } from 'react'
export const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>増やす</button>
<button onClick={() => setCount(count - 1)}>減らす</button>
<button onClick={() => setCount(0)}>リセット</button>
</div>
)
}
テストを書く
src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter', () => {
it('初期値として0を表示する', () => {
render(<Counter />)
expect(screen.getByText('カウント: 0')).toBeInTheDocument()
})
it('「増やす」ボタンをクリックするとカウントが1増える', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '増やす' }))
expect(screen.getByText('カウント: 1')).toBeInTheDocument()
})
it('「増やす」ボタンを3回クリックするとカウントが3になる', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '増やす' }))
await user.click(screen.getByRole('button', { name: '増やす' }))
await user.click(screen.getByRole('button', { name: '増やす' }))
expect(screen.getByText('カウント: 3')).toBeInTheDocument()
})
it('「減らす」ボタンをクリックするとカウントが1減る', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '減らす' }))
expect(screen.getByText('カウント: -1')).toBeInTheDocument()
})
it('カウントを増やした後「リセット」ボタンをクリックすると0に戻る', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '増やす' }))
await user.click(screen.getByRole('button', { name: '増やす' }))
await user.click(screen.getByRole('button', { name: 'リセット' }))
expect(screen.getByText('カウント: 0')).toBeInTheDocument()
})
})
コードを解説する
userEvent — ユーザーの操作をリアルに再現するライブラリです。クリック・キーボード入力・ホバーなどをシミュレートできます。
// userEventの使い方
const user = userEvent.setup() // まずsetup()でインスタンスを作る
await user.click(element) // クリック
await user.type(input, 'テキスト') // 文字入力
await user.keyboard('{Enter}') // キーボード操作
await が必要なのは、userEvent の操作が非同期だからです。忘れると「クリックしたのに状態が変わっていない」というバグが起きます。
getByRole — RTLで最も推奨されているクエリです。HTMLの意味的な役割(role)で要素を取得します。
// ボタンをroleで取得
screen.getByRole('button', { name: '増やす' })
// 他によく使うrole
screen.getByRole('heading', { name: 'タイトル' }) // h1〜h6
screen.getByRole('textbox') // input[type="text"]
screen.getByRole('checkbox') // checkbox
screen.getByRole('link', { name: 'ホーム' }) // a要素
なぜ getByText より getByRole を優先するのか? getByRole はアクセシビリティ(a11y)を意識した書き方で、スクリーンリーダーなどの支援技術でも正しく動くコンポーネントを作る習慣につながります。
3. UserCardコンポーネント — 条件付きレンダリングの本格テスト
少し複雑なコンポーネントでも練習しましょう。
コンポーネントを作る
src/components/UserCard.tsx
type User = {
name: string
role: 'admin' | 'member' | 'guest'
bio?: string
}
type Props = {
user: User
onFollow?: () => void
}
export const UserCard = ({ user, onFollow }: Props) => {
const roleLabel = {
admin: '管理者',
member: 'メンバー',
guest: 'ゲスト',
}[user.role]
return (
<div>
<h2>{user.name}</h2>
<span data-testid="role-badge">{roleLabel}</span>
{user.bio && <p>{user.bio}</p>}
{user.role === 'admin' && (
<div data-testid="admin-panel">管理者パネル</div>
)}
{onFollow && (
<button onClick={onFollow}>フォローする</button>
)}
</div>
)
}
テストを書く
src/components/UserCard.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserCard } from './UserCard'
describe('UserCard', () => {
// テスト用のデータを共通で定義しておく
const adminUser = { name: '山田', role: 'admin' as const }
const memberUser = { name: '鈴木', role: 'member' as const }
const guestUser = { name: '佐藤', role: 'guest' as const }
describe('ユーザー名とロールの表示', () => {
it('ユーザー名を表示する', () => {
render(<UserCard user={memberUser} />)
expect(screen.getByText('鈴木')).toBeInTheDocument()
})
it('adminユーザーには「管理者」バッジを表示する', () => {
render(<UserCard user={adminUser} />)
expect(screen.getByTestId('role-badge')).toHaveTextContent('管理者')
})
it('memberユーザーには「メンバー」バッジを表示する', () => {
render(<UserCard user={memberUser} />)
expect(screen.getByTestId('role-badge')).toHaveTextContent('メンバー')
})
it('guestユーザーには「ゲスト」バッジを表示する', () => {
render(<UserCard user={guestUser} />)
expect(screen.getByTestId('role-badge')).toHaveTextContent('ゲスト')
})
})
describe('bioの表示', () => {
it('bioがある場合は表示する', () => {
const userWithBio = { ...memberUser, bio: 'フロントエンドエンジニアです' }
render(<UserCard user={userWithBio} />)
expect(screen.getByText('フロントエンドエンジニアです')).toBeInTheDocument()
})
it('bioがない場合は表示しない', () => {
render(<UserCard user={memberUser} />)
expect(screen.queryByText('フロントエンドエンジニアです')).not.toBeInTheDocument()
})
})
describe('管理者パネル', () => {
it('adminユーザーには管理者パネルを表示する', () => {
render(<UserCard user={adminUser} />)
expect(screen.getByTestId('admin-panel')).toBeInTheDocument()
})
it('memberユーザーには管理者パネルを表示しない', () => {
render(<UserCard user={memberUser} />)
expect(screen.queryByTestId('admin-panel')).not.toBeInTheDocument()
})
})
describe('フォローボタン', () => {
it('onFollowが渡された場合、フォローボタンを表示する', () => {
render(<UserCard user={memberUser} onFollow={() => {}} />)
expect(screen.getByRole('button', { name: 'フォローする' })).toBeInTheDocument()
})
it('onFollowが渡されない場合、フォローボタンを表示しない', () => {
render(<UserCard user={memberUser} />)
expect(screen.queryByRole('button', { name: 'フォローする' })).not.toBeInTheDocument()
})
it('フォローボタンをクリックするとonFollowが呼ばれる', async () => {
const user = userEvent.setup()
const handleFollow = vi.fn() // モック関数を作る
render(<UserCard user={memberUser} onFollow={handleFollow} />)
await user.click(screen.getByRole('button', { name: 'フォローする' }))
expect(handleFollow).toHaveBeenCalledTimes(1) // 1回呼ばれたか確認
})
})
})
新しく登場したものを解説する
data-testid — テスト専用の属性です。getByTestId('admin-panel') のように使います。
ただし、data-testid は最後の手段として使いましょう。RTLの推奨優先順位は:
1. getByRole ← 最優先(アクセシブルなので)
2. getByLabelText ← フォームの入力欄など
3. getByPlaceholderText
4. getByText ← テキストで取得
5. getByDisplayValue
6. getByAltText ← 画像のalt
7. getByTitle
8. getByTestId ← 最後の手段
vi.fn() — Vitestが提供するモック関数です。「この関数が何回呼ばれたか」「どんな引数で呼ばれたか」を記録できます。
const handleFollow = vi.fn()
// 関数が呼ばれたかチェック
expect(handleFollow).toHaveBeenCalled()
// 何回呼ばれたかチェック
expect(handleFollow).toHaveBeenCalledTimes(1)
// どんな引数で呼ばれたかチェック
expect(handleFollow).toHaveBeenCalledWith('expected-argument')
propsとして渡すコールバック関数のテストには必ず vi.fn() を使いましょう。
よくあるミスとその対処法
テストを書き始めたばかりのときによく遭遇するエラーと、その対処法をまとめました。
ミス1:async/await を忘れる
// ❌ awaitなし — クリック後の状態変化が反映されない
it('ボタンをクリックするとカウントが増える', () => {
const user = userEvent.setup()
render(<Counter />)
user.click(screen.getByRole('button', { name: '増やす' })) // awaitなし!
expect(screen.getByText('カウント: 1')).toBeInTheDocument() // 失敗する
})
// ✅ awaitあり
it('ボタンをクリックするとカウントが増える', async () => { // asyncも必要
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '増やす' }))
expect(screen.getByText('カウント: 1')).toBeInTheDocument() // 成功
})
ミス2:getByText と queryByText を逆に使う
// ❌ 「存在しない」を確認するときにgetByTextを使う → エラーが発生
expect(screen.getByText('ログイン中です')).not.toBeInTheDocument()
// ✅ 「存在しない」を確認するときはqueryByText
expect(screen.queryByText('ログイン中です')).not.toBeInTheDocument()
ミス3:テキストの完全一致に気をつける
// コンポーネント
<p>カウント: {count}</p>
// ❌ スペースや句読点の違いで失敗する
expect(screen.getByText('カウント:0')).toBeInTheDocument() // スペースなし
expect(screen.getByText('カウント: 0')).toBeInTheDocument() // 全角コロン
// ✅ コンポーネントと完全一致させる
expect(screen.getByText('カウント: 0')).toBeInTheDocument()
// または正規表現で部分一致
expect(screen.getByText(/カウント/)).toBeInTheDocument()
ミス4:各テストで独立したrenderを行わない
// ❌ renderを一度だけしてテストを共有しようとする(DOMが汚染される)
describe('Counter', () => {
render(<Counter />) // describeの中でrenderしてはいけない
it('初期値は0', () => { ... })
it('クリックで増える', () => { ... })
})
// ✅ 各itの中でrenderする(RTLが各テスト後に自動でクリーンアップしてくれる)
describe('Counter', () => {
it('初期値は0', () => {
render(<Counter />)
...
})
it('クリックで増える', async () => {
render(<Counter />)
...
})
})
テストを実行して結果を確認する
全てのテストをまとめて実行:
npm run test
特定のファイルだけ実行:
npm run test Counter
UIモードで視覚的に確認(とても便利!):
npm run test:ui
カバレッジを確認:
npm run test:coverage
まとめ
この記事では以下のことを学びました:
-
render・screen・expect— RTLの基本的な使い方 -
getByTextvsqueryByTextvsfindByText— 状況に応じたクエリの使い分け -
getByRole— RTLで最も推奨されるクエリ -
userEvent— クリックや入力などユーザー操作のシミュレート -
vi.fn()— コールバック関数が呼ばれたかを検証するモック関数 -
data-testid— 最後の手段として使うテスト専用属性 -
よくあるミス —
async/awaitの忘れ・クエリの使い間違いなど
Part 1で環境を作り、Part 2で基本的なコンポーネントテストが書けるようになりました。次のPart 3ではいよいよ実務で使うライブラリ — React Hook Form と TanStack Query のテストに挑戦します。
参考リンク
いいねやコメントいただけると励みになります!
次回予告
Part 3 — React Hook Form + TanStack Query のテスト実践
フォームのバリデーションテスト、APIのモック、ローディング・エラー状態のテストまで、実務に直結する内容を扱います。