乱数を interface で抽象化してゲームロジックをテスタブルにする
乱数を interface で抽象化してゲームロジックをテスタブルにする 概要 ゲーム開発において、「運」の要素は面白さを生む不可欠なスパイスです。クリティカルヒット、レアアイテムのドロップ、ダンジョンの自動生成など、多くの場面で乱数が使われます。 しかし、プログラミングの文脈において、乱数は「不確実性」そのものであり、ユニットテストの天敵です。本記事では、TypeScript を用いて乱数をインターフェースで抽象化し、テスト時に決定論的な(結果が予測可能な)挙動をさせる設計パターンについて解説します。 課題:Math.random() がもたらす「テスト不能」という病 もっとも素浦に実装すると、ゲームロジックの中で直接 Math.random() を呼び出すことになります。 // 直接 Math.random() を使う例 export class CombatService { calculateDamage(baseDamage: number, critRate: number): number { // 運が悪ければテストが落ちる if (Math.random() < critRate) { return baseDamage * 2; } return baseDamage; } } このコードをテストしようとすると、以下の問題に直面します。 非決定的 (Non-deterministic) なテスト: 同じ入力に対して、実行するたびに結果が変わる可能性があります。 境界値のテストが困難: 「クリティカル率 5% のとき、0.049 を引いたらクリティカル、0.051 を引いたら通常攻撃」という境界値の検証が運任せになります。 モックの乱立: vi.spyOn(Math, 'random') などでグローバルなオブジェクトを書き換える手法もありますが、並列テストで干渉したり、クリーンアップを忘れると他のテストに影響を与えたりと、脆いテストになりがちです。 設計:Port / Adapter パターンによる抽象化 この問題を解決するために、Dependency Inversion Principle (依存性逆転の原則) を適用します。 ロジックが「具体的な乱数生成器」に依存するのではなく、抽象的な「乱数提供インターフェース」に依存するように設計を変更します。 1. Port (Interface) の定義 ロジックが必要とする「乱数を得るための窓口」を定義します。 2. Adapter (Implementation) の実装 Production Adapter: 本番環境では Math.random() を使う。 Test Adapter: テスト環境では、事前に定義した値を返したり、シード値に基づいた再現性のある乱数を返す。 この設計にすることで、ロジック側は「誰がどうやって乱数を作っているか」を気にせず、「乱数がもらえること」だけを約束された状態になります。 ...