JavaScriptのテスト考え方

2025-07-28

はじめに

ソフトウェア開発において、テストは品質保証の重要な手段です。この章では、JavaScriptのテストについて、初心者の方にもわかりやすく基本から実践まで詳しく解説します。テストの重要性、主要なテストツール、実際のテストの書き方、ベストプラクティスまで、実践的な知識を身につけましょう。

テストの基本概念

テストとは何か?

テストとは、コードが期待通りに動作することを確認するためのプロセスです。自動化されたテストを書くことで、以下のようなメリットが得られます。

  • バグの早期発見
  • リファクタリング時の安心感
  • コードの仕様を明確化
  • 開発プロセスの効率化

テストの種類

ユニットテスト

プログラムの最小単位(関数やクラスなど)を個別に検証するテストです。各部分が意図した通りに動作するかを早期に確認できるため、バグの発見が容易になり、コードの信頼性と保守性が向上します。

統合テスト

複数のモジュールや機能を組み合わせて動作を検証するテストです。個々のユニットが正しく動くだけでなく、モジュール間の連携やデータの受け渡しが正しく機能しているかを確認することを目的としています。

E2Eテスト(End-to-End)

アプリケーション全体の動作をユーザー視点で検証するテストです。実際の操作(画面遷移・入力・ボタン押下など)をシミュレーションし、システム全体が正しく連携して機能しているかを確認します。最終的に、ユーザーが期待通りの体験を得られるかを保証するのが目的です。

スナップショットテスト

コンポーネントの出力結果を保存しておき、後で変更がないか比較するテストです。主にUIテストで使われ、レンダリング結果(HTML構造など)をスナップショットとして保存し、コード変更後に前回の結果と差分を検出します。意図しないUI変更を早期に発見できるのが特徴です。

テストツールのエコシステム

JavaScriptには豊富なテストツールがあります。主要なツールを紹介します。

テストランナー

テストコードを自動的に実行し、結果を管理・報告するツールのことです。テストの実行・成功や失敗の判定・結果の集計などを行い、開発者が効率よく動作確認できるようにします。代表的なものに JestMochaVitest などがあります。

  • Jest(ジェスト): Facebook製のオールインワンテストフレームワーク
  • Mocha(モカ): 柔軟性の高いテストフレームワーク
  • Jasmine(ジャスミン): BDDスタイルのテストフレームワーク

アサーションライブラリ

テスト内で実際の結果が期待した結果と一致しているかを確認するための仕組みを提供するライブラリです。「値が等しい」「特定の要素を含む」「例外が発生する」などの条件を明示的にチェックできます。
代表的なものに ChaiJestのexpectNode.jsのassert などがあり、テストの信頼性と可読性を高めます。

  • Chai(チャイ): 様々なスタイルのアサーションを提供
  • Jest Assertions: Jestに組み込まれたアサーション

テストユーティリティ

テストを効率的に実行・記述するための補助機能やツールの総称です。モック(擬似データ)やスタブ、レンダリング関数、イベントシミュレーションなどを提供し、テスト対象の環境を簡単に再現できます。代表的な例として、Reactでは Testing LibraryEnzyme があり、ユーザー操作やDOMの動作をテストしやすくします。

  • Testing Library: ユーザー中心のテストを支援
  • Sinon: スパイ、スタブ、モックを作成
  • Enzyme: Reactコンポーネントのテストを支援(レガシー)

E2Eテストツール

実際のユーザー操作を自動化してアプリ全体の動作を検証するツールです。ブラウザ上でクリック・入力・遷移などをシミュレートし、アプリが期待通りに動くかを確認します。代表的なツールには CypressPlaywrightSelenium などがあり、UIの動作確認や回帰テストに広く利用されています。

  • Cypressサイプレス): モダンなE2Eテストフレームワーク
  • Playwrightプレイライト): マルチブラウザ対応のE2Eテストツール
  • Seleniumセレニウム): 伝統的なE2Eテストツール

Jestを使った基本的なテスト

Jestを使った基本的なテストとは、JavaScriptの関数や処理が想定どおり動作するかを確認するための仕組みです。

例えば、ある関数の入力と出力が正しいかを testit ブロックで書き、expect とマッチャーを使って結果を検証します。

次のコードは関数とテストの基本を示すサンプルです。最初のadd関数は、2つの引数abを受け取り、その合計を返す単純な足し算関数です。続くtest関数はJestというテストフレームワークを用いた単体テストで、「add(1, 2)の結果が3になる」ことを検証しています。expect(...).toBe(...)で実際の値と期待値を比較し、正しければテスト成功となります。

