Unit-Tests Teil 2 - Unit-Tests für MonoBehaviours

Wie in meinem vorherigen Blogbeitrag „Unit-Tests Teil 1 – Unit-Tests nach Vorschrift“versprochen, widmet sich dieser dem Entwerfen von MonoBehaviours unter Berücksichtigung der Testbarkeit. MonoBehaviour ist eine Art spezielle Klasse, die von Unity auf besondere Weise behandelt wird. Jedes Mal, wenn Sie versuchen, ein MonoBehaviour Derivat zu instanziieren, erhalten Sie eine Warnung, dass dies nicht zulässig ist. Als guter Pfadfinder, der die Warnung nicht ignoriert (eine Warnung zu ignorieren ist auf lange Sicht schlecht!), haben Sie sich vielleicht die Frage gestellt: „Wie kann ich dann MonoBehaviour verspotten?“ Die gute Nachricht ist, dass Sie das nicht müssen! Darf ich Ihnen vorstellen...
Wenn Sie bereits versucht haben, Tests zu schreiben, sind Sie wahrscheinlich auf einige der natürlichen Feinde von Unit-Tests gestoßen, wie z. B. die Benutzeroberfläche, Legacy-Code, schlechtes Design ohne Zugriff auf den Quellcode oder Bereiche mit einem hohen Grad an Parallelität. Warum sind diese Teile schwer zu testen? Isolierung erreichen: das Geprüfte vom Kontext trennen. Es gibt Tools, die bei Legacy-Code hilfreich sein können, für neuen Code kann jedoch ein sehr einfaches Muster verwendet werden: Das Humble Object-Muster.
Die Idee hinter diesem Muster ist sehr einfach. Wenn Sie eine Komponente testen möchten, die Abhängigkeiten aufweist, die schwer zu testen sind, extrahieren Sie die gesamte Logik aus der Komponente in eine separate, entkoppelte (und somit testbare) Klasse und referenzieren Sie diese dann. Mit anderen Worten: Aus der problematischen Komponente (mit einer Abhängigkeit, die den Testautoren das Leben schwer macht) wird eine sehr dünne Codeschicht mit möglichst wenig Logikcode, bei der alle logischen Operationen an die neu erstellte Klasse delegiert werden.
Von einem Zustand, in dem der Test eine indirekte Abhängigkeit zur nicht testbaren Komponente aufweist …

