Testowanie struktur stanowych, takich jak Phaser?


9

TL; DR Potrzebuję pomocy w identyfikacji technik upraszczających zautomatyzowane testowanie jednostek podczas pracy w ramach stanowych.


Tło:

Obecnie piszę grę w TypeScript i frameworku Phaser . Phaser opisuje się jako środowisko gry HTML5, które stara się jak najmniej ograniczać strukturę twojego kodu. Wiąże się to z kilkoma kompromisami, a mianowicie, że istnieje boski obiekt Phaser.Game, który umożliwia dostęp do wszystkiego: pamięci podręcznej, fizyki, stanów gry i innych.

Ta stan sprawia, że ​​naprawdę trudno jest przetestować wiele funkcji, takich jak moja Tilemap. Zobaczmy przykład:

Tutaj sprawdzam, czy moje warstwy kafelków są prawidłowe, i mogę zidentyfikować ściany i stworzenia w mojej Tilemapie:

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]
            ]);

Niezależnie od tego, co zrobię, gdy tylko spróbuję utworzyć mapę, Phaser wewnętrznie wywołuje jej pamięć podręczną, która jest zapełniana tylko podczas działania.

Nie mogę wywołać tego testu bez załadowania całej gry.

Złożonym rozwiązaniem może być napisanie adaptera lub serwera proxy, który buduje mapę tylko wtedy, gdy musimy wyświetlić ją na ekranie. Albo mógłbym sam zapełnić grę, ręcznie ładując tylko zasoby, których potrzebuję, a następnie używając go tylko dla określonej klasy testowej lub modułu.

Wybrałem to, co uważam za bardziej pragmatyczne, ale obce rozwiązanie tego problemu. Pomiędzy ładowaniem mojej gry a faktycznym jej odtwarzaniem przeskoczyłem TestStatew, który uruchamia test ze wszystkimi zasobami i danymi z pamięci podręcznej już załadowanymi.

To jest fajne, ponieważ mogę przetestować wszystkie funkcje, których chcę, ale także odłączyć, ponieważ jest to techniczny test integracji i zastanawiam się, czy nie mogę po prostu spojrzeć na ekran i sprawdzić, czy wrogowie są wyświetlani. W rzeczywistości nie, mogli zostać błędnie zidentyfikowani jako Przedmiot (zdarzyło się to już raz) lub - później w testach - mogli nie otrzymać wydarzeń związanych z ich śmiercią.

Moje pytanie - czy shimming w stanie testowym jest taki powszechny? Czy istnieją lepsze podejścia, zwłaszcza w środowisku JavaScript, o których nie wiem?


Inny przykład:

OK, oto bardziej konkretny przykład, który pomoże wyjaśnić, co się dzieje:

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);
    }

Skonstruowałem Tilemap z trzech części:

  • Mapy key
  • Szczegółowy manifestopis wszystkich zasobów (arkuszy i arkuszy) wymaganych przez mapę
  • A, mapDefinitionktóry opisuje strukturę i warstwy tilemapy.

Po pierwsze, muszę zadzwonić do super, aby zbudować Tilemap w Phaserze. Jest to część, która wywołuje wszystkie te połączenia do pamięci podręcznej, gdy próbuje wyszukać rzeczywiste zasoby, a nie tylko klucze zdefiniowane w pliku manifest.

Po drugie, łączę arkusze i warstwy płytek z Tilemap. Może teraz renderować mapę.

Po trzecie, iterację moich warstw i znaleźć jakieś specjalne przedmioty, które chcę do wyciągnięcia z mapy: Creatures, Items, Interactablesi tak dalej. Te obiekty tworzę i przechowuję do późniejszego wykorzystania.

Obecnie nadal mam stosunkowo prosty interfejs API, który pozwala mi znajdować, usuwać i aktualizować te podmioty:

    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 });
    }

Właśnie tę funkcjonalność chcę sprawdzić. Jeśli nie dodam warstw lub zestawów kafelków, mapa nie będzie renderowana, ale być może będę w stanie ją przetestować. Jednak nawet wywołanie super (...) wywołuje logikę kontekstową lub stanową, której nie mogę wyodrębnić w moich testach.


2
Jestem zmieszany. Czy próbujesz przetestować, czy Phaser wykonuje ładowanie mapy tilemap, czy próbujesz przetestować zawartość samej mapy tilemap? Jeśli jest to pierwsze, zazwyczaj nie testujesz, czy twoje zależności wykonują swoją pracę; to zadanie opiekuna biblioteki. Jeśli to drugie, twoja logika gry jest zbyt ściśle powiązana z frameworkiem. O ile pozwala na to wydajność, chcesz zachować wewnętrzne funkcjonowanie gry i pozostawić efekty uboczne na najwyższych warstwach programu, aby uniknąć tego rodzaju bałaganu.
Doval,

Nie, testuję własną funkcjonalność. Przepraszam, jeśli testy tak nie wyglądają, ale pod przykryciem jest trochę. Zasadniczo przeglądam mapę tilem i odkrywam specjalne kafelki, które przekształcam w elementy gry, takie jak Przedmioty, Stworzenia i tak dalej. Ta logika jest cała moja i na pewno musi zostać przetestowana.
IAE