// 足し算関数
function add(a, b) {
  return a + b;
}

// Jestによるテスト
test('add 関数は 1 + 2 を 3 にする', () => {
  expect(add(1, 2)).toBe(3);
});

こうしたテストを積み重ねることで、コードの信頼性や変更時の安全性を高められます。

Jestのセットアップ

Jestが標準的に使われるのは、設定なしで簡単に導入でき、豊富な機能(アサーション・モック・スナップショットなど)が最初から揃っており、速度と互換性にも優れているためです。

特にReactとの相性が良く、Facebook(現Meta)が開発している点も普及を後押ししています。

Jestのインストールコマンド

npm install --save-dev jest

package.jsonに以下を追加

{
  "scripts": {
    "test": "jest"
  }
}

簡単なテスト例

テスト対象の関数(sum.js)

module.exports = sum;によって、この関数を他のファイルからインポートして利用できるようにしています。主にユニットテスト(例えばJestなど)でテスト対象として使われる基本的な関数の構成です。

function sum(a, b) {
  return a + b;
}

module.exports = sum;

テストファイル(sum.test.js)

1つ目のテストでは、sum(1, 2)の結果が3であることをexpect(...).toBe(3)で検証しています。
2つ目のテストでは、不正な入力(文字列)を与えた際にsum関数が"引数は数値でなければなりません"というエラーを投げることを確認しています。

このようにJestのtest()関数とexpect()を組み合わせることで、関数の正しい動作とエラーハンドリングの両方を自動で検証できます。

const sum = require('./sum');

test('1 + 2 は 3', () => {
  expect(sum(1, 2)).toBe(3);
});

test('不正な入力でエラーを投げる', () => {
  expect(() => sum('1', '2')).toThrow('引数は数値でなければなりません');
});

テスト実行

npm test は、Node.js プロジェクトでテストを実行するためのコマンドです。package.json に定義された "test" スクリプトを実行し、通常は JestMocha などのテストランナーを起動してテストコードを自動的に実行します。テスト結果(成功・失敗・エラーメッセージなど)がコンソールに表示され、コードの動作確認や品質保証に役立ちます。

npm test

テストの構成要素

テストスイートとテストケース

テストスイートは関連するテストをまとめたグループで、describe で定義します。その中に含まれる個々の検証単位がテストケースで、testit を使って記述します。次のコードは、Jestを用いた単体テストの例で、describeitを使ってテスト内容を整理しています。describeは「数学関数のテスト」というテストグループを定義し、その中でitが個々のテストケースを表します。1つ目のテストではsum(1, 2)が3になること、2つ目では負の数sum(-1, -2)が-3になることを確認しています。expect(...).toBe(...)で実際の値と期待値が一致するか検証します。

describe('数学関数のテスト', () => {
  it('2つの数値を加算する', () => {
    expect(sum(1, 2)).toBe(3);
  });

  it('負の数も加算できる', () => {
    expect(sum(-1, -2)).toBe(-3);
  });
});

アサーション

テストの結果を確認するための表現で、expect とマッチャーを使って「値が一致する」「含まれる」などの条件を検証します。正しくなければテストは失敗します。次のコードは、Jestで使われる代表的なアサーション(検証)方法をまとめた例です。toBeはプリミティブ値の厳密な一致を、toEqualはオブジェクトや配列の内容を深く比較します。toBeTruthytoBeFalsyは真偽値の評価を確認します。toThrowは関数が特定のエラーを投げるかどうかを検証します。toContainは配列に指定要素が含まれるかを調べ、resolves.toBeは非同期関数が期待通りの結果を返すことを確認します。

// 等値
expect(result).toBe(3); // プリミティブ値
expect(result).toEqual({ a: 1 }); // オブジェクトの深い比較

// 真偽値
expect(result).toBeTruthy();
expect(result).toBeFalsy();

// エラー
expect(() => func()).toThrow('エラーメッセージ');

// 配列
expect(array).toContain('item');

// 非同期
await expect(asyncFunc()).resolves.toBe('result');

テストフック

テストの前後で処理を実行する仕組みで、beforeEachafterEach などがあります。繰り返し使う準備や後処理をまとめるのに便利です。次のコードは、Jestの**テストフック(ライフサイクルメソッド)**の使い方を示しています。beforeAllはすべてのテスト実行前に一度だけ実行され、共通の初期設定に使われます。afterEachは各テストの実行後に毎回呼び出され、データのリセットや後処理に利用します。beforeEachdescribeブロック内で各テストの前に実行され、そのスコープに限定された初期化が行えます。これにより、テスト環境を安定して再現可能にします。