... wir sind an einem Punkt angelangt, an dem der Test den fehlerhaften (also einfach nicht testbaren) Code nicht einmal erkennt:
Das ist so ziemlich alles. Ehrlich gesagt ist es ein Kinderspiel.
Was macht Spiele in Bezug auf Code und Testbarkeit so besonders? Wie unterscheidet sich das Testen von Spielen vom Testen anderer Software? Persönlich betrachte ich Spiele als ziemlich anspruchsvolle Software. Es wäre naiv zu behaupten, dass sich Spiele nicht wesentlich von der Software unterscheiden, die Sie täglich verwenden. In Spielen finden Sie (natürlich mit Ausnahmen) glänzende und auf Hochglanz polierte Grafiken, Hintergrundmusik und andere gut gemachte Soundbeispiele. Spiele müssen häufig Echtzeiteingaben, möglicherweise aus einer Vielzahl von Quellen, sowie eine Reihe von Ausgabegeräten (Leseauflösung) verarbeiten. Nicht-funktionale Anforderungen können für Spiele auch strenger sein. Für Multiplayer -Spiele benötigen Sie eine zuverlässige, synchronisierte Netzwerkverbindung und müssen gleichzeitig über die erforderliche Leistung verfügen, um eine konstante Bildrate aufrechtzuerhalten.
Dadurch kann ein komplexes System entstehen, das viele unterschiedliche Medien und Technologien berührt. Für mich waren Spiele immer Meisterwerke der Software-Endprodukte, und manche davon hatten den Anspruch, als Kunstwerke anerkannt zu werden (sowohl im klassischen visuellen Sinne als auch technisch hinter den Kulissen).
All diese Komplexität hat Konsequenzen für die Codearchitektur. Leider stehen Hochleistungsarchitekturen normalerweise einer guten Codegestaltung entgegen, eine Einschränkung, auf die Sie möglicherweise auch in Unity stoßen. Einer der Kernmechanismen, der speziell entwickelt werden musste, ist der MonoBehaviours- Mechanismus. Wenn Sie sich jemals gefragt haben, warum die Rückrufe in MonoBehaviours nicht mit Schnittstellen oder Vererbung implementiert werden (wie der gesunde Menschenverstand vielleicht nahelegt), liegt dies an Leistungsgründen (siehe die Klarstellung von Lucas Meijer in den Kommentaren). Ohne ins Detail zu gehen, wirkt sich dies auch negativ auf die Testbarkeit von MonoBehavioursaus. Die Tatsache, dass Sie mit dem neuen Operator kein MonoBehaviour instanziieren können, schließt praktisch aus, dass Sie irgendwelche Mocking-Frameworks verwenden können. Bei all den Dingen, die bei der Verwendung eines MonoBehaviour im Hintergrund passieren, wäre es wahrscheinlich sowieso keine gute Idee. Das Unterbinden dieses Verhaltens würde zahlreiche Probleme verursachen.
Letztendlich kommt es nur auf Sie an und darauf, wie motiviert Sie sind, testbaren Code zu schreiben. Viele Ansätze können dasselbe Problem lösen, aber nur wenige eignen sich gut für die Testautomatisierung. Wenn Sie testbaren Code schreiben möchten, müssen Sie manchmal mehr Code schreiben, als Sie für nötig halten. Wenn Sie noch lernen (sollten wir nicht sowieso unser ganzes Leben lang lernen?) oder gerade erst mit dem Abenteuer Testautomatisierung begonnen haben, empfinden Sie einige der Codeteile oder Designannahmen möglicherweise als unnötigen Mehraufwand. Dies wird jedoch so schnell zur Gewohnheit, dass Sie es nicht einmal bemerken, wenn Sie anfangen, die Pro-Automatisierungsdesigns zu verwenden, ohne darüber nachzudenken.
Ich habe versprochen, euch in diesem Blogbeitrag eine Möglichkeit zu zeigen, wie ihr MonoBehaviour so gestalten könnt, dass ihr es anschließend testen könnt. Das war nicht ganz richtig, da wir MonoBehaviours selbst nicht testen werden. Sie haben wahrscheinlich bereits eine Idee, wie Sie das Humble Object Pattern in Ihr Design implementieren können, um es besser testbar zu machen. Lassen Sie mich Ihnen dennoch die Idee in einem realen Projekt umgesetzt zeigen.
Lassen Sie uns für dieses Beispiel einen Anwendungsfall erstellen. Stellen Sie sich einen einfachen Spieler-Controller vor, der für die Steuerung eines Raumschiffs verantwortlich ist. Um das Beispiel zu vereinfachen, platzieren wir es in einem 2D-Weltraum. Wir möchten, dass das Raumschiff in jede Richtung fliegen kann. Es verfügt über eine Waffe, die geradlinige Kugeln abfeuern kann (Weltraumraketen?), jedoch nicht häufiger als eine bestimmte Feuerrate. Die Anzahl der Kugeln ist außerdem durch die Kapazität des Kugelhalters begrenzt, d. h., wenn Sie alle Kugeln verschossen haben, müssen Sie nachladen. Um es interessanter zu machen, machen wir die Bewegungsgeschwindigkeit vom Zustand des Raumschiffs abhängig.
Ein Monoverhalten , das als Controller für unser Raumschiff dient, könnte folgendermaßen aussehen:

Im FixedUpdate- Callback lesen wir die Eingabe und führen die Aktion aus, je nachdem, welche Tasten vom Benutzer gedrückt wurden. Um uns im Raumschiff zu bewegen, müssen wir die Position des Raumschiffs mit einer konstanten Geschwindigkeit entsprechend der Richtung der Achsen verschieben. Wie Sie im Code sehen können, sind die Variablen deltaX und deltaY Multiplikationen von: Time.fixedDeltaTime, der Wert von der Eingabeachse und die Geschwindigkeitskonstante, die selbst vom Gesundheitszustand abhängig ist.
Beim Fire1- Ereignis (z. B. Klick der linken Maustaste) möchten wir prüfen, ob es möglich ist, die Kugel abzufeuern. Zunächst muss mindestens eine Kugel im Kugelhalter verbleiben. Zweitens möchten wir, dass das Raumschiff nur mit einer bestimmten Geschwindigkeit schießt (in diesem Fall einmal pro halbe Sekunde). Daher prüfen wir, wie viel Zeit seit dem letzten Schuss vergangen ist. Wenn alles bereit ist, erzeugen wir die Kugel.
Das Fire2- Ereignis lädt einfach den Patronenhalter nach.
Um Unit-Tests für diese Logik zu schreiben, müssen wir zwei Probleme überwinden. Die erste ist, wie bereits erwähnt, die nicht verspottbare MonoBehaviour Klasse, von der wir durch Vererbung abhängig sind. Das zweite Problem betrifft Echtzeitsoftware allgemeiner. Unsere Logik ist zeitabhängig (Feuerrate), was es unmöglich macht, Komponententests durchzuführen, da wir die statische Zeitklasse von Unity nicht abfangen können. Die gute Nachricht ist, dass all diese Probleme gelöst werden können.
Lassen Sie uns unseren Code ein wenig umgestalten, indem wir eine einfache Refaktorisierung der Methodenextraktion anwenden und dabei bedenken, dass die Logikmethoden nicht auf die Unity API verweisen sollten (in diesem Fall Eingabeverarbeitung und Bullet-Instanziierung). Die Zeitabhängigkeit in der if-Anweisung sollte ebenfalls in eine separate Methode extrahiert werden. Das Endergebnis sollte ungefähr so aussehen:

