4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンドテスト入門 Part 2 — Reactコンポーネントを実際にテストしてみよう

4
Posted at

フロントエンドテスト入門 Part 2 — Reactコンポーネントを実際にテストしてみよう

このシリーズはフレッシャーFEがテストをゼロから学ぶ記録です。
Part 1では「テストとは何か・環境構築」を扱いました。まだ読んでいない方はそちらからどうぞ。


はじめに

Part 1では「なぜテストが必要か」と「環境セットアップ」を学びました。今回はいよいよ手を動かす編です。

実際にReactコンポーネントを作って、テストを書いて、「ああ、こういうことか」という感覚を掴んでいきましょう。

この記事を読み終えると:

  • シンプルなコンポーネントのテストが書ける
  • renderscreenexpect の使い方がわかる
  • ユーザーのクリックや入力をシミュレートするテストが書ける
  • 条件付きレンダリングのテストが書ける

RTLの基本的な考え方をおさらい

コードを書く前に、React Testing Library(RTL)の哲学を一つだけ頭に入れておきましょう。

「ユーザーが使うように、テストを書く」

これが全てです。ユーザーはDOMのclassNameidを見ません。ユーザーはテキストを読んで、ボタンを押して、入力欄に文字を打ちます。

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 を受け取って挨拶を表示し、isLoggedIntrue のときだけ「ログイン中です」を表示します。

テストを書く

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:getByTextqueryByText を逆に使う

// ❌ 「存在しない」を確認するときに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

まとめ

この記事では以下のことを学びました:

  • renderscreenexpect — RTLの基本的な使い方
  • getByText vs queryByText vs findByText — 状況に応じたクエリの使い分け
  • getByRole — RTLで最も推奨されるクエリ
  • userEvent — クリックや入力などユーザー操作のシミュレート
  • vi.fn() — コールバック関数が呼ばれたかを検証するモック関数
  • data-testid — 最後の手段として使うテスト専用属性
  • よくあるミスasync/await の忘れ・クエリの使い間違いなど

Part 1で環境を作り、Part 2で基本的なコンポーネントテストが書けるようになりました。次のPart 3ではいよいよ実務で使うライブラリ — React Hook FormTanStack Query のテストに挑戦します。


参考リンク


いいねやコメントいただけると励みになります!


次回予告

Part 3 — React Hook Form + TanStack Query のテスト実践
フォームのバリデーションテスト、APIのモック、ローディング・エラー状態のテストまで、実務に直結する内容を扱います。

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?