Code de refactoring Legacy Partie 5 - Méthodes testables du jeu

Ancien code. Code laid. Code compliqué. Code spaghetti. Gibberish absurdité. En deux mots, Code hérité. C’est une série qui vous aidera à travailler et à vous en occuper.

Dans notre précédent tutoriel, nous avons testé nos fonctions Runner. Dans cette leçon, il est temps de continuer là où nous nous sommes arrêtés en testant notre Jeu classe. Maintenant, lorsque vous commencez avec une aussi grosse quantité de code que celle que nous avons ici, il est tentant de commencer à tester de manière descendante, méthode par méthode. C'est la plupart du temps impossible. Il vaut bien mieux commencer à le tester avec ses méthodes courtes et testables. Voici ce que nous allons faire dans cette leçon: trouver et tester ces méthodes.

Créer un jeu

Afin de tester une classe, nous devons initialiser un objet de ce type spécifique. Nous pouvons considérer que notre premier test consiste à créer un tel nouvel objet. Vous serez surpris du nombre de secrets que les constructeurs peuvent cacher.

require_once __DIR__. '/… /Trivia/php/Game.php'; La classe GameTest étend PHPUnit_Framework_TestCase function testWeCanCreateAGame () $ game = new Game (); 

À notre surprise, Jeu peut effectivement être créé assez facilement. Aucun problème en cours d'exécution juste nouveau jeu(). Rien ne casse. C'est un très bon début, d'autant plus que JeuLe constructeur de est assez grand et il fait beaucoup de choses.

Trouver la première méthode testable

Il est tentant de simplifier le constructeur pour le moment. Mais nous n'avons que le maître d'or pour nous assurer de ne rien casser. Avant de passer au constructeur, nous devons tester la majeure partie du reste de la classe. Alors, par où devrions-nous commencer?

Recherchez la première méthode qui renvoie une valeur et demandez-vous "Puis-je appeler et contrôler la valeur de retour de cette méthode?". Si la réponse est oui, c'est un bon candidat pour notre test.

function isPlayable () $ minimumNumberOfPlayers = 2; return ($ this-> howManyPlayers ()> = $ minimumNumberOfPlayers); 

Qu'en est-il de cette méthode? Cela semble être un bon candidat. Seulement deux lignes et il retourne une valeur booléenne. Mais attendez, il appelle une autre méthode, combien de joueurs().

function howManyPlayers () nombre de retours ($ this-> players); 

Ceci est fondamentalement juste une méthode qui compte les éléments de la classe ' joueurs tableau. OK, donc si on n'ajoute aucun joueur, il devrait être nul. isPlayable () devrait retourner faux. Voyons si notre hypothèse est correcte.

fonction testAJustCreatedNewGameIsNotPlayable () $ game = new Game (); $ this-> assertFalse ($ game-> isPlayable ()); 

Nous avons renommé notre méthode de test précédente pour refléter ce que nous voulons réellement tester. Ensuite, nous venons d'affirmer que le jeu n'est pas jouable. Le test est réussi. Mais les faux positifs sont fréquents dans de nombreux cas. Nous pouvons donc affirmer vrai et nous assurer que le test échoue..

$ this-> assertTrue ($ game-> isPlayable ());

Et ça le fait!

PHPUnit_Framework_ExpectationFailedException: Échec de l'affirmation de la valeur false.

Jusqu'ici, assez prometteur. Nous avons réussi à tester la valeur de retour initiale de la méthode, la valeur représentée par la valeur initiale. Etat du Jeu classe. Veuillez noter le mot souligné: "Etat". Nous devons trouver un moyen de contrôler l'état du jeu. Nous devons le changer pour qu'il y ait un minimum de joueurs..

Si on analyse Jeude ajouter() méthode, nous verrons qu'il ajoute des éléments à notre tableau.

array_push ($ this-> players, $ playerName);

Notre hypothèse est renforcée par la façon dont le ajouter() méthode est utilisée dans RunnerFunctions.php.

function run () $ aGame = new Game (); $ aGame-> add ("Chet"); $ aGame-> add ("Pat"); $ aGame-> add ("Sue"); //… //

Sur la base de ces observations, nous pouvons conclure qu’en utilisant ajouter() deux fois, nous devrions pouvoir apporter notre Jeu dans un état avec deux joueurs.

function testAfterAddingTwoPlayersToANewGameItIsPlayable () $ game = new Game (); $ game-> add ('premier joueur'); $ game-> add ('deuxième joueur'); $ this-> assertTrue ($ game-> isPlayable ()); 

