Phaserなどのステートフルフレームワークのユニットテスト


9

TL; DRステートフルなフレームワーク内で作業する場合、自動化された単体テストを簡略化するための手法を特定するのに役立ちます。


バックグラウンド:

TypeScriptとPhaserフレームワークでゲームを書いています。Phaserは、コードの構造をできるだけ制限しないHTML5ゲームフレームワークとしての地位を占めています。これにはいくつかのトレードオフが伴います。つまり、キャッシュ、物理、ゲームの状態など、すべてにアクセスできるGod-object Phaser.Gameが存在します。

このステートフル性は、私のタイルマップなどの多くの機能をテストすることを本当に困難にします。例を見てみましょう:

ここでは、タイルレイヤーを正しくテストして、タイルマップ内の壁と生き物を識別できるかどうかをテストしています。

export class TilemapTest extends tsUnit.TestClass {
    constructor() {
        super();

        this.map = this.mapLoader.load("maze", this.manifest, this.mazeMapDefinition);

        this.parameterizeUnitTest(this.isWall,
            [
                [{ x: 0, y: 0 }, true],
                [{ x: 1, y: 1 }, false],
                [{ x: 1, y: 0 }, true],
                [{ x: 0, y: 1 }, true],
                [{ x: 2, y: 0 }, false],
                [{ x: 1, y: 3 }, false],
                [{ x: 6, y: 3 }, false]
            ]);

        this.parameterizeUnitTest(this.isCreature,
            [
                [{ x: 0, y: 0 }, false],
                [{ x: 2, y: 0 }, false],
                [{ x: 1, y: 3 }, true],
                [{ x: 4, y: 1 }, false],
                [{ x: 8, y: 1 }, true],
                [{ x: 11, y: 2 }, false],
                [{ x: 6, y: 3 }, false]
            ]);

私が何をしようとも、マップを作成しようとするとすぐに、Phaserは内部的にそのキャッシュを呼び出します。このキャッシュは、実行時にのみ読み込まれます。

ゲーム全体をロードせずにこのテストを呼び出すことはできません。

複雑な解決策は、画面に表示する必要があるときにのみマップを構築するアダプタまたはプロキシを記述することです。または、必要なアセットのみを手動でロードし、特定のテストクラスまたはモジュールにのみ使用することで、自分でゲームを作成することもできます。

私は自分が実際的であると感じるものを選びましたが、これに対する外国の解決策です。ゲームの読み込みと実際の再生の間に、TestStateすべてのアセットとキャッシュデータが既に読み込まれた状態でテストを実行するin をシムしました。

必要なすべての機能をテストできるのでこれはクールですが、これは技術的な統合テストであり、画面を見て敵が表示されているかどうかを確認できないのではないかと不思議に思っています。実際、いいえ、彼らはアイテムとして誤認された可能性があります(既に一度起こった)またはテストの後半で、彼らの死に関連するイベントが与えられなかった可能性があります。

私の質問 -このようなテスト状態でシミングは一般的ですか?特にJavaScript環境で、私が知らないより良いアプローチはありますか?


もう一つの例:

さて、これが何が起こっているのかを説明するのに役立つより具体的な例です:

export class Tilemap extends Phaser.Tilemap {
    // layers is already defined in Phaser.Tilemap, so we use tilemapLayers instead.
    private tilemapLayers: TilemapLayers = {};

    // A TileMap can have any number of layers, but
    // we're only concerned about the existence of two.
    // The collidables layer has the information about where
    // a Player or Enemy can move to, and where he cannot.
    private CollidablesLayer = "Collidables";
    // Triggers are map events, anything from loading
    // an item, enemy, or object, to triggers that are activated
    // when the player moves toward it.
    private TriggersLayer    = "Triggers";

    private items: Array<Phaser.Sprite> = [];
    private creatures: Array<Phaser.Sprite> = [];
    private interactables: Array<ActivatableObject> = [];
    private triggers: Array<Trigger> = [];

    constructor(json: TilemapData) {
        // First
        super(json.game, json.key);

        // Second
        json.tilesets.forEach((tileset) => this.addTilesetImage(tileset.name, tileset.key), this);
        json.tileLayers.forEach((layer) => {
            this.tilemapLayers[layer.name] = this.createLayer(layer.name);
        }, this);

        // Third
        this.identifyTriggers();

        this.tilemapLayers[this.CollidablesLayer].resizeWorld();
        this.setCollisionBetween(1, 2, true, this.CollidablesLayer);
    }

私は3つの部分からタイルマップを作成します。

  • 地図の key
  • manifestマップに必要なすべてのアセット(タイルシートとスプライトシート)の詳細
  • A mapDefinitionタイルマップの構造や層が記載されています。

まず、スーパーを呼び出してPhaser内でTilemapを構築する必要があります。これは、で定義されたキーだけでなく、実際のアセットを検索しようとするときに、キャッシュへのすべての呼び出しを呼び出す部分ですmanifest

次に、タイルシートとタイルレイヤーをタイルマップに関連付けます。これでマップをレンダリングできます。

:第三に、私は私の層を反復処理し、私はマップから押し出すようにしたいという特別なオブジェクトを検索 CreaturesItemsInteractablesなどと。これらのオブジェクトを後で使用するために作成して保存します。

私は現在、これらのエンティティを検索、削除、更新できる比較的シンプルなAPIをまだ持っています。

    wallAt(at: TileCoordinates) {
        var tile = this.getTile(at.x, at.y, this.CollidablesLayer);
        return tile && tile.index != 0;
    }

    itemAt(at: TileCoordinates) {
        return _.find(this.items, (item: Phaser.Sprite) => _.isEqual(this.toTileCoordinates(item), at));
    }

    interactableAt(at: TileCoordinates) {
        return _.find(this.interactables, (object: ActivatableObject) => _.isEqual(this.toTileCoordinates(object), at));
    }

    creatureAt(at: TileCoordinates) {
        return _.find(this.creatures, (creature: Phaser.Sprite) => _.isEqual(this.toTileCoordinates(creature), at));
    }

    triggerAt(at: TileCoordinates) {
        return _.find(this.triggers, (trigger: Trigger) => _.isEqual(this.toTileCoordinates(trigger), at));
    }

    getTrigger(name: string) {
        return _.find(this.triggers, { name: name });
    }

確認したいのはこの機能です。タイルレイヤーまたはタイルセットを追加しない場合、マップはレンダリングされませんが、テストできる可能性があります。ただし、super(...)を呼び出しても、テストでは分離できないコンテキスト固有のロジックまたはステートフルロジックが呼び出されます。


2
よくわかりません。Phaserがタイルマップをロードする仕事をしていることをテストしようとしているか、タイルマップ自体のコンテンツをテストしようとしているか?前者の場合、通常、依存関係が機能することをテストしません。それは図書館のメンテナの仕事です。後者の場合、ゲームロジックがフレームワークに密結合しすぎています。パフォーマンスが許す限り、ゲームの内部の仕組みを純粋に保ち、副作用をプログラムの最上位層に任せて、この種の混乱を回避する必要があります。
Doval

いいえ、自分の機能をテストしています。テストがそのように見えなくて申し訳ありませんが、カバーの下に少し行きます。基本的に、私はタイルマップを調べて、アイテムやクリーチャーなどのゲームエンティティに変換する特別なタイルを見つけています。このロジックはすべて私のものであり、確実にテストする必要があります。
IAE

1
それでは、フェイザーがこれにどの程度関与しているかを説明できますか?Phaserが呼び出される場所と理由は不明です。地図はどこから来たの?
Doval

混乱してごめんなさい!テストしようとしている機能のユニットの例として、Tilemapコードを追加しました。Tilemapは、拡張機能(またはオプションで-a)を備えたPhaser.Tilemapです。これにより、使用したい追加の機能を多数備えたタイルマップをレンダリングできます。最後の段落では、それを単独でテストできない理由を強調しています。コンポーネントとしても、new Tilemap(...)Phaserがキャッシュを掘り始めた瞬間です。私はそれを延期する必要がありますが、それは私のタイルマップが2つの状態にあることを意味します。1つは適切にレンダリングできない状態、もう1つは完全に構築された状態です。
IAE、2014

最初のコメントで述べたように、ゲームロジックはフレームワークに結合されすぎているようです。フレームワークをまったく導入せずにゲームロジックを実行できるはずです。タイルマップを画面に描画するために使用されるアセットに結合することが邪魔になっています。
Doval

回答:


2

あなたが直面している問題は他の多くのフレームワークでも目に見える問題であるため、フェイザーやタイプスピットを知らなくても、私は答えを出そうとします。問題は、コンポーネントが密結合されていることです(すべてが神オブジェクトを指し、神オブジェクトがすべてを所有しています...)。これは、フレームワークの作成者がユニットテストを自分で作成した場合には起こりそうもないことです。

基本的に、4つのオプションがあります。

  1. ユニットテストを停止します。
    他のすべてのオプションが失敗しない限り、このオプションは選択しないでください。
  2. 別のフレームワークを選択するか、独自のフレームワークを作成してください。
    単体テストを使用していて、結合が失われている別のフレームワークを選択すると、作業が非常に簡単になります。しかし、おそらくあなたが好きなものは何もないので、あなたは今持っているフレームワークに行き詰まっています。自分で書くには、かなり時間がかかることがあります。
  3. フレームワークに貢献し、テストに適したものにします。
    おそらく最も簡単ですが、それは実際にどれだけの時間があるか、フレームワークの作成者がプルリクエストを受け入れるかどうかにかかっています。
  4. フレームワークをラップします。
    このオプションは、おそらくユニットテストを始めるのに最適なオプションです。単体テストで本当に必要な特定のオブジェクトをラップし、残りのオブジェクトを偽造します。

2

Davidのように、私はPhaserやTypescriptに精通していませんが、あなたの懸念はフレームワークとライブラリを使用した単体テストに共通していると認識しています。

簡単な答えは「はい」です。単体テストでこれを処理するには、シミングが正しく一般的な方法です。切り離しは、孤立した単体テストと機能テストの違いを理解していると思います。

ユニットテストは、コードの小さなセクションが正しい結果を生成することを証明します。単体テストの目標には、サードパーティのコードのテストは含まれていません。想定は、サードパーティが期待どおりに動作するようにコードが既にテストされていることです。フレームワークに依存するコードの単体テストを作成する場合、特定の依存関係をシムして、コードに対して特定の状態のように見えるものを準備したり、フレームワーク/ライブラリ全体をシムしたりするのが一般的です。簡単な例は、Webサイトのセッション管理です。おそらく、shimはストレージから読み取るのではなく、常に有効で一貫した状態を返します。もう1つの一般的な例は、メモリ内のデータをシムし、データベースにクエリを実行するライブラリをバイパスすることです。これは、接続に使用しているデータベースまたはライブラリをテストすることではなく、コードがデータを正しく処理することを目的としているためです。

ただし、適切な単体テストを行っても、エンドユーザーが期待どおりに表示されるわけではありません機能テストは、機能全体、フレームワークなどすべてが機能しているというより高レベルの見方をとります。単純なWebサイトの例に戻ると、機能テストはコードにWebリクエストを作成し、有効な結果について応答をチェックする場合があります。結果を生成するために必要なすべてのコードにまたがっています。テストは、特定のコードの正確さよりも機能のためのものです。

ユニットテストは順調に進んでいると思います。システム全体の機能テストを追加するには、Phaserランタイムを呼び出して結果を確認する個別のテストを作成します。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.