🐐

【TypeScript/ハンズオン】テスト駆動開発(TDD)入門 第4回:エラーハンドリングとバリデーションのテスト

2025/01/14に公開

はじめに

前回まででテストの基本やFizzBuzzの実装を通じてTDDの流れを学びました。
https://zenn.dev/nezumizuki/articles/c24df235f7333d
今回は、より実践的なシナリオとして、フォームのバリデーションとエラーハンドリングのテストについて学んでいきます。
実際のWebアプリケーション開発でよく遭遇する、ユーザー入力の検証処理をTDDで実装していく過程を見ていきましょう。

1. 開発環境のセットアップ

1.1 基本環境の構築

まずは必要な開発環境を整えていきます。TypeScriptとJestを使用した環境を構築します。

# プロジェクトディレクトリの作成
mkdir form-validation-tdd
cd form-validation-tdd

# package.jsonの初期化
npm init -y

# 必要なパッケージのインストール
npm install --save-dev typescript ts-jest @types/jest jest

# TypeScript設定ファイルの生成
npx tsc --init

# Jest設定ファイルの生成
npx ts-jest config:init

# ディレクトリ構造の作成
mkdir src
mkdir src/validators

ここでインストールしているパッケージの役割を説明します

  • typescript: TypeScriptコンパイラ本体
  • ts-jest: TypeScriptでJestのテストを書くためのプリプロセッサ
  • @types/jest: JestのTypeScript型定義
  • jest: テストフレームワーク本体

1.2 TypeScript設定

TypeScriptの設定ファイル(tsconfig.json)の主要な設定を確認します

{
  "compilerOptions": {
    "target": "es2016",        // コンパイル後のJavaScriptバージョン
    "module": "commonjs",      // モジュールシステムの種類
    "strict": true,           // 厳格な型チェックを有効化
    "esModuleInterop": true,  // import/exportの相互運用性を向上
    "skipLibCheck": true,     // 型定義ファイルの型チェックをスキップ
    "forceConsistentCasingInFileNames": true, // ファイル名の大文字小文字を厳格にチェック
    "outDir": "./dist",       // コンパイル後のファイル出力先
    "rootDir": "./src"        // ソースコードのルートディレクトリ
  }
}

1.3 package.jsonのスクリプト設定

開発に必要なNPMスクリプトを設定します

{
  "scripts": {
    "test": "jest --watchAll",        // テストを監視モードで実行
    "test:coverage": "jest --coverage", // カバレッジレポートを生成
    "build": "tsc",                   // TypeScriptのコンパイル
    "clean": "rm -rf dist"            // ビルド成果物の削除
  }
}

これらのスクリプトは以下のように使用できます:

  • npm test: 開発中のテスト実行(ファイル変更を監視)
  • npm run test:coverage: テストカバレッジの確認
  • npm run build: プロダクションビルド
  • npm run clean: ビルド成果物のクリーンアップ

1.4 この時点のプロジェクト構造

form-validation-tdd/
├── node_modules/
├── src/
│   └── validators/
├── package.json
├── package-lock.json
├── tsconfig.json
└── jest.config.js

1.5 最初のテスト環境確認

環境構築が正しくできているか確認するため、最も単純なテストを作成します。

# 最初のテストファイルを作成
touch src/validators/username.test.ts
// src/validators/username.test.ts
describe('validateUsername', () => {
    test('should exist', () => {
        expect(true).toBe(true);
    });
});

このテストの内容を解説します

  • describe: テストのグループ化を行うJestの関数です
  • test: 個別のテストケースを定義します
  • expect(true).toBe(true): 最も単純なアサーション(検証)です
# テストを起動(監視モード)
npm test

