Mokona Guu Center

Test Driven Development : parachutes

Publié le

Bienvenue dans le troisième article dans lequel je continue la progression dans l'étude des parties de Dôbutsu Shôgi possibles en utilisant le TDD.

Dans l'article précédent, j'étais arrivé à un état où une partie était commencée, un lion capturé, et je vérifiais alors qu'il y avait un gagnant.

Le dernier test écrit était celui-ci :

def test_has_a_winner_if_a_lion_got_captured(self):
    self.create_game()
    self.capture_lion()
    self.assertTrue(self.has_winner())

Pièce de Shôgi en bois

À ce point là, je dois décider du prochain test. Je peux continuer comme la dernière fois en remontant la liste de mes simplifications pour arriver par exemple au déplacement d'un pion, ou à l'abandon d'un joueur. Ça serait valable.

Mais à ce point, on peut se demander ce que l'on manipule dans le jeu. Je prends donc le parti de tester la mise en place de pions. Au Shôgi, une pièce une fois capturée peut-être parachutée, c'est-à-dire mise sur le plateau par le joueur qui l'avait capturée. Plutôt que de tester la mise en place complète du jeu initial, je teste donc le parachutage.

On ne parachute pas un Lion, puisque sa capture termine la partie. Je teste donc le parachutage d'un pion. Si le Shôgi a des règles sur la légalité de l'emplacement du parachutage, le Dôbutsu Shôgi n'en a qu'une : on ne parachute que sur une case vide. N'ayant pas de concept de case vide ou pleine pour le moment, je vérifie seulement que je peux parachuter une pièce.

    def test_accepts_a_dropped_pawn(self):
        self.create_game()
        drop(PIECE_PAWN, (0,0))

Ne passe pas. Deviens :

    def test_accepts_a_dropped_pawn(self):
        def drop(piece, location):
            pass

        PIECE_PAWN = "P"

        self.create_game()
        drop(PIECE_PAWN, (0, 0))

C'est le quatrième test qui commence par self.create_game(). D'ailleurs, tous les tests commencent de la même manière. En phase de refactor, j'élimine la duplication en déplaçant self.create_game() dans la fonction setUp(). Du coup, le test def test_can_be_created(self) est vide et peut-être enlevé.

Puis un autre test, qui me prouve que la pièce est à présent placée.

    def test_has_a_dropped_pawn_on_its_board(self):
        location = (0, 0)
        drop(PIECE_PAWN, location)
        self.assertEqual(PIECE_PAWN, get_piece_at(location))

Deviens :

    def test_has_a_dropped_pawn_on_its_board(self):
        def drop(piece, location):
            pass

        def get_piece_at(location):
            return PIECE_PAWN

        PIECE_PAWN = "P"

        location = (0, 0)
        drop(PIECE_PAWN, location)
        self.assertEqual(PIECE_PAWN, get_piece_at(location))

Note Python : cela peut paraître étrange aux programmeurs C++ et naturel aux programmeurs Javascript. PIECE_PAWN est défini au moment de sa référence dans get_piece_at(), c'est tout ce qui importe.

En passe de refactor, drop() et PIECE_PAWN sont dé-dupliqués en montant d'un niveau.

Afin de refactorer get_piece_at(), je dois prouver que c'est nécessaire avec un autre type de pièce.

    def test_has_a_dropped_elephant_on_its_board(self):
        def get_piece_at(location):
            return PIECE_ELEPHANT

        PIECE_ELEPHANT = "E"

        location = (0, 0)
        self.drop(PIECE_ELEPHANT, location)
        self.assertEqual(PIECE_ELEPHANT, get_piece_at(location))

À présent que j'ai deux get_piece_at() avec un corps de fonction différent, je peux programmer un comportement qui fonctionnera dans ces deux cas.

Voici les modifications que je fais.

    # Dans setUp, j'ajoute une variable qui retient la pièce parachutée
    def setUp(self):
        self.lion_was_captured = False
        self.dropped_piece = ""
        self.create_game()

    # Cette variable est mise à jour dans drop
    def drop(self, piece, location):
        self.dropped_piece = piece

    # La résolution locale du test est comme ceci
    def test_has_a_dropped_elephant_on_its_board(self):
        def get_piece_at(location):
            return self.dropped_piece
        ...

Par rapport à la méthode TTDAIYMI, je suis allé un peu plus vite en évitant une résolution complètement locale. Il est assez facile de prédire les quelques déplacements de code nécessaires. Cependant, la résolution reste extrêmement simple. Rien n'est fait à propos de location, car celle-ci est toujours la même, et rien n'est fait à propos de plusieurs drop() s'enchaînant, car il n'y a pas de test à ce propos.