Wie Sie sehen, macht die Methode FixedUpdate hier nichts anderes, als die Eingaben der Benutzer an die Methode weiterzuleiten, die den logischen Teil übernimmt. Die Überprüfung der Feuerrate wurde in die Methode CanFire extrahiert, die das Ergebnis „true“ generiert, wenn eine bestimmte Zeitspanne verstrichen ist. Diese Extraktion ist wichtig, da sie uns später das Schreiben von Unit-Tests ermöglicht. Wenn wir die Klasse „SpaceshipMotor“ jetzt simulieren könnten, würden wir einfach die Methode „CanFire“ abfangen und sie je nach Wunsch „true“ oder „false“ zurückgeben lassen. Dadurch würde der Test zeitunabhängig. Da wir SpaceshipMotor aber nicht simulieren können, weil es von Monobehaviour erbt, müssen wir das Humble Object Pattern anwenden.
Wie machen wir das? Wir müssen einfach den gesamten Logikcode, der die Unity API nicht verwendet, in eine separate Klasse extrahieren und einen Verweis darauf in den SpaceshipMotoreinführen. Schauen wir uns die Klasse noch einmal an und schauen wir, was extrahiert werden soll. TranformPosition und InstanciateBullet verwenden die Unity API , aber alles andere kann extrahiert werden. Ich weiß, dass es auch die statische Zeitklasse gibt, aber darauf werde ich später zurückkommen.
Bevor wir mit der eigentlichen Extraktion beginnen, müssen wir als Letztes erklären, wie die extrahierte Logik mit der Unity API kommuniziert, ohne von ihr abhängig zu sein. Hier kommen die Schnittstellen ins Spiel. Die Klasse mit Logik verfügt über einen Verweis auf eine Schnittstelle und die tatsächliche Implementierung ist mir egal. Um die Dinge einfach zu halten, können wir diese Schnittstellen direkt in MonoBehaviour selbst implementieren! Schauen wir uns die folgenden 2 Klassen an:


Beginnen wir mit der SpaceshipMotor -Klasse. Die Klasse implementierte einige Schnittstellen, die für die Transformation der Position unseres Raumschiffs bzw. die Instanziierung der Kugel verantwortlich sind. Die Klasse selbst hat ein Feld, das auf den SpaceshipController verweist, der jetzt die gesamte Logik implementiert. Die Klasse SpaceshipController weiß nichts über den SpaceshipMotor und kann lediglich Methoden der Schnittstellen aufrufen, auf die sie verweist.
Unity serialisiert keine Verweise auf die Schnittstellen. Wenn Sie sich nicht um die Serialisierung kümmern, übergeben Sie einfach die Schnittstellenreferenzen beim Erstellen der SpaceshipController -Klasse. Andernfalls können Sie die Referenzen im OnEnable- Rückruf festlegen, der jedes Mal nach der Serialisierung aufgerufen wird. Nur zur Info: Die gesamte SpaceshipMotor -Klasse wird auf die übliche Weise serialisiert, nur die Schnittstellenverweise gehen verloren.
Ihnen ist sicherlich der Verweis auf die Time- Klasse in SpaceshipMotoraufgefallen. Ich weiß, ich habe gesagt, hier sollte kein Verweis auf die Unity API enthalten sein, aber ich habe ihn dort gelassen, um einen anderen Ansatz zum Umgang mit zeitabhängigen Abhängigkeiten zu demonstrieren. Idealerweise könnten wir den Time.time- Wert einfach als Argument an die Methoden übergeben.
Für UML-Fans ist dies das Endergebnis als (vereinfachtes) UML-Diagramm:

Mit der entkoppelten SpaceshipMotor- Klasse hindert uns nichts daran, einige Unit-Tests zu schreiben. Schauen Sie sich einen der Tests an:

Der Test bestätigt, dass Sie nicht schießen können, wenn Sie keine Munition mehr haben. Der Test selbst ist nach dem Arrange-Act-Assert-Muster aufgebaut. Im Anordnungsteil erstellen wir Objektmocks mit den Methoden GetGunMock und GetControllerMock . GetControllerMockerstellt nicht nur einen Mock, sondern überschreibt auch das Verhalten der CanFire- Methode, um immer „true“ zurückzugeben. Dadurch wird die Zeitabhängigkeit vom Controllerobjekt entfernt. Als nächstes setzen wir die aktuelle Aufzählungsnummer auf 0. Danach wenden wir „Fire“ auf die Controller-Klasse an und stellen fest, ob „Fire“ auf der Schnittstelle des Gun-Controllers nicht aufgerufen wurde.
Es gibt noch einige weitere Tests im Projekt. Sie können es von hier aus greifen und ein wenig damit spielen. Ich habe NSubstitute für das Mocking-Objekt verwendet. Wir liefern auch eine Version davon mit den Unity Test Tools. Alle drei hier besprochenen Versionen des Controllers sind im Projekt enthalten.
Das war es für heute von mir. Ich hoffe, die Lektüre hat Ihnen Spaß gemacht, und wünsche Ihnen viel Spaß beim Testen!
Tomek