1
Czy możesz zatem wyjaśnić, w jaki sposób Phaser jest w to zaangażowany? Nie jest dla mnie jasne, gdzie wywoływana jest Phaser i dlaczego. Skąd pochodzi mapa?
Doval,

Przepraszam za zamieszanie! Dodałem kod Tilemap jako przykład jednostki funkcjonalności, którą próbuję przetestować. Tilemap jest rozszerzeniem (lub opcjonalnie ma-a) Phasera. Tilemap, który pozwala mi renderować mapę z wieloma dodatkowymi funkcjami, których chciałbym użyć. Ostatni akapit podkreśla, dlaczego nie mogę go przetestować w izolacji. Nawet jako składnik, w chwili, gdy po prostu new Tilemap(...)Phaser zaczyna kopać w swojej pamięci podręcznej. Musiałbym to odłożyć, ale to oznacza, że ​​moja Mapa Tilemap jest w dwóch stanach, jednym, który nie może się odpowiednio oddać, i w pełni zbudowanym.
IAE

Wydaje mi się, że, jak powiedziałem w moim pierwszym komentarzu, twoja logika gry jest zbyt sprzężona z frameworkiem. Powinieneś być w stanie uruchomić logikę gry w ogóle bez włączania frameworka. Przeszkadza połączenie mapy kafelków z zasobami używanymi do narysowania jej na ekranie.
Doval,

Odpowiedzi:


2

Nie znając Phasera ani Typescipt, wciąż próbuję dać ci odpowiedź, ponieważ problemy, z którymi się borykasz, są problemami widocznymi również w wielu innych frameworkach. Problem polega na tym, że komponenty są ściśle ze sobą powiązane (wszystko wskazuje na obiekt Boga, a obiekt Boga jest właścicielem wszystkiego ...). Jest to mało prawdopodobne, jeśli twórcy frameworka sami stworzą testy jednostkowe.

Zasadniczo masz cztery opcje:

  1. Zatrzymaj testowanie jednostkowe.
    Tych opcji nie należy wybierać, chyba że zawiodą wszystkie inne opcje.
  2. Wybierz inny framework lub napisz własny.
    Wybór innego frameworka, który korzysta z testów jednostkowych i ma utratę sprzężenia, znacznie ułatwi życie. Ale być może nie ma nic, co by ci się podobało i dlatego utknąłeś w ramach, które masz teraz. Pisanie własnego może zająć dużo czasu.
  3. Wnieś wkład do frameworka i uczyń go przyjaznym dla testów.
    Prawdopodobnie najłatwiejszy do zrobienia, ale tak naprawdę zależy to od tego, ile czasu masz i od tego, jak chętni twórcy frameworka są w stanie zaakceptować żądania ściągnięcia.
  4. Zwiń ramy.
    Ta opcja jest prawdopodobnie najlepszą opcją na rozpoczęcie testów jednostkowych. Zawiń niektóre obiekty, których naprawdę potrzebujesz w testach jednostkowych, i stwórz fałszywe obiekty do końca.

2

Podobnie jak David, nie znam Phasera ani Maszynopisu, ale dostrzegam twoje obawy jako wspólne dla testów jednostkowych z frameworkami i bibliotekami.

Krótka odpowiedź brzmi: tak, shimming jest prawidłowym i powszechnym sposobem radzenia sobie z tym przy testowaniu jednostkowym . Myślę, że odłączenie rozumie różnicę między izolowanymi testami jednostkowymi a testami funkcjonalnymi.

Testy jednostkowe dowodzą, że małe fragmenty kodu dają prawidłowe wyniki. Celem testu jednostkowego nie jest testowanie kodu innej firmy. Zakładamy, że kod został już przetestowany pod kątem działania zgodnie z oczekiwaniami innej firmy. Podczas pisania testu jednostkowego kodu opartego na frameworku często shimuje się pewne zależności, aby przygotować kod, który wygląda jak określony stan, lub całkowicie shimuje framework / bibliotekę. Prostym przykładem jest zarządzanie sesją dla witryny internetowej: być może podkładka zawsze zwraca prawidłowy, spójny stan zamiast odczytu z pamięci. Innym częstym przykładem jest pomijanie danych w pamięci i omijanie dowolnej biblioteki, która zapytałaby bazę danych, ponieważ celem nie jest testowanie bazy danych lub biblioteki, której używasz do łączenia się z nią, a jedynie prawidłowe przetwarzanie danych w kodzie.

Ale dobre testy jednostkowe nie oznaczają, że użytkownik końcowy zobaczy dokładnie to, czego oczekujesz. Testy funkcjonalne mają bardziej ogólny widok, że działa cała funkcja, frameworki i inne. Wracając do przykładu prostej witryny, test funkcjonalny może wysłać żądanie do kodu i sprawdzić poprawność wyników. Obejmuje cały kod wymagany do uzyskania wyników. Test dotyczy funkcjonalności bardziej niż konkretnej poprawności kodu.

Myślę więc, że jesteś na dobrej drodze z testami jednostkowymi. Aby dodać testowanie funkcjonalne całego systemu, stworzyłbym osobne testy, które wywołują środowisko wykonawcze Phaser i sprawdzają wyniki.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.