【TypeScript/ハンズオン】テスト駆動開発(TDD)入門 第4回:エラーハンドリングとバリデーションのテスト
はじめに
前回まででテストの基本やFizzBuzzの実装を通じてTDDの流れを学びました。
実際の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
型)を持っていること - 必要なプロパティ(
isValid
とerrors
)が含まれていること
実行すると、当然ながら関数が存在しないためエラーになります
《期待される結果》
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
};
}
実装のポイント
- エラーメッセージを蓄積する配列を用意
- 各バリデーションルールをチェック
- ルール違反があればエラーメッセージを追加
- エラーの有無で最終的な有効性を判定
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);
});
});
});
このテストでは以下を確認しています
- 最大長のバリデーション
- 21文字(上限超過)の入力
- 適切なエラーメッセージの確認
- 文字パターンのバリデーション
- 特殊文字(@)を含む入力
- パターン不一致のエラーメッセージ確認
テスト実行すると、現在の実装では対応していないルールのため失敗します
《期待される結果》
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
};
}
実装のポイント:
- 各バリデーションルールを順次チェック
- 複数のエラーを同時に検出可能
- 全てのチェックを通過した場合のみ有効と判定
これで全てのテストが通るようになります
《期待される結果》
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');
});
✨ スッキリ!
カスタムマッチャーを使うメリット
- テストコードが読みやすくなる
- 同じ検証ロジックの繰り返しを避けられる
- テストの意図が明確になる
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);
});
});
});
改善のポイント
- テストの意図がより明確に
- エラーメッセージの確認が簡潔に
- コードの重複が減少
4.3 setupテストファイルの設定
カスタムマッチャーを全テストで使用できるよう、設定を行います
- テストのセットアップファイルを作成
// src/test-setup.ts
import './testing/matchers';
- 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';
}
}
このクラスの特徴
- 標準の
Error
クラスを拡張 - フィールド名とエラー内容を保持
- 人間が読みやすいエラーメッセージを生成
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: [] };
}
この改善により
- エラーの即時検出が可能に
- エラー情報がより詳細に
- 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を用いたフォームバリデーション機能の実装について学びました。
- バリデーション機能をTDDで段階的に実装する方法
- カスタムマッチャーによるテストの可読性向上
- エラー処理の実装とテスト方法
「テストを先に書く」という考え方は最初は抵抗があるかもしれません。私も最初はそうでした。でも、一歩ずつテストを書きながら実装を進めていくと、コードに自信が持てるようになり、バグの早期発見にもつながります。
このようなバリデーション機能は多くのWebアプリケーションで必要とされる機能です。
TDDで実装することで、要件の見落としを防ぎ、品質の高いコードを書くことができます。
皆さんも、ぜひ普段の開発で少しずつTDDを取り入れてみてください。最初は戸惑うこともあると思いますが、その分得られるものも大きいはずです。
P.S.
Webアプリケーション開発でTDDを実践していく流れを本にして整理してみようと思います。
Discussion