En ajoutant cette seconde méthode de test, nous pouvons nous assurer que isPlayable () renvoie vrai si les conditions sont remplies.

Mais vous pouvez penser que ce n'est pas tout à fait un test unitaire. Nous utilisons le ajouter() méthode! Nous exerçons plus que le strict minimum de code. Nous pourrions simplement ajouter les éléments au $ joueurs tableau et ne pas compter sur le ajouter() méthode du tout.

Eh bien, la réponse est oui et non. Nous pourrions le faire, d’un point de vue technique. Cela aura l'avantage d'un contrôle direct sur le tableau. Cependant, cela aura l’inconvénient de dupliquer le code entre le code et les tests. Alors, choisissez l’une des mauvaises options avec lesquelles vous croyez pouvoir vivre et utilisez celle-là. Personnellement, je préfère réutiliser des méthodes comme ajouter().

Tests de refactoring

Nous sommes sur le vert, nous refactor. Pouvons-nous améliorer nos tests? Eh bien oui, nous pouvons. Nous pourrions transformer notre premier test pour vérifier toutes les conditions d'un nombre insuffisant de joueurs.

fonction testAGameWithNotEnoughPlayersIsNotPlayable () $ game = new Game (); $ this-> assertFalse ($ game-> isPlayable ()); $ game-> add ('Un joueur'); $ this-> assertFalse ($ game-> isPlayable ()); 

Vous avez peut-être entendu parler du concept suivant: "Une assertion par test". Je suis plutôt d'accord avec cela, mais si vous avez un test qui vérifie un concept unique et nécessite plusieurs assertions pour le vérifier, je pense qu'il est acceptable d'utiliser plus d'une assertion. Ce point de vue est également fortement défendu par Robert C. Martin dans ses enseignements..

Mais qu'en est-il de notre deuxième méthode de test? Est-ce suffisant? Je dis NON.

$ game-> add ('premier joueur'); $ game-> add ('deuxième joueur');

Ces deux appels me dérangent un peu. Ils sont une implémentation détaillée sans explication explicite dans notre méthode. Pourquoi ne pas les extraire dans une méthode privée?

function testAfterAddingEnoughPlayersToANewGameItIsPlayable () $ game = new Game (); $ this-> addEnoughPlayers ($ game); $ this-> assertTrue ($ game-> isPlayable ());  fonction privée addEnoughPlayers ($ game) $ game-> add ('Premier joueur'); $ game-> add ('deuxième joueur'); 

C'est beaucoup mieux et cela nous amène également à un autre concept qui nous a échappé. Dans les deux tests, nous avons exprimé d’une manière ou d’une autre le concept de "suffisamment de joueurs". Mais combien est suffisant? Est-ce deux? Oui, pour l'instant c'est. Mais voulons-nous que notre test échoue si le JeuLa logique nécessitera au moins trois joueurs? Nous ne voulons pas que cela se produise. Nous pouvons introduire un champ de classe statique public pour cela.

class Game static $ minimumNumberOfPlayers = 2; //… // function __construct () //… // function isPlayable () return ($ this-> howManyPlayers ()> = self :: $ minimumNumberOfPlayers);  //… //

Cela nous permettra de l'utiliser dans nos tests.

fonction privée addEnoughPlayers ($ game) pour ($ i = 0; $ i < Game::$minimumNumberOfPlayers; $i++)  $game->ajouter ('un joueur'); 

Notre méthode de petit assistant consiste simplement à ajouter des joueurs jusqu'à ce qu'il y en ait assez. Nous pouvons même créer une autre méthode de ce type pour notre premier test. Nous ajoutons donc assez de joueurs..

fonction testAGameWithNotEnoughPlayersIsNotPlayable () $ game = new Game (); $ this-> assertFalse ($ game-> isPlayable ()); $ this-> addJustNothEnoughPlayers ($ game); $ this-> assertFalse ($ game-> isPlayable ());  fonction privée addJustNothEnoughPlayers ($ game) pour ($ i = 0; $ i < Game::$minimumNumberOfPlayers - 1; $i++)  $game->ajouter ('un joueur'); 

Mais cela introduit une certaine duplication. Nos deux méthodes d'assistance sont assez similaires. Ne pouvons-nous pas en extraire un troisième?

