Модульное тестирование структуры с состоянием, такой как Phaser?

9

TL; DR Мне нужна помощь в определении методов для упрощения автоматического модульного тестирования при работе в среде с состоянием.


Фон:

В настоящее время я пишу игру на TypeScript и в инфраструктуре Phaser . Phaser описывает себя как игровую среду HTML5, которая старается как можно меньше ограничивать структуру вашего кода. Это имеет несколько компромиссов, а именно, что существует объект- бог Phaser.Game, который позволяет вам получить доступ ко всему: кешу, физике, игровым состояниям и многому другому .

Это состояние делает очень трудным тестирование многих функций, таких как мой Tilemap. Давайте посмотрим на пример:

Здесь я проверяю, правильно ли мои слои листов, и я могу идентифицировать стены и существа в моей Tilemap:

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который запускает тест со всеми активами и кэшированными данными, уже загруженными.

Это круто, потому что я могу протестировать все функциональные возможности, которые я хочу, но также и откатать, потому что это технический интеграционный тест, и возникает вопрос, не мог ли я просто посмотреть на экран и посмотреть, отображаются ли враги. На самом деле, нет, они могли быть ошибочно идентифицированы как Предмет (случился уже один раз), или - позже в тестах - им могли не дать события, связанные с их смертью.

Мой вопрос - это мерцание в тестовом состоянии, как это распространено? Есть ли лучшие подходы, особенно в среде 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);
    }

Я строю свою Tilemap из трех частей:

  • Карта key
  • В manifestдетализирующем всех активы (tilesheets и spritesheets) требуемый карта
  • А, mapDefinitionкоторый описывает структуру и слои тайла карты.

Во-первых, я должен вызвать super для создания Tilemap внутри Phaser. Это та часть, которая вызывает все эти вызовы кеша при попытке поиска реальных ресурсов, а не только ключей, определенных в manifest.

Во-вторых, я связываю листы листов и слои листов с картой листов. Теперь он может визуализировать карту.

В- третьих, я итерация через мои слои и найти какие - либо специальные объекты , которые я хочу , чтобы выдавливать из карты: Creatures, Items, Interactablesи так далее. Я создаю и храню эти объекты для последующего использования.

В настоящее время у меня все еще есть относительно простой 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 (...) вызывает контекстно-зависимую логику или логику с состоянием, которую я не могу выделить в своих тестах.

IAE
источник
2
Я смущен. Вы пытаетесь проверить, выполняет ли Phaser свою работу по загрузке карты тайлов, или вы пытаетесь протестировать содержимое самой карты тайлов? Если это первое, вы обычно не проверяете, что ваши зависимости выполняют свою работу; это работа библиотекаря. Если последнее, ваша игровая логика слишком тесно связана с фреймворком. Столько, сколько позволит производительность, вы хотите сохранить внутреннюю работу вашей игры в чистоте и оставить побочные эффекты на верхних уровнях программы, чтобы избежать такого беспорядка.
Доваль
Нет, я проверяю свою собственную функциональность. Извините, если тесты не выглядят так, но есть кое-что под одеялом. По сути, я просматриваю карту тайлов и нахожу специальные тайлы, которые я превращаю в игровые объекты, такие как Предметы, Существа и так далее. Эта логика полностью моя и обязательно должна быть проверена.
IAE
1
Можете ли вы объяснить, как именно Фазер участвует в этом? Мне не ясно, где вызывается Фазер и почему. Откуда берется карта?
Доваль
Я извиняюсь за путаницу! Я добавил свой код Tilemap в качестве примера функциональности, которую я пытаюсь протестировать. Tilemap - это расширение (или опционально has-a) Phaser.Tilemap, которое позволяет мне рендерить карту тайлов с набором дополнительных функций, которые я хотел бы использовать. Последний абзац подчеркивает, почему я не могу проверить это изолированно. Даже будучи компонентом, в тот момент, когда я просто new Tilemap(...)Phaser начинает копаться в своем кеше. Я должен был бы отложить это, но это означает, что мой Tilemap находится в двух состояниях: одно, которое не может отображаться правильно, и полностью построенное.
IAE
Мне кажется, что, как я сказал в своем первом комментарии, ваша игровая логика слишком связана с фреймворком. Вы должны быть в состоянии запустить свою игровую логику, не вводя каркас вообще. Связывание карты тайлов с активами, используемыми для рисования на экране, мешает.
Доваль

