【TypeScript/ハンズオン】テスト駆動開発(TDD)入門 第3回:FizzBuzzで学ぶTDDの実践

2025/01/09に公開

はじめに

前回は、Jestを使ってテストを書いていく方法を実践しました。
https://zenn.dev/nezumizuki/articles/b9dd543d601218
今回は、実際のプログラムをTDDで開発する具体的な流れを、FizzBuzzを例に整理していきます。

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

1.1 必要なツールの確認

  • Node.js(v16以上)がインストールされていること
  • npmが利用可能であること
# バージョンの確認方法
node -v
npm -v

もし未インストールの場合は、Node.jsの公式サイトからダウンロードしてインストールしてください。

1.2 プロジェクトの作成

以下のコマンドを順番に実行していきます

# プロジェクトディレクトリの作成と移動
mkdir fizzbuzz-tdd
cd fizzbuzz-tdd

# package.jsonの初期化(すべてEnterで OK)
npm init -y

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

インストールが完了したら、package.jsonが作成されていることを確認。

1.3 TypeScript設定

最新のNode.js環境に合わせた設定を行います。

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

tsconfig.jsonを以下のように編集します

{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

ポイント

  • target: モダンなJavaScript機能を使用可能に
  • sourceMap: デバッグを容易にする
  • rootDirinclude: ソースコードの配置場所を明確に
  • include , exclude: 不要なファイルをコンパイルしないことで、ビルド時間を短縮
  • exclude: テストファイルをビルド対象から除外

1.4 Jest設定

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

生成されたjest.config.jsを以下のように編集します

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.test.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

カバレッジとは?

テストカバレッジとは、プログラムコードがテストによってどの程度カバー(網羅)されているかを示す指標です。Jest では以下の4つの観点でカバレッジを計測します

  1. Branches(分岐):

    • if文やswitch文などの条件分岐がテストされている割合
    • 例:if文の真の場合と偽の場合の両方がテストされているか
  2. Functions(関数):

    • 関数やメソッドが呼び出されてテストされている割合
    • 例:クラス内のすべてのメソッドがテストで実行されているか
  3. Lines(行):

    • コードの各行が実行されている割合
    • 例:デッドコード(実行されない行)の検出に有効
  4. Statements(文):

    • プログラムの各文が実行されている割合
    • 例:1行に複数の文がある場合も個別に計測

上記の設定では、これら4つの指標すべてで80%以上のカバレッジを要求しています。
これは一般的な水準とされる値です。

この設定のポイント

  • testMatch: テストファイルのパターンを明確に指定(*.test.tsファイルを検索)
  • collectCoverageFrom: カバレッジ計測の対象ファイルを指定
    • src/**/*.ts: srcディレクトリ以下のすべての.tsファイル
    • !src/**/*.test.ts: テストファイル自体は除外
  • coverageThreshold: 各カバレッジ指標の最低基準を設定(80%)

カバレッジレポートは npm run test:coverage を実行することで確認できます。
レポートでは、カバーされていないコードの箇所が視覚的に表示され、テストの追加が必要な箇所を特定するのに役立ちます。

ただし、カバレッジ100%が必ずしも完璧なテストを意味するわけではありません。
カバレッジは「最低限」の指標として捉え、実際のテストでは機能の正常系・異常系や境界値のテストなど、質的な面も重視することが大切です。

1.5 package.jsonの設定

package.jsonに以下のスクリプトを追加します

{
  "scripts": {
    "build": "tsc",
    "test": "jest --watchAll",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci"
  }
}

各カスタムスクリプトの役割

  • build: TypeScriptのコンパイル
  • test: テストをウォッチモードで実行
  • test:coverage: カバレッジレポートを生成
  • test:ci: CI環境用のテスト実行

1.6 ディレクトリ構造の作成

# srcディレクトリの作成
mkdir src

# 必要なファイルの作成
touch src/fizzbuzz.ts
touch src/fizzbuzz.test.ts

2. FizzBuzzの仕様理解とテスト計画

2.1 仕様の確認

FizzBuzzは以下のルールで数を変換します

  • 3の倍数なら "Fizz"
  • 5の倍数なら "Buzz"
  • 3と5の両方の倍数なら "FizzBuzz"
  • それ以外は数字をそのまま文字列で返す

2.2 テスト計画

以下の順序でテストを書いていきます

  1. 通常の数字(1, 2, 4など)→ 最もシンプルなケース
  2. 3の倍数(3, 6, 9など)→ 最初の特殊ケース
  3. 5の倍数(5, 10など)→ 2番目の特殊ケース
  4. 15の倍数(15, 30など)→ 複合条件
  5. エラーケース→ 入力値の検証

この順序には意味があります

  • シンプルなケースから始めることで、基本的な機能を確立
  • 特殊ケースを段階的に追加することで、複雑さを管理可能に
  • エラーケースは最後に追加することで、正常系の動作を先に確実にする

3. 最初のテストを書く

3.1 テストファイルの作成

// src/fizzbuzz.test.ts
import { fizzbuzz } from './fizzbuzz';

describe('FizzBuzz', () => {
    describe('normal numbers', () => {
        test('1 -> "1"', () => {
            expect(fizzbuzz(1)).toBe('1');
        });
    });
});

解説

  • 外側のdescribeでテスト全体をグループ化
  • 内側のdescribeで関連するテストケースをまとめる
  • テストの命名は入力と期待値を明確に

3.2 テストを実行

試しに実行してみます。

npm test

このとき、以下のようなエラーが表示されるはずです

Cannot find module './fizzbuzz'

3.3 最小限の実装

実装を用意します

// src/fizzbuzz.ts
export function fizzbuzz(num: number): string {
    return '1';
}

これで最初のテストが通るはずです(npm testで確認)。
この実装は意図的に最小限です

  • 必要最小限のコードだけを書く
  • 「ベタ書き」でも問題ない
  • この段階での一般化は避ける

4. テストの拡充と実装の改善

4.1 通常の数字のテスト追加

// src/fizzbuzz.test.ts
describe('FizzBuzz', () => {
    describe('normal numbers', () => {
        test('1 -> "1"', () => {
            expect(fizzbuzz(1)).toBe('1');
        });

        test('2 -> "2"', () => {
            expect(fizzbuzz(2)).toBe('2');
        });

        test('4 -> "4"', () => {
            expect(fizzbuzz(4)).toBe('4');
        });
    });
});

4.2 実装の更新

// src/fizzbuzz.ts
export function fizzbuzz(num: number): string {
    return num.toString();
}

4.3 Fizzのテスト追加

// fizzbuzz.test.tsに追加
describe('FizzBuzz', () => {
    // ... 既存のテスト ...

    describe('Fizz cases', () => {
        test('3 -> "Fizz"', () => {
            expect(fizzbuzz(3)).toBe('Fizz');
        });

        test('6 -> "Fizz"', () => {
            expect(fizzbuzz(6)).toBe('Fizz');
        });
    });
});

4.4 Fizz実装の追加

export function fizzbuzz(num: number): string {
    if (num % 3 === 0) {
        return 'Fizz';
    }
    return num.toString();
}

4.5 Buzzのテスト追加

describe('FizzBuzz', () => {
    // ... 既存のテスト ...

    describe('Buzz cases', () => {
        test('5 -> "Buzz"', () => {
            expect(fizzbuzz(5)).toBe('Buzz');
        });

        test('10 -> "Buzz"', () => {
            expect(fizzbuzz(10)).toBe('Buzz');
        });
    });
});

4.6 Buzz実装の追加

export function fizzbuzz(num: number): string {
    if (num % 3 === 0) {
        return 'Fizz';
    }
    if (num % 5 === 0) {
        return 'Buzz';
    }
    return num.toString();
}

4.7 FizzBuzzのテスト追加

describe('FizzBuzz', () => {
    // ... 既存のテスト ...

    describe('FizzBuzz cases', () => {
        test('15 -> "FizzBuzz"', () => {
            expect(fizzbuzz(15)).toBe('FizzBuzz');
        });

        test('30 -> "FizzBuzz"', () => {
            expect(fizzbuzz(30)).toBe('FizzBuzz');
        });
    });
});

4.8 FizzBuzz実装の追加

export function fizzbuzz(num: number): string {
    if (num % 3 === 0 && num % 5 === 0) {
        return 'FizzBuzz';
    }
    if (num % 3 === 0) {
        return 'Fizz';
    }
    if (num % 5 === 0) {
        return 'Buzz';
    }
    return num.toString();
}

5. エラー処理の追加

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

// src/errors.ts
export class ValidationError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'ValidationError';
    }
}