fonction privée addEnoughPlayers ($ game) $ this-> addManyPlayers ($ game, Game :: $ minimumNumberOfPlayers);  fonction privée addJustNothEnoughPlayers ($ game) $ this-> addManyPlayers ($ game, Game :: $ minimumNumberOfPlayers - 1);  fonction privée addManyPlayers ($ game, $ numberOfPlayers) pour ($ i = 0; $ i < $numberOfPlayers; $i++)  $game->ajouter ('un joueur'); 

C’est mieux, mais cela pose un problème différent. Nous avons réduit la duplication de ces méthodes, mais notre $ jeu l'objet est maintenant transmis sur trois niveaux. Cela devient difficile à gérer. Il est temps de l'initialiser dans le test installer() méthode et le réutiliser.

La classe GameTest étend PHPUnit_Framework_TestCase private $ game; function setUp () $ this-> game = nouvelle partie;  function testAGameWithNotEnoughPlayersIsNotPlayable () $ this-> assertFalse ($ this-> game-> isPlayable ()); $ this-> addJustNothEnoughPlayers (); $ this-> assertFalse ($ this-> game-> isPlayable ());  function testAfterAddingEnoughPlayersToANewGameItIsPlayable () $ this-> addEnoughPlayers ($ this-> jeu); $ this-> assertTrue ($ this-> game-> isPlayable ());  fonction privée addEnoughPlayers () $ this-> addManyPlayers (Game :: $ minimumNumberOfPlayers);  fonction privée addJustNothEnoughPlayers () $ this-> addManyPlayers (Game :: $ minimumNumberOfPlayers - 1);  fonction privée addManyPlayers ($ numberOfPlayers) pour ($ i = 0; $ i < $numberOfPlayers; $i++)  $this->jeu-> ajouter ('Un joueur'); 

Beaucoup mieux. Tout le code non pertinent est dans des méthodes privées, $ jeu est initialisé dans installer() et une grande partie de la pollution a été retirée des méthodes de test. Cependant, nous avons dû faire un compromis ici. Dans notre premier test, nous commençons par une affirmation. Cela suppose que installer() créera toujours un jeu vide. C'est OK pour le moment. Mais à la fin de la journée, vous devez comprendre qu’il n’existe pas de code parfait. Il y a juste du code avec des compromis avec lesquels vous êtes prêt à vivre.

La seconde méthode testable

Si nous numérisons notre Jeu classe du haut vers le bas, la méthode suivante sur notre liste est ajouter(). Oui, la même méthode que nous avons utilisée dans nos tests dans le paragraphe précédent. Mais pouvons-nous le tester?

fonction testItCanAddANewPlayer () $ this-> game-> add ('Un joueur'); $ this-> assertEquals (1, compter ($ this-> jeu-> joueurs)); 

Il s’agit maintenant d’une manière différente de tester les objets. Nous appelons notre méthode puis nous vérifions l'état de l'objet. Comme ajouter() revient toujours vrai, il n'y a aucun moyen de tester sa sortie. Mais on peut commencer avec un vide Jeu objet, puis vérifiez s'il y a un seul utilisateur après l'ajout d'un. Mais est-ce assez de vérification?

fonction testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('Un joueur'); $ this-> assertEquals (1, compter ($ this-> jeu-> joueurs)); 

Ne vaudrait-il pas mieux également vérifier s'il n'y a pas de joueurs avant d'appeler ajouter()? Eh bien, c'est peut-être un peu trop ici, mais comme vous pouvez le voir dans le code ci-dessus, nous pourrions le faire. Et chaque fois que vous n'êtes pas sûr de l'état initial, vous devriez faire une affirmation à ce sujet. Cela vous protège également des modifications futures du code susceptibles de modifier l'état initial de votre objet..

Mais testons-nous toutes les choses du ajouter() méthode fait? Je dis NON. Outre l'ajout d'un utilisateur, il définit également de nombreux paramètres. Nous devrions également vérifier pour ceux.

fonction testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('Un joueur'); $ this-> assertEquals (1, compter ($ this-> jeu-> joueurs)); $ this-> assertEquals (0, $ this-> game-> places [1]); $ this-> assertEquals (0, $ this-> game-> bourses [1]); $ this-> assertFalse ($ this-> game-> inPenaltyBox [1]); 