《期待される結果》

 PASS  src/validators/username.test.ts
  validateUsername
    ✓ should exist (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.123 s
Ran all test suites.

これでテスト環境が正しく設定できていることが確認できました。
以降のテストは、ファイルの変更を保存するたびに自動的に実行されます。
この自動実行機能により、開発のフィードバックループを短くすることができます。

2. バリデーションの基本設計

2.1 型定義の作成

まず、バリデーション機能で使用する型を定義します

// src/types.ts
export interface ValidationResult {
    isValid: boolean;    // バリデーション結果
    errors: string[];    // エラーメッセージの配列
}

export interface FieldValidationResult {
    [field: string]: string[];  // キーは文字列型、値は文字列の配列型としたエラーメッセージのマップ
}

これらは型定義は、

  • ValidationResult: 単一フィールドのバリデーション結果を表現
  • FieldValidationResult: フォーム全体のバリデーション結果をフィールドごとに管理

2.2 最初の実装テスト

まずはユーザー名のバリデーション機能の最も基本的なテストを書きます

// src/validators/username.test.ts
import { validateUsername } from './username';

describe('validateUsername', () => {
    test('returns validation result for simple input', () => {
        const result = validateUsername('validuser');
        expect(result).toBeDefined();
        expect(result).toHaveProperty('isValid');
        expect(result).toHaveProperty('errors');
    });
});

このテストでは以下を確認しています

  • 関数が存在すること
  • 戻り値が期待される形式(ValidationResult型)を持っていること
  • 必要なプロパティ(isValiderrors)が含まれていること

実行すると、当然ながら関数が存在しないためエラーになります

《期待される結果》

 FAIL  src/validators/username.test.ts
  ● Test suite failed to run

    src/validators/username.test.ts:1:34 - error TS2307: Cannot find module './username' or its corresponding type declarations.

    1 import { validateUsername } from './username';.

2.3 最小限の実装

テストを通すための最小限の実装を行います

// src/validators/username.ts
import { ValidationResult } from '../types';

export function validateUsername(username: string): ValidationResult {
    return {
        isValid: true,  // とりあえず常にtrue
        errors: []      // エラーなし
    };
}

この実装は、要件を満たす最小限のものです。
TDDでは、このように小さな一歩から始めることが重要です。テストが通ることを確認します。

《期待される結果》

 PASS  src/validators/username.test.ts
  validateUsername
    ✓ returns validation result for simple input

2.4 バリデーションルールの定義

次に、実際のバリデーションルールを定義します

// src/validators/rules.ts
export const VALIDATION_RULES = {
    username: {
        minLength: 3,
        maxLength: 20,
        pattern: /^[a-zA-Z0-9_]+$/,
        messages: {
            required: 'Username is required',
            minLength: 'Username must be at least 3 characters long',
            maxLength: 'Username must be at most 20 characters long',
            pattern: 'Username can only contain letters, numbers and underscores'
        }
    }
};

このルール定義の内容

  • minLength: ユーザー名の最小長(3文字)
  • maxLength: ユーザー名の最大長(20文字)
  • pattern: 許可される文字のパターン(英数字とアンダースコアのみ)
  • messages: 各種エラーメッセージ

これらのルールに基づいて、具体的なテストケースを追加します

// src/validators/username.test.ts
import { validateUsername } from './username';
import { VALIDATION_RULES } from './rules';

describe('validateUsername', () => {
    // 既存のテストはそのまま...

    test('validates minimum length', () => {
        const result = validateUsername('ab'); // 2文字(最小長3文字未満)
        expect(result.isValid).toBe(false);
        expect(result.errors).toContain(VALIDATION_RULES.username.messages.minLength);
    });
});

このテストでは

  • 最小長未満の文字列を入力
  • バリデーションが失敗すること(isValid: false
  • 適切なエラーメッセージが含まれること

を確認しています。

実行すると失敗します。

《期待される結果》

 FAIL  src/validators/username.test.ts
  validateUsername
    √ returns validation result for simple input (9 ms)
    × validates minimum length (3 ms)

  ● validateUsername › validates minimum length

    expect(received).toBe(expected) // Object.is equality

    Expected: false
    Received: true

これは当然の結果で、現在の実装は常にtrueを返すようになっているためです。

3. ユーザー名バリデーションの本格的な実装

3.1 バリデーションロジックの実装

テストを通すために、実際のバリデーションロジックを実装します

// src/validators/username.ts
import { ValidationResult } from '../types';
import { VALIDATION_RULES } from './rules';

export function validateUsername(username: string): ValidationResult {
    const errors: string[] = [];
    const rules = VALIDATION_RULES.username;

    // 必須チェック
    if (!username) {
        errors.push(rules.messages.required);
        return { isValid: false, errors };
    }

    // 最小長チェック
    if (username.length < rules.minLength) {
        errors.push(rules.messages.minLength);
    }

    return {
        isValid: errors.length === 0,  // エラーがなければvalid
        errors
    };
}

実装のポイント

  1. エラーメッセージを蓄積する配列を用意
  2. 各バリデーションルールをチェック
  3. ルール違反があればエラーメッセージを追加
  4. エラーの有無で最終的な有効性を判定

3.2 追加のテストケース

バリデーション機能を完成させるため、さらにテストケースを追加します

// src/validators/username.test.ts
describe('validateUsername', () => {
    // 既存のテスト...

      describe('length validation', () => {

    test('validates minimum length', () => {
      const result = validateUsername('ab'); // 2文字(最小長3文字未満)
      expect(result.isValid).toBe(false);
      expect(result.errors).toContain(VALIDATION_RULES.username.messages.minLength);
    });
  
    test('validates maximum length', () => {
        // 21文字の文字列を生成(最大長超過)
        const result = validateUsername('a'.repeat(21));
        expect(result.isValid).toBe(false);
        expect(result.errors).toContain(VALIDATION_RULES.username.messages.maxLength);
    });
  });

  describe('pattern validation', () => {
    test('rejects special characters', () => {
        const result = validateUsername('user@name');
        expect(result.isValid).toBe(false);
        expect(result.errors).toContain(VALIDATION_RULES.username.messages.pattern);
    });
  });
});

このテストでは以下を確認しています

  1. 最大長のバリデーション
    • 21文字(上限超過)の入力
    • 適切なエラーメッセージの確認
  2. 文字パターンのバリデーション
    • 特殊文字(@)を含む入力
    • パターン不一致のエラーメッセージ確認

テスト実行すると、現在の実装では対応していないルールのため失敗します

《期待される結果》

 FAIL  src/validators/username.test.ts
  validateUsername
    √ returns validation result for simple input (8 ms)
    √ validates minimum length (1 ms)
    length validation
      × validates maximum length (2 ms)
    pattern validation
      × rejects special characters

3.3 バリデーションロジックの更新

全てのテストケースに対応するよう、実装を完成させます

// src/validators/username.ts
export function validateUsername(username: string): ValidationResult {
    const errors: string[] = [];
    const rules = VALIDATION_RULES.username;

    // 必須チェック
    if (!username) {
        errors.push(rules.messages.required);
        return { isValid: false, errors };
    }

    // 最小長チェック
    if (username.length < rules.minLength) {
        errors.push(rules.messages.minLength);
    }

    // 最大長チェック
    if (username.length > rules.maxLength) {
        errors.push(rules.messages.maxLength);
    }

    // パターンチェック
    if (!rules.pattern.test(username)) {
        errors.push(rules.messages.pattern);
    }

    return {
        isValid: errors.length === 0,
        errors
    };
}

実装のポイント:

  1. 各バリデーションルールを順次チェック
  2. 複数のエラーを同時に検出可能
  3. 全てのチェックを通過した場合のみ有効と判定

これで全てのテストが通るようになります

《期待される結果》

 PASS  src/validators/username.test.ts
  validateUsername
    √ returns validation result for simple input (8 ms)
    √ validates minimum length (1 ms)
    length validation
      √ validates maximum length (1 ms)
    pattern validation
      √ rejects special characters

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

4. テストの可読性を高めるカスタムマッチャー

マッチャーとは?

テストを書く際、よくexpect(結果).toBe(期待値)のような形で検証を行います。このtoBeのような検証用の関数を「マッチャー」と呼びます。

Jestには以下のような標準のマッチャーが用意されています

// 値が完全に一致することを確認
expect(2 + 2).toBe(4)

// オブジェクトや配列の内容が一致することを確認
expect({name: "太郎"}).toEqual({name: "太郎"})

// 特定のプロパティを持っているか確認
expect({name: "太郎", age: 20}).toHaveProperty("age")

なぜカスタムマッチャーを作るの?

例えば、以下のようなテストを書く場合を考えてみましょう

// カスタムマッチャーを使わない場合
test('ユーザー名が正しい', () => {
    const result = validateUsername('yamada123');
    expect(result.isValid).toBe(true);
    expect(result.errors.length).toBe(0);
});

test('ユーザー名が短すぎる', () => {
    const result = validateUsername('ya');
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Username must be at least 3 characters long');
});

これを、カスタムマッチャーを使うと以下のように書けます

// カスタムマッチャーを使う場合
test('ユーザー名が正しい', () => {
    const result = validateUsername('yamada123');
    expect(result).toBeValidResult(); 
});

test('ユーザー名が短すぎる', () => {
    const result = validateUsername('ya');
    expect(result).toHaveValidationError('Username must be at least 3 characters long');
});

✨ スッキリ!

カスタムマッチャーを使うメリット

  1. テストコードが読みやすくなる
  2. 同じ検証ロジックの繰り返しを避けられる
  3. テストの意図が明確になる

4.1 カスタムマッチャーの作成方法

では、実際にカスタムマッチャーを作ってみましょう

// src/testing/matchers.ts
import { ValidationResult } from '../types';

// 1. まずTypeScriptに新しいマッチャーの型を教える
declare global {
    namespace jest {
        interface Matchers<R> {
            toBeValidResult(): R;
            toHaveValidationError(errorMessage: string): R;
        }
    }
}

// 2. マッチャーの実装
expect.extend({
    // バリデーション成功を確認するマッチャー
    toBeValidResult(received: ValidationResult) {
        const pass = received.isValid && received.errors.length === 0;
        
        // テストが失敗したときのメッセージも設定
        return {
            message: () => 
                pass
                    ? 'バリデーションは失敗するはずでした'
                    : 'バリデーションは成功するはずでした',
            pass
        };
    },

    // エラーメッセージを確認するマッチャー
    toHaveValidationError(received: ValidationResult, errorMessage: string) {
        const pass = received.errors.includes(errorMessage);
        return {
            message: () =>
                pass
                    ? `エラー "${errorMessage}" は含まれないはずでした`
                    : `エラー "${errorMessage}" が含まれるはずでした`,
            pass
        };
    }
});

このように、カスタムマッチャーを使うことで、テストコードがより自然な日本語に近い形で書けるようになり、テストの意図が明確になります。特に、同じような検証を何度も行う場合に効果を発揮します。

4.2 カスタムマッチャーを使用したテストの書き直し

作成したカスタムマッチャーを使用して、テストをより読みやすく改善します

// src/validators/username.test.ts
describe('validateUsername', () => {
  test('accepts valid username', () => {
    const result = validateUsername('validuser123');
    expect(result).toBeValidResult();  // カスタムマッチャーを使用
  });

  describe('length validation', () => {
    test('rejects invalid username', () => {
      const result = validateUsername('a');
      expect(result).toHaveValidationError(VALIDATION_RULES.username.messages.minLength);
  });
  
    test('validates maximum length', () => {
      // 21文字の文字列を生成(最大長超過)
      const result = validateUsername('a'.repeat(21));
      expect(result).toHaveValidationError(VALIDATION_RULES.username.messages.maxLength);
    });
  });
  
  describe('pattern validation', () => {
    test('rejects special characters', () => {
      const result = validateUsername('user@name');
      expect(result).toHaveValidationError(VALIDATION_RULES.username.messages.pattern);
    });
  });
});

改善のポイント

  1. テストの意図がより明確に
  2. エラーメッセージの確認が簡潔に
  3. コードの重複が減少

4.3 setupテストファイルの設定

カスタムマッチャーを全テストで使用できるよう、設定を行います

  1. テストのセットアップファイルを作成
// src/test-setup.ts
import './testing/matchers';
  1. Jestの設定ファイルを更新
// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts']  // セットアップファイルの指定
};

5. エラー処理の改善

5.1 カスタムエラークラスの作成

より詳細なエラー情報を提供するため、専用のエラークラスを作成します

// src/errors/ValidationError.ts
export class ValidationError extends Error {
    constructor(
        public readonly field: string,     // エラーが発生したフィールド名
        public readonly violations: string[] // 検証違反の詳細
    ) {
        super(`Validation failed for ${field}: ${violations.join(', ')}`);
        this.name = 'ValidationError';
    }
}

このクラスの特徴

  1. 標準のErrorクラスを拡張
  2. フィールド名とエラー内容を保持
  3. 人間が読みやすいエラーメッセージを生成

5.2 エラー処理のテスト

カスタムエラークラスの動作を確認するテスト

// src/errors/ValidationError.test.ts
import { ValidationError } from './ValidationError';

describe('ValidationError', () => {
    test('formats error message correctly', () => {
        const error = new ValidationError(
            'username', 
            ['Too short', 'Invalid characters']
        );
        
        // メッセージフォーマットの確認
        expect(error.message).toBe(
            'Validation failed for username: Too short, Invalid characters'
        );
        
        // プロパティの確認
        expect(error.field).toBe('username');
        expect(error.violations).toEqual(['Too short', 'Invalid characters']);
    });
});

5.3 バリデーション関数の改善

エラー処理を例外ベースに改善します

// src/validators/username.ts
export function validateUsername(username: string): ValidationResult {
    const errors: string[] = [];
    const rules = VALIDATION_RULES.username;

    // 必須チェック(即時エラー)
    if (!username) {
        throw new ValidationError('username', [rules.messages.required]);
    }

    // その他のバリデーション
    if (username.length < rules.minLength) {
        errors.push(rules.messages.minLength);
    }

    // エラーがある場合は例外をスロー
    if (errors.length > 0) {
        throw new ValidationError('username', errors);
    }

    // 全てのチェックに通過
    return { isValid: true, errors: [] };
}

この改善により

  1. エラーの即時検出が可能に
  2. エラー情報がより詳細に
  3. try-catchによる統一的なエラーハンドリングが可能に

テストも例外処理に対応するよう更新

// src/validators/username.test.ts
import { validateUsername } from './username';
import { VALIDATION_RULES } from './rules';
import { ValidationError } from '../errors/ValidationError';

describe('validateUsername', () => {
    describe('basic validation', () => {
        test('accepts valid username', () => {
            expect(() => validateUsername('validuser123')).not.toThrow();
        });

        test('rejects empty username', () => {
            expect(() => validateUsername('')).toThrow(ValidationError);
            expect(() => validateUsername('')).toThrow(VALIDATION_RULES.username.messages.required);
        });
    });

    describe('length validation', () => {
        test('rejects too short username', () => {
            expect(() => validateUsername('ab')).toThrow(ValidationError);
            expect(() => validateUsername('ab')).toThrow(VALIDATION_RULES.username.messages.minLength);
        });

        test('validates maximum length', () => {
            const tooLongUsername = 'a'.repeat(VALIDATION_RULES.username.maxLength + 1);
            expect(() => validateUsername(tooLongUsername)).toThrow(ValidationError);
            expect(() => validateUsername(tooLongUsername)).toThrow(VALIDATION_RULES.username.messages.maxLength);
        });
    });

    describe('pattern validation', () => {
        test('rejects special characters', () => {
            expect(() => validateUsername('user@name')).toThrow(ValidationError);
            expect(() => validateUsername('user@name')).toThrow(VALIDATION_RULES.username.messages.pattern);
        });
    });
});
  • 例外をキャッチするため、テスト対象の関数を関数で包む(expect(() => ...))

  • toThrowマッチャーを使用して例外の発生を検証

  • エラーメッセージの内容も正規表現で検証

  • バリデーションエラー時に例外が投げられることを確認

  • 投げられた例外がValidationError型であることを確認

  • エラーメッセージの内容も検証

まとめ

この記事では、TDDを用いたフォームバリデーション機能の実装について学びました。

  1. バリデーション機能をTDDで段階的に実装する方法
  2. カスタムマッチャーによるテストの可読性向上
  3. エラー処理の実装とテスト方法

「テストを先に書く」という考え方は最初は抵抗があるかもしれません。私も最初はそうでした。でも、一歩ずつテストを書きながら実装を進めていくと、コードに自信が持てるようになり、バグの早期発見にもつながります。

このようなバリデーション機能は多くのWebアプリケーションで必要とされる機能です。
TDDで実装することで、要件の見落としを防ぎ、品質の高いコードを書くことができます。

皆さんも、ぜひ普段の開発で少しずつTDDを取り入れてみてください。最初は戸惑うこともあると思いますが、その分得られるものも大きいはずです。

P.S.
Webアプリケーション開発でTDDを実践していく流れを本にして整理してみようと思います。

Discussion