JavaScriptのテスト考え方

2025-07-28

はじめに

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

テストの基本概念

テストとは何か?

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

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

テストの種類

  1. ユニットテスト: 個々の関数やコンポーネントを独立してテスト
  2. 統合テスト: 複数のコンポーネントやモジュールの連携をテスト
  3. E2Eテスト(End-to-End): ユーザーの操作フロー全体をテスト
  4. スナップショットテスト: UIの予期せぬ変更を検知

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

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

1. テストランナー

  • Jest: Facebook製のオールインワンテストフレームワーク
  • Mocha: 柔軟性の高いテストフレームワーク
  • Jasmine: BDDスタイルのテストフレームワーク

2. アサーションライブラリ

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

3. テストユーティリティ

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

4. E2Eテストツール

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

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

Jestのセットアップ

npm install --save-dev jest

package.jsonに以下を追加:

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

簡単なテスト例

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

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

module.exports = sum;

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

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

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

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

テスト実行:

npm test

テストの構成要素

1. テストスイートとテストケース

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

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

2. アサーション

// 等値
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');

3. テストフック

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

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

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

非同期コードのテスト

Promiseベースのテスト

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

async/awaitを使ったテスト

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

モックとスパイ

関数のモック

const mockFunc = jest.fn();

mockFunc('arg1', 'arg2');

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

モジュールのモック

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

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

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

Reactコンポーネントのテスト

Testing Libraryを使った例

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)

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');
  });
});

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

1. テストの設計原則

  1. FIRST原則:
  • Fast(高速): テストは高速に実行可能であること
  • Isolated(独立): テストは互いに独立していること
  • Repeatable(再現可能): 環境に依存せず再現可能であること
  • Self-validating(自己検証): テストは成功/失敗を自己判断できること
  • Timely(適時): テスト対象コードと同時に書くこと
  1. AAAパターン:
  • Arrange(準備): テストの前提条件を設定
  • Act(実行): テスト対象の動作を実行
  • Assert(検証): 結果を検証

2. 良いテストの特徴

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

3. テストカバレッジ

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

テスト駆動開発(TDD)

TDDは「テストファースト」の開発手法で、以下のサイクルで進めます:

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

TDDのメリット

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

テストのデバッグ

テストが失敗した時のデバッグ方法:

  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('引数は数値でなければなりません');
  });
});

問題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');
  });
});