C'est mieux. Nous vérifions chaque action que le ajouter() méthode fait. Cette fois, j’ai préféré tester directement le $ joueurs tableau. Pourquoi? Nous aurions pu utiliser le combien de joueurs() méthode qui fait essentiellement la même chose, non? Eh bien, dans ce cas, nous avons estimé qu’il était plus important de décrire nos affirmations par les effets que le ajouter() méthode a sur l'état de l'objet. Si nous avons besoin de changer ajouter(), nous nous attendons à ce que le test qui teste son comportement strict échoue. J'ai eu d'innombrables débats avec mes collègues de Syneto à ce sujet. Surtout parce que ce type de test introduit un fort couplage entre le test et la façon dont le ajouter() méthode est effectivement implémentée. Donc, si vous préférez tester l’inverse, cela ne signifie pas que vos idées sont fausses..

Nous pouvons en toute sécurité ignorer les tests de la sortie, la echoln () lignes. Ils ne font que diffuser du contenu à l'écran. Nous ne voulons pas encore toucher à ces méthodes. Notre maître d'or repose totalement sur cette sortie.

Tests de refactoring (Bis)

Nous avons une autre méthode testée avec un tout nouveau test de réussite. Il est temps de les reformuler, juste un petit peu. Commençons par nos tests. Les trois dernières affirmations ne sont-elles pas un peu déroutantes? Ils ne semblent pas être strictement liés à l'ajout d'un joueur. Changeons le:

fonction testItCanAddANewPlayer () $ this-> assertEquals (0, count ($ this-> game-> players)); $ this-> game-> add ('Un joueur'); $ this-> assertEquals (1, compter ($ this-> jeu-> joueurs)); $ this-> assertDefaultPlayerParametersAreSetFor (1); 

C'est mieux. La méthode est maintenant plus abstraite, réutilisable, nommée de manière expressive et cache tous les détails sans importance.

Refactoring le ajouter() Méthode

Nous pouvons faire quelque chose de similaire avec notre code de production.

fonction add ($ playerName) array_push ($ this-> players, $ playerName); $ this-> setDefaultPlayerParametersFor ($ this-> howManyPlayers ()); echoln ($ playerName. "a été ajouté"); echoln ("Ils sont le numéro du joueur". count ($ this-> players)); retourne vrai; 

Nous avons extrait les détails sans importance dans setDefaultPlayerParametersFor ().

fonction privée setDefaultPlayerParametersFor ($ playerId) $ this-> places [$ playerId] = 0; $ this-> bourses [$ playerId] = 0; $ this-> inPenaltyBox [$ playerId] = false; 

En fait, cette idée m'est venue après avoir écrit le test. Ceci est un autre bel exemple de la façon dont les tests nous obligent à penser notre code d’un point de vue différent. C’est sous cet angle différent que nous devons exploiter et laisser nos tests guider notre conception du code de production.

La troisième méthode testable

Trouvons notre troisième candidat pour les tests. combien de joueurs() est trop simple et indirectement déjà testé. rouleau() est trop complexe pour être testé directement. De plus, il revient nul. poser des questions() semble être intéressant à première vue, mais c'est toute la présentation, pas de valeur de retour.

currentCategory () est testable, mais c'est joli difficile tester. C'est un énorme sélecteur avec dix conditions. Nous avons besoin d'un test long de dix lignes et ensuite, nous devons sérieusement refactoriser cette méthode et très certainement aussi les tests. Nous devrions prendre note de cette méthode et y revenir une fois que nous aurons fini avec les plus faciles. Pour nous, ce sera dans notre prochain tutoriel.

wasCorrectlyAnswered () est à compliquer à nouveau. Nous devrons en extraire des petits morceaux de code qui peuvent être testés. toutefois, mauvaise réponse() semble prometteur. Il affiche des éléments à l'écran, mais il modifie également l'état de notre objet. Voyons si nous pouvons le contrôler et le tester.

fonction testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox () $ this-> game-> add ('Un joueur'); $ this-> game-> currentPlayer = 0; $ this-> game-> wrongAnswer (); $ this-> assertTrue ($ this-> game-> inPenaltyBox [0]); 

Grrr… Il était assez difficile d'écrire cette méthode de test. mauvaise réponse() repose sur $ this-> currentPlayer pour sa logique comportementale, mais il utilise aussi $ this-> joueurs dans sa partie présentation. Un exemple moche de la raison pour laquelle vous ne devriez pas mélanger logique et présentation. Nous en traiterons dans un prochain tutoriel. Pour l'instant, nous avons testé que l'utilisateur entre dans la surface de réparation. Nous devons également constater qu’il existe un si() déclaration dans la méthode. C'est une condition que nous n'avons pas encore testée, car nous n'avons qu'un seul joueur et que nous ne remplissons donc pas la condition. Nous pourrions tester la valeur finale de $ currentPlayer bien que. Mais l'ajout de cette ligne de code au test le fera échouer.