Ответы:

2

Не зная Phaser или Typescipt, я все еще пытаюсь дать вам ответ, потому что проблемы, с которыми вы сталкиваетесь, - это проблемы, которые также видны во многих других средах. Проблема в том, что компоненты тесно связаны (все указывает на объект Бога, а объект Бога владеет всем ...). Это вряд ли произойдет, если создатели фреймворка сами создадут юнит-тесты.

В основном у вас есть четыре варианта:

  1. Прекратить юнит-тестирование.
    Эти параметры не должны выбираться, если все остальные параметры не будут выполнены.
  2. Выберите другой фреймворк или напишите свой.
    Выбор другого фреймворка, использующего модульное тестирование и потерявшего связь, значительно облегчит жизнь. Но, возможно, нет ни одного, который вам нравится, и поэтому вы застряли в рамках, которые у вас есть сейчас. Написание собственного может занять много времени.
  3. Внесите свой вклад в каркас и сделайте его дружественным к тестированию.
    Возможно, это проще всего сделать, но это действительно зависит от того, сколько у вас есть времени и насколько готовы создатели фреймворка принимать запросы на извлечение.
  4. Оберните рамки.
    Этот вариант, вероятно, является лучшим вариантом для начала модульного тестирования. Оберните определенные объекты, которые вам действительно нужны в модульных тестах, и создайте поддельные объекты для остальных.
Дэвид Перформс
источник
2

Как и Дэвид, я не знаком с Phaser или Typescript, но я понимаю ваши опасения как общие для модульного тестирования с фреймворками и библиотеками.

Короткий ответ - да, шимминг - это правильный и распространенный способ справиться с этим с помощью модульного тестирования . Я думаю, что разъединение понимает разницу между изолированным модульным тестированием и функциональным тестированием.

Модульное тестирование доказывает, что небольшие разделы вашего кода дают правильные результаты. Цель юнит-теста не включает тестирование стороннего кода. Предполагается, что код уже протестирован и работает так, как ожидается третьей стороной. При написании модульного теста для кода, основанного на фреймворке, принято смешивать определенные зависимости, чтобы подготовить то, что выглядит как определенное состояние к коду, или полностью экранировать фреймворк / библиотеку. Простым примером является управление сессиями для веб-сайта: возможно, оболочка всегда возвращает действительное согласованное состояние вместо чтения из хранилища. Другим распространенным примером является отбрасывание данных в памяти и обход любой библиотеки, которая будет запрашивать базу данных, поскольку цель состоит не в том, чтобы протестировать базу данных или библиотеку, которую вы используете для подключения к ней, а в том, что ваш код обрабатывает данные правильно.

Но хорошее модульное тестирование не означает, что конечный пользователь увидит именно то, что вы ожидаете. Функциональное тестирование предполагает более высокое представление о том, что работает целая функция, фреймворки и все. Возвращаясь к примеру простого веб-сайта, функциональный тест может сделать веб-запрос к вашему коду и проверить ответ на наличие действительных результатов. Он охватывает весь код, необходимый для получения результатов. Тест на функциональность больше, чем на конкретную правильность кода.

Так что я думаю, что вы на правильном пути с модульным тестированием. Чтобы добавить функциональное тестирование всей системы, я бы создал отдельные тесты, которые запускают среду выполнения Phaser и проверяют результаты.

Мэтт С
источник