Tests unitaires partie 2 - Tests unitaires MonoBehaviour

TOMEK PASZEK Anonymous
Jun 3, 2014|10 Min
Tests unitaires partie 2 - Tests unitaires MonoBehaviour
Cette page a été traduite automatiquement pour faciliter votre expérience. Nous ne pouvons pas garantir l'exactitude ou la fiabilité du contenu traduit. Si vous avez des doutes quant à la qualité de cette traduction, reportez-vous à la version anglaise de la page web.

Comme promis dans mon billet précédent Unit testing part 1 - Unit tests by the book, celui-ci est dédié à la conception de MonoBehaviour avec la testabilité à l'esprit. MonoBehaviour est en quelque sorte une classe spéciale qui est gérée par Unity d'une manière particulière. Chaque fois que vous essayez d'instancier un dérivé de MonoBehaviour, vous recevez un avertissement indiquant qu'il n'est pas autorisé. En bon boy-scout et sans ignorer l'avertissement (ignorer un avertissement est mauvais à long terme !), vous vous êtes peut-être posé la question suivante : comment puis-je me moquer de MonoBehaviour dans ce cas ? La bonne nouvelle, c'est que vous n'avez pas à le faire ! Laissez-moi vous présenter...

Le modèle de l'objet humble

Si vous avez déjà essayé d'écrire des tests, vous êtes probablement tombé sur certains des ennemis naturels des tests unitaires, comme l'interface utilisateur, le code hérité, une mauvaise conception sans accès au code source ou des domaines présentant un degré élevé de concurrence. Pourquoi ces pièces sont-elles difficiles à tester ? Isolation : séparer ce qui est testé du contexte. Il existe des outils qui peuvent être utiles pour les anciens codes, mais pour les nouveaux codes, un modèle très simple peut être utilisé : Le modèle de l'objet humble.

L'idée de ce modèle est très simple. Chaque fois que vous voulez tester un composant qui a des dépendances difficiles à tester, extrayez toute la logique du composant dans une classe séparée et découplée (donc testable) et faites-y référence. En d'autres termes, le composant problématique (avec une dépendance qui rend la vie des auteurs de tests misérable) devient une très fine couche de code qui contient aussi peu de code logique que possible, toutes les opérations logiques étant déléguées à la classe nouvellement créée.

D'un état où le test a une dépendance indirecte au composant non testable...

example-dependancy1

...nous en sommes arrivés à un stade où le test n'est même pas conscient du mauvais code (enfin, du code non testable) :

C'est à peu près tout. Pour être honnête, c'est une évidence.

Jeux et testabilité

Qu'est-ce qui rend les jeux si particuliers en termes de code et de testabilité ? En quoi les tests de jeux diffèrent-ils des tests d'autres logiciels ? Personnellement, je considère les jeux comme des logiciels assez sophistiqués. Il serait naïf de dire que les jeux ne sont pas très différents des logiciels que vous utilisez tous les jours. Dans les jeux (à quelques exceptions près, bien sûr), vous trouverez des graphismes brillants et soignés, une musique de fond et d'autres échantillons sonores bien conçus. Les jeux doivent souvent gérer des données en temps réel, provenant potentiellement de diverses sources, ainsi qu'une gamme de périphériques de sortie (résolution de lecture). Les exigences non fonctionnelles peuvent également être plus strictes pour les jeux. Les jeux Multiplayer vous obligeront à disposer d'une connexion réseau fiable et synchronisée tout en conservant les performances nécessaires pour maintenir un taux de rafraîchissement constant.

Il s'agit donc d'un système complexe qui fait appel à de nombreux types de médias et de technologies. Pour moi, les jeux ont toujours été des chefs-d'œuvre de logiciel, certains d'entre eux aspirant à être reconnus comme des œuvres d'art (d'un point de vue classique et visuel, mais aussi d'un point de vue technique, en coulisses).

Unity contre testabilité

Toute cette complexité a des conséquences sur l'architecture du code. Pour notre malheur, les architectures à haute performance vont généralement à l'encontre d'une bonne conception du code, une restriction que vous pouvez également rencontrer dans Unity. Le mécanisme MonoBehaviour est l'un des mécanismes de base qui a dû être conçu de manière particulière. Si vous vous êtes déjà demandé pourquoi les callbacks dans MonoBehaviour ne sont pas implémentés avec des interfaces ou l'héritage (comme le suggère le bon sens), c'est pour des raisons de performance (voir la clarification de Lucas Meijer dans les commentaires). Sans entrer dans les détails, cela va également à l'encontre de la testabilité de MonoBehaviour. Le fait que vous ne puissiez pas instancier un MonoBehaviour avec l'opérateur new vous interdit pratiquement d'utiliser les frameworks de mocking existants. Ce ne serait probablement pas une bonne idée de toute façon avec toutes les choses qui se passent derrière la scène chaque fois qu'un MonoBehaviour est utilisé. L'interception de ce comportement générerait de nombreux problèmes.