$ this-> assertEquals (1, $ this-> game-> currentPlayer);

Regarder de plus près la méthode privée shouldResetCurrentPlayer () révèle le problème. Si l'index du joueur actuel est égal au nombre de joueurs, il sera remis à zéro. Aaaahhh! Nous entrons réellement dans le si()!

fonction testWhenAPlayerEntersAWrongAnswerItIsSentToThePenaltyBox () $ this-> game-> add ('Un joueur'); $ this-> game-> currentPlayer = 0; $ this-> game-> wrongAnswer (); $ this-> assertTrue ($ this-> game-> inPenaltyBox [0]); $ this-> assertEquals (0, $ this-> game-> currentPlayer);  function testCurrentPlayerIsNotResetAfterWrongAnswerIfOtherPlayersDidNotYetPlay () $ this-> addManyPlayers (2); $ this-> game-> currentPlayer = 0; $ this-> game-> wrongAnswer (); $ this-> assertEquals (1, $ this-> game-> currentPlayer); 

Bien. Nous avons créé un deuxième test, pour tester le cas spécifique où il y a encore des joueurs qui n'ont pas joué. Nous ne nous soucions pas de la inPenaltyBox état pour le deuxième test. Nous ne sommes intéressés que par l'index du joueur actuel.

La méthode finale testable

La dernière méthode que nous pouvons tester, puis refactor est didPlayerWin ().

fonction didPlayerWin () $ numberOfCoinsToWin = 6; return! ($ this-> bourses [$ this-> currentPlayer] == $ numberOfCoinsToWin); 

Nous pouvons immédiatement observer que sa structure de code est très similaire à isPlayable (), la méthode que nous avons testée en premier. Notre solution devrait également être similaire. Lorsque votre code est si court (deux à trois lignes seulement), le fait de faire plus d’une petite étape n’est pas un si grand risque. Dans le pire des cas, vous inversez trois lignes de code. Faisons cela en une seule étape.

fonction testTestPlayerWinsWithTheCorrectNumberOfCoins () $ this-> game-> currentPlayer = 0; $ this-> game-> purses [0] = Game :: $ numberOfCoinsToWin; $ this-> assertTrue ($ this-> game-> didPlayerWin ()); 

Mais attendez! Ça échoue. Comment est-ce possible? Cela ne devrait-il pas passer? Nous avons fourni le nombre correct de pièces. Si nous étudions notre méthode, nous découvrirons un petit fait trompeur.

return! ($ this-> bourses [$ this-> currentPlayer] == $ numberOfCoinsToWin);

La valeur de retour est effectivement annulée. Donc, la méthode ne nous dit pas si un joueur a gagné, elle nous dit si un joueur n'a pas gagné la partie. Nous pourrions entrer et trouver les endroits où cette méthode est utilisée et nier sa valeur là-bas. Ensuite, changez son comportement ici, pour ne pas nier faussement la réponse. Mais il est utilisé dans wasCorrectlyAnswered (), une méthode que nous ne pouvons pas encore tester un peu. Peut-être pour le moment, un simple changement de nom pour mettre en évidence la fonctionnalité correcte suffira.

fonction didPlayerNotWin () return! ($ this-> porte-monnaie [$ this-> currentPlayer] == self :: $ numberOfCoinsToWin); 

Pensées & Conclusion

Donc, cela termine le tutoriel. Bien que nous n'aimions pas la négation dans le nom, c'est un compromis que nous pouvons faire à ce stade. Ce nom changera sûrement lorsque nous commencerons à refactoriser d'autres parties du code. De plus, si vous regardez nos tests, ils ont l’air bizarre:

fonction testTestPlayerWinsWithTheCorrectNumberOfCoins () $ this-> game-> currentPlayer = 0; $ this-> game-> purses [0] = Game :: $ numberOfCoinsToWin; $ this-> assertFalse ($ this-> game-> didPlayerNotWin ()); 

En testant false sur une méthode inversée, avec une valeur suggérant un résultat vrai, nous avons introduit beaucoup de confusion dans la lisibilité de nos codes. Mais c’est bien pour le moment, car nous devons nous arrêter à un moment donné, juste?

Dans notre prochain tutoriel, nous commencerons à travailler sur certaines des méthodes les plus difficiles du Jeu classe. Merci pour la lecture.