5.2 バリデーション関数の作成

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

export function validatePositiveInteger(num: number): void {
    if (!Number.isInteger(num)) {
        throw new ValidationError('Input must be an integer');
    }
    if (num <= 0) {
        throw new ValidationError('Input must be positive');
    }
}

4.3 エラーケースのテスト

// fizzbuzz.test.tsに追加
describe('FizzBuzz', () => {
    // ... 既存のテスト ...

    describe('error cases', () => {
        test('throws ValidationError for zero', () => {
            expect(() => fizzbuzz(0)).toThrow('Input must be positive');
        });

        test('throws ValidationError for negative numbers', () => {
            expect(() => fizzbuzz(-1)).toThrow('Input must be positive');
        });

        test('throws ValidationError for decimal numbers', () => {
            expect(() => fizzbuzz(1.5)).toThrow('Input must be an integer');
        });
    });
});

4.4 エラー処理の実装

// src/fizzbuzz.ts
import { validatePositiveInteger } from './validators';

export function fizzbuzz(num: number): string {
    validatePositiveInteger(num);

    if (num % 3 === 0 && num % 5 === 0) {
        return 'FizzBuzz';
    }
    if (num % 3 === 0) {
        return 'Fizz';
    }
    if (num % 5 === 0) {
        return 'Buzz';
    }
    return num.toString();
}

5. リファクタリング

5.1 ルールベースの設計