beforeAll(() => {
  // 全テストの前に行う設定
});

afterEach(() => {
  // 各テスト後に実行するクリーンアップ
});

describe('スコープ付きフック', () => {
  beforeEach(() => {
    // このdescribeブロック内の各テスト前に実行
  });
});

非同期コードのテスト

タイマーやAPI呼び出しなど、時間がかかる処理をテストする方法です。コールバックやPromise、async/awaitを利用して完了を待ち、結果を検証します。

Promiseベースのテスト

非同期処理を返す関数をPromiseでテストする方法です。return でPromiseを返すか、.resolves / .rejects を使って期待される結果やエラーを確認します。次のコードは、Jestで非同期処理をテストする例です。fetchData()はPromiseを返す関数であり、thenを使ってその完了後に結果を検証しています。returnを付けることで、JestはPromiseの解決を待ってからテストを終了します。もしexpectの条件が満たされない場合(data'peanut butter'でない場合)、テストは失敗します。非同期処理をテストするときは、このようにPromiseを返すか、async/awaitを使うのが正しい方法です。

test('非同期関数のテスト', () => {
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

async/awaitを使ったテスト

Promiseを扱うテストをよりシンプルに書く方法で、テスト関数をasyncにし、awaitで結果を待ってからアサーションを行います。読みやすく保守性も高いです。次のコードは、async/await構文を使ったJestの非同期テストの例です。test関数にasyncを付けることで、テスト内でawaitを使用できます。fetchData()がPromiseを返す非同期関数であり、その結果をawaitで受け取ってdataに代入します。次にexpect(data).toBe('peanut butter')で、返り値が期待どおりか検証します。Promiseを直接返すよりも可読性が高く、非同期処理のテストでは一般的な書き方です。

test('async/awaitで非同期テスト', async () => {
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

モックとスパイ

外部依存や副作用を持つ処理を置き換えてテストを容易にする技術です。モックはダミー実装に差し替えることで動作を制御し、スパイは呼び出し回数や引数を記録します。

関数のモック

API呼び出しやランダム関数などを、Jestのjest.fn()で差し替えます。期待する戻り値を指定でき、テストが外部環境に依存しないようにします。次のコードは、Jestのモック関数(jest.fn)を使った呼び出し検証の例です。jest.fn()でモック関数mockFuncを作成し、mockFunc('arg1', 'arg2')で呼び出します。expect(mockFunc).toHaveBeenCalled()は、その関数が少なくとも1回呼ばれたことを確認し、toHaveBeenCalledWith('arg1', 'arg2')は指定した引数で呼び出されたことを検証します。モック関数は実際の処理を行わず、呼び出し状況を追跡できるため、関数間の動作確認に便利です。

const mockFunc = jest.fn();

mockFunc('arg1', 'arg2');

expect(mockFunc).toHaveBeenCalled();
expect(mockFunc).toHaveBeenCalledWith('arg1', 'arg2');

Reactコンポーネント(モジュールのモック)

外部ライブラリや自作モジュールをテスト時に差し替える仕組みで、jest.mock()を使って特定の関数や全体をモック化します。次のコードは、Jestで外部モジュール(API)をモック化してテストする例です。jest.mock('../api')によって../apiモジュール全体をモック化し、実際のAPI通信を行わないようにします。次にapi.getData.mockResolvedValue('mock data')で、非同期関数getDataが呼ばれた際に必ず'mock data'を返すよう設定しています。テストではfetchData()を実行し、その戻り値が'mock data'であることをexpect(...).toBe(...)で検証します。これにより、外部依存を排除した安全な単体テストが可能になります。

jest.mock('../api');

test('APIモジュールのモック', async () => {
  const api = require('../api');
  api.getData.mockResolvedValue('mock data');

  const data = await fetchData();
  expect(data).toBe('mock data');
});

Testing Libraryを使った例

React Testing Libraryでは、ユーザー視点でDOMを操作・検証できます。renderでコンポーネントを描画し、screen.getByTextなどで要素を探して動作を確認します。次のコードは、ReactコンポーネントをJestとTesting Libraryでテストする例です。1つ目のテストでは、ButtonコンポーネントにクリックイベントハンドラhandleClick(モック関数)を渡してレンダーし、fireEvent.click()で実際にクリックを発生させます。

expect(handleClick).toHaveBeenCalledTimes(1)により、ハンドラが1回呼ばれたことを確認します。2つ目のテストはスナップショットテストで、render()結果のHTML構造を保存し、将来の変更が意図したものかを比較検証します。UIの不意な崩れを防ぐための自動検出に使われます。

import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

test('ボタンクリックでハンドラが呼ばれる', () => {
  const handleClick = jest.fn();
  render();

  fireEvent.click(screen.getByText('Click me'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

test('スナップショットテスト', () => {
  const { container } = render();
  expect(container.firstChild).toMatchSnapshot();
});

E2Eテストの例(Cypress)

エンドツーエンド(E2E)テストのフレームワークで、実際のブラウザ上でアプリを操作し、UIやフローが期待通りに動くかを自動で検証できます。次のコードは、Cypressを用いたE2E(エンドツーエンド)テストの例です。

describeはテストのまとまりを示し、itは個々のテストケースを定義しています。このテストでは、ユーザーがログインページ/loginを開き、IDとパスワードを入力してフォームを送信する流れを自動化しています。cy.url().should('include', '/dashboard')で遷移先URLを確認し、cy.contains('Welcome, testuser')でログイン成功メッセージを検証します。実際のユーザー操作に近い形でWebアプリの動作を確認するのがCypressの特徴です。

describe('ログインフローのテスト', () => {
  it('成功的なログイン', () => {
    cy.visit('/login');
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('form').submit();
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome, testuser');
  });
});

テストのベストプラクティス

テストの設計原則

FIRST原則

FIRST原則とは、良いテストの5つの特性を示す指針です。

    • Fast(高速): テストは高速に実行可能であること
    • Isolated(独立): テストは互いに独立していること
    • Repeatable(再現可能): 環境に依存せず再現可能であること
    • Self-validating(自己検証): テストは成功/失敗を自己判断できること
    • Timely(適時): テスト対象コードと同時に書くこと

    AAAパターン

    AAAパターンとは、テストコードを分かりやすく構造化するための基本パターンで、次の3つの段階から成ります。

    • Arrange(準備): テストの前提条件を設定
    • Act(実行): テスト対象の動作を実行
    • Assert(検証): 結果を検証

    良いテストの特徴

    テストを書く際は、1つのテストケースでは1つのことだけを検証することが重要です。テスト名は何をテストしているのかが明確に伝わるようにし、実装の細部ではなく機能や振る舞いに焦点を当てて確認します。こうすることで、テストが失敗したときに原因をすぐに特定できるわかりやすいテストコードになります。

    • 1つのテストケースでは1つのことだけをテスト
    • テスト名は何をテストしているか明確に
    • 実装詳細ではなく振る舞いをテスト
    • テストが失敗した時、原因がすぐわかる

    テストカバレッジ

    テストではカバレッジ100%を目指す必要はなく、現実的には80%前後を目標にしつつ、特に重要なロジックや不具合が起きやすい部分にテストを集中させます。また、jest --coverageを使えばカバレッジレポートを生成して、どこまでテストできているかを確認できます。

    • カバレッジ100%が目標ではない(80%程度が現実的)
    • 重要なロジックに焦点を当てる
    • jest --coverageでカバレッジレポートを生成可能

    テスト駆動開発(TDD)

    TDD(テスト駆動開発)は、「テストファースト」を基本とする開発手法で、**Red(失敗するテストを書く)→ Green(テストが通る最小限の実装を行う)→ Refactor(コードをリファクタリングして品質を高める)**というサイクルを繰り返しながら進めます。このプロセスにより、確実に動作するコードを小さなステップで積み上げ、保守性と信頼性の高いソフトウェアを開発できます。

    1. Red: 失敗するテストを書く
    2. Green: テストが通る最小限の実装をする
    3. Refactor: コードをリファクタリングし、テストを維持

    TDDのメリット

    TDD(テスト駆動開発)のメリットは、まずテストを先に書くことで設計が自然と洗練される点にあります。さらに、実装前にテストを定義するためテストカバレッジが高くなり、コードの動作を網羅的に確認できます。また、常にテストが保証されている状態のため、安心してリファクタリングできるという大きな利点もあります。

    • 設計が洗練される
    • テストカバレッジが自然と高くなる
    • 自信を持ってリファクタリングできる

    テストのデバッグ

    テストが失敗した際は、まずエラーメッセージを注意深く読み原因を特定し、必要に応じて特定のテストだけを実行(test.only)して問題箇所を絞り込みます。さらに、console.logで中間値を確認したり、Node.jsの--inspect-brkオプションを使ったデバッガーで詳細な実行状況を追うことで、効率的にバグの原因を突き止めることができます。

    1. エラーメッセージを注意深く読む
    2. テストのみを実行(test.only
    3. console.logで中間値を確認
    4. デバッガーを使用(Node.jsの--inspect-brkオプション)

    まとめ

    JavaScriptにおける自動テストはソフトウェア品質を支える基盤であり、ユニットテスト・統合テスト・E2Eテストを適切に使い分けることが重要です。

    現在はJestが標準的に用いられ、テストは実装ではなく振る舞いを検証することが基本方針です。モックを活用すれば依存を切り離せ、ReactコンポーネントはTesting Libraryでテストできます。

    さらに、E2EテストにはCypressやPlaywrightが有力で、TDDは品質向上に効果的な手法です。テストを書く習慣を身につけることで、より堅牢でメンテナンス性の高いコードを書けるようになります。

    演習問題

    問題1

    以下の関数をテストするコードをJestで書いてください。正常系と異常系の両方を含めてください。

    function divide(a, b) {
      if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error('引数は数値でなければなりません');
      }
      if (b === 0) {
        throw new Error('0で除算できません');
      }
      return a / b;
    }

    問題2

    次のテストに関する記述について、正しいものには○、間違っているものには×をつけてください。

    1. テストは実装詳細ではなく振る舞いを検証すべきである ( )
    2. モックを使うと外部依存のあるコードをテストしやすくなる ( )
    3. テストカバレッジ100%を常に目指すべきである ( )
    4. E2Eテストはユニットテストよりも実行が速い ( )

    問題3

    以下のReactコンポーネントをTesting Libraryでテストしてください。
    ボタンクリック時の動作と初期表示を検証してください。

    function Counter() {
      const [count, setCount] = useState(0);
    
      return (
        <div>
          <p data-testid="count">Count: {count}</p>
          <button onClick={() => setCount(c => c + 1)}>Increment</button>
          <button onClick={() => setCount(0)}>Reset</button>
        </div>
      );
    }

    解答例

    問題1の解答

    describe('divide関数のテスト', () => {
      test('正常な除算', () => {
        expect(divide(10, 2)).toBe(5);
        expect(divide(9, 4)).toBe(2.25);
      });
    
      test('0除算でエラー', () => {
        expect(() => divide(10, 0)).toThrow('0で除算できません');
      });
    
      test('非数値入力でエラー', () => {
        expect(() => divide('10', '2')).toThrow('引数は数値でなければなりません');
        expect(() => divide(null, undefined)).toThrow('引数は数値でなければなりません');
      });
    });

    解説:

    divide関数の動作を検証するユニットテストの例です。

    • 正常系テストでは、10 ÷ 259 ÷ 42.25になることを確認しています。
    • **エラーテスト(異常系)**として、0で割った場合に"0で除算できません"というエラーを投げるかを検証します。
    • さらに、非数値入力のテストでは、文字列やnullundefinedなどの不正な引数を与えた際に"引数は数値でなければなりません"というエラーが発生することを確認しています。

    これにより、divide関数が正常な入力だけでなく、例外処理も正しく動作することを保証できます。

    問題2の解答

    1. × (重要なロジックに焦点を当てるべき)
    2. × (E2Eテストの方が遅い)

    問題3の解答

    import { render, screen, fireEvent } from '@testing-library/react';
    import Counter from './Counter';
    
    describe('Counterコンポーネント', () => {
      test('初期表示は0', () => {
        render();
        expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
      });
    
      test('Incrementボタンでカウントアップ', () => {
        render();
        fireEvent.click(screen.getByText('Increment'));
        expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
    
        fireEvent.click(screen.getByText('Increment'));
        expect(screen.getByTestId('count')).toHaveTextContent('Count: 2');
      });
    
      test('Resetボタンでカウントリセット', () => {
        render();
        fireEvent.click(screen.getByText('Increment'));
        fireEvent.click(screen.getByText('Increment'));
        fireEvent.click(screen.getByText('Reset'));
        expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
      });
    });

    解説:

    このコードは、React Testing Libraryを使ってCounterコンポーネントの動作をテストする例です。

    • 最初のテストでは、コンポーネントをレンダリングして初期表示が「Count: 0」であることを確認します。
    • 2つ目のテストでは、「Increment」ボタンをクリックするたびにカウントが1ずつ増加することを検証しています。
    • 3つ目のテストでは、「Reset」ボタンをクリックするとカウントが0に戻ることを確認しています。

    renderでコンポーネントを描画し、screenで要素を取得、fireEventでクリック操作をシミュレートすることで、実際のユーザー操作に近い形でコンポーネントの挙動をテストしています。