Vous contre la testabilité

En fin de compte, tout dépend de vous et de votre motivation à écrire un code testable. De nombreuses approches peuvent résoudre le même problème, mais seules quelques-unes fonctionneront bien pour l'automatisation des tests. Si vous voulez écrire un code testable, vous devrez parfois écrire plus de code que vous ne le pensez nécessaire. Si vous êtes encore en train d'apprendre (ne devrions-nous pas apprendre toute notre vie, de toute façon ?) ou si vous venez de vous lancer dans l'aventure de l'automatisation des tests, vous trouverez peut-être que certains morceaux de code ou certaines hypothèses de conception sont des frais généraux inutiles. Toutefois, ces derniers deviennent si rapidement une habitude que vous ne remarquerez même pas que vous commencez à utiliser les modèles pro-automation sans même y penser.

Dans cet article de blog, j'ai promis de vous montrer une façon de concevoir des MonoBehaviour pour pouvoir les tester par la suite. Ce n'était pas tout à fait vrai, car nous ne testerons pas les MonoBehaviour eux-mêmes. Vous avez probablement déjà une idée de la façon d'appliquer le Humble Object Pattern à votre conception pour la rendre plus testable, mais laissez-moi néanmoins vous montrer l'idée mise en œuvre dans un projet réel.

L'exemple

Créons un cas d'utilisation pour les besoins de cet exemple. Imaginez un simple contrôleur de joueur chargé de diriger un vaisseau spatial. Pour simplifier l'exemple, plaçons-le dans un espace mondial en 2D. Nous voulons que le vaisseau spatial puisse voler dans toutes les directions. Il possède un pistolet qui peut tirer des balles (fusées spatiales ?) en ligne droite, mais pas plus fréquemment qu'une cadence de tir donnée. Le nombre de balles est également limité par la capacité du porte-balles, de sorte qu'une fois que vous les avez toutes tirées, vous devez les recharger. Pour rendre les choses plus intéressantes, faisons en sorte que la vitesse de déplacement dépende de l'état de santé du vaisseau.

Un MonoBehaviour qui servira de contrôleur pour notre vaisseau spatial pourrait ressembler à ceci :

Image

Dans le callback FixedUpdate, nous lisons l'entrée et effectuons l'action en fonction des boutons sur lesquels l'utilisateur a appuyé. Pour se déplacer autour du vaisseau spatial, il faut traduire la position du vaisseau avec une vitesse constante en fonction de la direction des axes. Comme vous pouvez le voir dans le code, les variables deltaX et deltaY sont des multiplications de : Time.fixedDeltaTime, la valeur de l'axe d'entrée et la constante de vitesse qui dépend elle-même du niveau de santé.

Lors de l'événement Fire1 (par exemple, un clic sur le bouton gauche de la souris), nous voulons vérifier s'il est possible de tirer sur la balle. Tout d'abord, il faut qu'il reste au moins une balle dans le porte-balles. Deuxièmement, nous voulons que le vaisseau spatial ne puisse tirer qu'à un certain rythme (une fois toutes les demi-secondes dans ce cas). Nous vérifions donc combien de temps s'est écoulé depuis la dernière balle tirée. Si nous sommes prêts à partir, nous frayons la balle.

L'événement Fire2 permet simplement de recharger le porte-balles.

Pour écrire des tests unitaires pour cette logique, nous devons surmonter deux problèmes. La première, comme indiqué précédemment, est la classe non-mockable MonoBehaviour dont nous dépendons via l'héritage. Le second problème est plus général pour les logiciels en temps réel. Notre logique dépend du temps (taux d'exécution), ce qui rend impossible la réalisation de tests unitaires puisque nous ne pouvons pas intercepter la classe statique Time de Unity. La bonne nouvelle, c'est que tout cela peut être résolu.

Refactorisons un peu notre code en appliquant quelques méthodes simples d'extraction et en gardant à l'esprit que les méthodes logiques ne doivent pas faire référence à l'API Unity (la gestion des entrées et l'instanciation des balles dans ce cas). La dépendance temporelle dans l'instruction if devrait également être extraite dans une méthode distincte. Le résultat final devrait ressembler plus ou moins à ceci :