La suite est mécanique : la copie du nouveau get_piece_at() dans le test de parachutage du pion ne brise pas les tests et j'enlève donc la duplication en élevant get_piece_at() au niveau de la fonction de tests.

Ce qui donne :

    def test_accepts_a_dropped_pawn(self):
        self.drop(PIECE_PAWN, (0, 0))

    def test_has_a_dropped_pawn_on_its_board(self):
        location = (0, 0)
        self.drop(PIECE_PAWN, location)
        self.assertEqual(PIECE_PAWN, self.get_piece_at(location))

    def test_has_a_dropped_elephan_on_its_board(self):
        PIECE_ELEPHANT = "E"

        location = (0, 0)
        self.drop(PIECE_ELEPHANT, location)
        self.assertEqual(PIECE_ELEPHANT, self.get_piece_at(location))

J'enlève le premier des trois tests, qui est redondant.

Le refactor n'est pas terminé cependant. En examinant setUp(), on peut voir qu'il commence à être (doucement) encombré par des initialisations de variables qui semblent être reliées : ce sont des états du jeu.

Il est donc temps de créer la classe ShogiGame par extraction. En voici les étapes, chacune d'elle étant terminée par un lancement des tests et vérification qu'ils sont toujours au vert :

  • Écriture de la classe ShogiGame dans le fichier (avec pour seul contenu un pass)
  • Transformation dans setUp() de self.create_game() par self.game = ShogiGame()
  • Remplacer pass dans ShogiGame par la fonction __init__ (constructeur), avec comme contenu pass
  • Déplacement de self.lion_was_captured = False dans le constructeur de ShogiGame. Ce déplacement entraîne le déplacement tel quel des méthodes capture_lion() et has_winner(), la réécriture des appels en remplaçant self par self.game et l'effacement du del self.lion_was_captured (et donc de tearDown(), inutile de le garder).
  • Déplacement de self.dropped_piece = \"\" dans le constructeur de ShogiGame. Ce déplacement entraîne le déplacement tel quel des méthodes drop() et get_piece_at(), la réécriture des appels en remplaçant self par self.game.
  • Effacement de la méthode de tests create_game().

À noter : le déplacement des méthodes et la réécriture des appels peut se faire en trois temps. Tout d'abord l'ajout des méthodes dans la nouvelle classe et un appel aux nouvelles méthodes depuis celles en place. Lancer les tests. Puis la réécriture des appels en direct. Lancer les tests. Puis l'effacement des méthodes de la classe de tests.

Lorsque les déplacements sont un peu sensibles, cela vaut le coup d'y aller par petites étapes.

À noter : en effectuant le déplacement, on s’aperçoit qu'il y a deux concepts orthogonaux dans ShogiGame. Deux méthodes utilisent une variable et les deux autres méthodes utilisent l'autre. Notre classe à peine créée a-t-elle déjà trop de responsabilités ?

Pour le vérifier il serait intéressant de trouver un test qui puissent fait le raccord.

En attendant, voici le résultat de cet billet :

    import unittest

    PIECE_PAWN = "P"

    class ShogiGame:
        def __init__(self):
            self.lion_was_captured = False
            self.dropped_piece = ""

        def capture_lion(self):
            self.lion_was_captured = True

        def has_winner(self):
            return self.lion_was_captured

        def drop(self, piece, location):
            self.dropped_piece = piece

        def get_piece_at(self, location):
            return self.dropped_piece


    class ShogiGameTestCase(unittest.TestCase):

        def setUp(self):
            self.game = ShogiGame()

        def test_has_no_winner_at_start(self):
            self.assertFalse(self.game.has_winner())

        def test_has_a_winner_if_a_lion_got_captured(self):
            self.game.capture_lion()
            self.assertTrue(self.game.has_winner())

        def test_has_a_dropped_pawn_on_its_board(self):
            location = (0, 0)
            self.game.drop(PIECE_PAWN, location)
            self.assertEqual(PIECE_PAWN, self.game.get_piece_at(location))

        def test_has_a_dropped_elephant_on_its_board(self):
            PIECE_ELEPHANT = "E"

            location = (0, 0)
            self.game.drop(PIECE_ELEPHANT, location)
            self.assertEqual(PIECE_ELEPHANT, self.game.get_piece_at(location))

Mais, et les Mock Objects ?

Dans le billet précédent, j'espérais pouvoir aborder l'outil des Mock Objects dès ce nouveau billet. Après avoir retourné le problème quelques fois, je me suis dit qu'il était encore trop tôt. Écrire des tests qui auraient provoqué l'utilisation de Mock Objects auraient donné de mauvais exemples. Je préfère donc attendre, le moment viendra.