// src/types.ts
export interface FizzBuzzRule {
    matches: (num: number) => boolean;
    output: string;
}
// src/rules.ts
import { FizzBuzzRule } from './types';

export const rules: FizzBuzzRule[] = [
    {
        matches: (num) => num % 3 === 0 && num % 5 === 0,
        output: 'FizzBuzz'
    },
    {
        matches: (num) => num % 3 === 0,
        output: 'Fizz'
    },
    {
        matches: (num) => num % 5 === 0,
        output: 'Buzz'
    }
];
// src/fizzbuzz.ts
import { validatePositiveInteger } from './validators';
import { rules } from './rules';

export function fizzbuzz(num: number): string {
    validatePositiveInteger(num);

    const matchingRule = rules.find(rule => rule.matches(num));
    return matchingRule ? matchingRule.output : num.toString();
}

5.2 リファクタリングの利点

このリファクタリングには以下のような目的があります。

  1. 拡張性の向上

    • 新しいルールを追加する際は、rules配列に新しいオブジェクトを追加するだけ
    • 既存のコードを変更する必要がない(Open-Closed Principleの遵守)
  2. 保守性の向上

    • 各ルールが独立して定義されているため、ルールの変更が容易
    • ルールの優先順位は配列の順序で制御可能
  3. テスタビリティの向上

    • 個々のルールを独立してテスト可能
    • ルールの追加・変更時のテストが容易
  4. 可読性の向上

    • ビジネスルールが明確に分離され、理解しやすい
    • 条件分岐のネストが解消される

5.3 ルールの追加例

例えば、7の倍数で"Whizz"を返すルールを追加する場合

// src/rules.ts
export const rules: FizzBuzzRule[] = [
    {
        matches: (num) => num % 3 === 0 && num % 5 === 0,
        output: 'FizzBuzz'
    },
    {
        matches: (num) => num % 7 === 0,
        output: 'Whizz'
    },
    {
        matches: (num) => num % 3 === 0,
        output: 'Fizz'
    },
    {
        matches: (num) => num % 5 === 0,
        output: 'Buzz'
    }
];

このように、新しいルールの追加が既存コードに影響を与えることなく可能です。

6. テストカバレッジの確認

npm run test:coverage

このコマンドを実行すると、以下のような詳細なカバレッジレポートが生成されます

----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files |   100   |    100   |   100   |   100   |
 fizzbuzz |   100   |    100   |   100   |   100   |
 rules    |   100   |    100   |   100   |   100   |
 types    |   100   |    100   |   100   |   100   |
----------|---------|----------|---------|---------|

補足:よくあるエラーとその解決方法

  1. Jest encountered an unexpected token

    • 原因:TypeScriptの設定が正しくない
    • 解決:jest.config.jsの設定を確認
  2. Cannot find module

    • 原因:ファイルパスが間違っている
    • 解決:インポートパスを確認
  3. テストの実行が遅い

    • 原因:ウォッチモードで不要なファイルも監視している
    • 解決:.gitignorenode_modulesが含まれているか確認

おまけ:理解度チェック

  1. シーケンス生成機能の追加

    • 1から指定された数までのFizzBuzz配列を生成する関数を作成
    • テストを先に書いてからTDDで実装
  2. カスタムルールの追加

    • 7の倍数で"Whizz"を返すルールを追加
    • テストを先に書いてから実装
    • ルールの優先順位を考慮する(例:21は"FizzWhizz"?それとも"Fizz"?)
  3. エラー処理の改善

    • 最大値の制限を追加(例:1000まで)
    • カスタムエラーメッセージの改善
    • バリデーション関数のテストカバレッジ向上

これらの演習を通じて、TDDの考え方とリファクタリングの重要性をより深く理解できるはずです。

まとめ

FizzBuzzという誰もが一度は聞いたことがある問題を通じて、TDDの実践的な開発フローを体験してみました。

「テストを先に書く」という考え方に違和感を覚えた方もいるかもしれません。でも、実際にコードを書いていく中で、

  1. 「まずは1を文字列"1"に変換する」という小さな一歩から始めて
  2. 「次は3の倍数」「その次は5の倍数」と、徐々に機能を追加していき
  3. 最後にはインターフェースを使った柔軟な設計まで到達する

という流れを見ると、TDDが単なる「テストを先に書く手法」ではなく、「設計を育てていく手法」だということが分かってきたのではないでしょうか。

特に、今回のようにステップ・バイ・ステップで進めていくと、途中で「あ、このパターンが見えてきた!」という発見があったと思います。それこそがTDDの醍醐味です。テストを書きながら、より良い設計が見えてくる。そんな体験ができたのではないでしょうか。

TDDの世界を楽しみながら探検していきましょう!

次回予告

次回は「エラーハンドリングのテスト」について整理しようと思います。

  1. バリデーション関数のテスト方法
  2. 例外処理の適切なテスト方法
  3. 境界値テストの書き方
  4. モック化が必要なケースの判断方法

TDDを普及出来たらうれしいです。

Discussion