example2

Comme vous pouvez le constater, la méthode FixedUpdate ne fait rien d'autre que de transmettre les données des utilisateurs à la méthode qui se charge de la partie logique. La vérification de la cadence de tir a été extraite de la méthode CanFire, qui génère le résultat "true" si un certain temps s'est écoulé. Cette extraction est importante car elle nous permettra d'écrire des tests unitaires par la suite. Si nous pouvions simuler la classe SpaceshipMotor maintenant, nous pourrions simplement intercepter la méthode CanFire et lui faire renvoyer true ou false à chaque fois que nous le souhaiterions. Cela rendrait le test indépendant du temps. Mais comme nous ne pouvons pas nous moquer de SpaceshipMotor parce qu'il hérite de MonoBehaviour, nous devons appliquer le Humble Object Pattern.

Comment ? Nous devons simplement extraire tout le code logique qui n'utilise pas l'API Unity dans une classe séparée et introduire une référence à celle-ci dans le SpaceshipMotor. Examinons à nouveau la classe et voyons ce qu'il faut en extraire. TranformPosition et InstanciateBullet utilisent l'API Unity, mais tout le reste peut être extrait. Je sais qu'il existe aussi la classe statique Time, mais j'y reviendrai plus tard.

La dernière chose qu'il nous reste à expliquer avant de procéder à l'extraction proprement dite est la manière dont la logique extraite communique avec l'API Unity sans en dépendre. C'est ici qu'interviennent les interfaces. La classe logique fera référence à une interface, et je ne me préoccuperai pas de l'implémentation réelle. Pour simplifier les choses, nous pouvons mettre en œuvre ces interfaces directement dans MonoBehaviour lui-même ! Examinons les deux classes suivantes :

Exemple3
Exemple4

Commençons par la classe SpaceshipMotor. La classe met en œuvre certaines interfaces qui sont responsables de la transformation de la position du vaisseau spatial et de l'instanciation de la balle, respectivement. La classe elle-même a un champ qui fait référence au SpaceshipController qui implémente toute la logique maintenant. La classe SpaceshipController ne sait rien du SpaceshipMotor et la seule chose qu'elle peut faire est d'invoquer les méthodes des interfaces qu'elle référence.

Unity ne sérialise pas les références aux interfaces. Si vous ne vous souciez pas de la sérialisation, passez simplement les références de l'interface lors de la construction de la classe SpaceshipController. Sinon, vous pouvez définir les références dans le callback OnEnable qui est appelé à chaque fois après la sérialisation. Pour information, toute la classe SpaceshipMotor sera sérialisée de la manière habituelle, seules les références à l'interface seront perdues.

Vous avez dû remarquer la référence à la classe Time dans SpaceshipMotor. Je sais que j'ai dit qu'il ne devait pas y avoir de référence à l'API Unity ici, mais je l'ai laissée pour montrer une approche différente de la gestion des dépendances temporelles. Idéalement, nous pourrions simplement transmettre la valeur Time.time comme argument aux méthodes.

Pour les amateurs d'UML, voici le résultat final sous la forme d'un diagramme UML (simplifié) :

example-uml1
Tests unitaires

Avec la classe découplée SpaceshipMotor, rien ne nous empêche d'écrire des tests unitaires. Jetez un coup d'œil à l'un des tests :

Exemple5

Le test valide le fait que l'on ne peut pas tirer si l'on n'a plus de balles. Le test lui-même est structuré selon le modèle Arrangement-Action-Assert. Dans la partie "arrangement", nous créons des objets fantômes avec les méthodes GetGunMock et GetControllerMock. Le GetControllerMock, en plus de créer un simulacre, modifie le comportement de la méthode CanFire pour qu'elle renvoie toujours true. Cela permet de supprimer la dépendance temporelle de l'objet contrôleur. Ensuite, nous fixons le numéro de la balle actuelle à 0. Ensuite, nous appliquons la fonction Fire à la classe du contrôleur et nous affirmons que Fire n'a pas été appelé sur l'interface du contrôleur de pistolet.

Le projet comporte encore quelques tests. Vous pouvez l'obtenir ici et jouer un peu avec. J'ai utilisé NSubstitute pour l'objet de simulation. Nous en livrons également une version avec les Unity Test Tools. Les trois versions du contrôleur dont nous avons parlé ici sont jointes au projet.

C'est tout pour aujourd'hui. J'espère que vous avez apprécié cette lecture et je vous souhaite de bons tests !

Tomek