Mokona Guu Center

Test Driven Development : les premiers tests

Publié le

Nous nous quittions dans le précédent article avec une question : quel serait un premier test à faire pour commencer l'étude du jeu.

Je rappelle tout d'abord le cycle du TDD :

  1. Écrire un premier test ;
  2. Vérifier qu'il échoue, afin de vérifier que le test est valide ;
  3. Écrire juste le code suffisant pour passer le test ;
  4. Vérifier que le test passe ;
  5. Puis refactoriser le code, c'est-à-dire l'améliorer tout en gardant les mêmes fonctionnalités.

Pièce de Shôgi en bois

Il n'y a pas qu'une seule bonne réponse, pour ce premier test, mais un ensemble. Et tout d'abord, nous pouvons réfléchir ce test de deux manières différentes :

  • un test point à point (end to end test) qui vérifie une comportement du système ;
  • un test unitaire, le plus petit possible, qui permet d'avancer pas à pas (baby steps).

L'art du TDD dans la pratique, sa maîtrise, va être la capacité à juger de la taille des « pas » à franchir pour qu'il ne soit trop grand pour introduire trop de concepts d'un coup, ni trop petit pour s'éviter des déplacements de codes triviaux.

Trouver la petite bête

Étape 1

Un test point à point pourrait ressembler à quelque chose comme ceci :

    class TestCase(unittest.TestCase):
        def test_end_to_end(self):
            board = Board()
            board.move((1, 1), (1, 2))  # moving the chicken forward
            board.move((2, 3), (2, 2))  # moving the girafe forward, stupid move
            board.move((1, 2), (1, 3))  # chicken captures the lion
            self.assertEqual(board.get_winner(), PLAYER_2)

... qui serait un très mauvais test pour les raisons suivantes :

  • plusieurs concepts introduits d'un seul coup : un tablier (Board), des mouvements (move()) et leurs coordonnées, un gagnant, un joueur...
  • le test sur la condition de victoire peut-être vrai dès la première ligne suivant la création du tablier, rien ne test le contraire

C'est le premier point qui est de loin le pire. En TDD, un concept est introduit seul. À vrai dire, un concept est induit par le test plutôt qu'imposé au test.

Raffinement

Ce test doit être simplifié. Comme on veut un test complet, il faudrait pouvoir simuler une partie où l'un joueur abandonne immédiatement et vérifier que l'autre joueur est gagnant. Nous n'aurions alors plus que quatre concepts : le tablier, des joueurs, un gagnant, l'abandon.

    def test_if_one_player_resigns_then_the_other_wins():
        board = Board()
        assert not board.has_winner()
        board.resign(PLAYER_1)
        assert board.get_winner() == PLAYER_2

Mais c'est encore beaucoup.

Comme nous somme dans un domaine d'étude, je vais me servir de la méthode « TDD as if you meant it » (TDDAIYMT par la suite). Dans cette méthode, l'étape numéro 1 du cycle TDD est poussé à l'extrême : « Écrire exactement un seul nouveau test. Cela doit être le test le plus petit qui semble pointer dans la direction de la solution ».

Pratiquer le « TDD as if you meant it », c'est un peu comme réviser ses gammes, revoir ses bases. Cela permet de réfléchir et de mesurer la taille des « pas » à faire lors d'un développement en situation réel.

Continuons donc avec cette méthode.

    def test_no_winner_at_start():
        board = Board()
        assert not board.has_winner()

Il reste à notre test les concepts de tablier et un état gagnant ou pas. Deux concepts.

Posons nous cependant la question : pourquoi Board() ? Oui, le jeu se joue sur un tablier, et une modélisation même non intentionnelle nous incite à représenter le jeu sous la forme de ce tablier, car c'est sur celui-ci que nous allons déplacer les pièces.

Mais pourquoi le tablier aurait-il la notion de gagnant ? Nous testons un jeu.

    def test_no_winner_at_start(self):
        game = ShogiGame(Board())
        self.assertFalse(game.has_winner())

Le tablier est tenace. J'insiste un peu sur ce trait car il est assez classique de vouloir devancer le test. On « sait » que le fonctionnement du jeu en aura besoin, on le créé donc a priori.

À vrai dire, rien ne nous dit qu'on aura besoin d'une classe Board(). Peut-être, peut-être pas. Il est donc inutile de devancer le test. Simplifions encore.

    def test_no_winner_at_start(self):
        game = ShogiGame()
        self.assertFalse(game.has_winner())

Deux concepts : un jeu de shogi et un gagnant. Mais soyons minimaliste.

    def test_game_can_be_created(self):
        ShogiGame()

On y est presque. Un seul concept, celui d'un jeu de shogi qui peut être démarré. Mais pourquoi une classe ? Encore une fois, en programmation TDD pragmatique, ce pourrait être valable. Mais la méthode TDDAIYMT ne permet la création d'une classe que dans des conditions particulières de l'étape de refactor, pas à l'étape 1.

    def test_game_can_be_created(self):
        create_game()

C'est tout p'tit !

Petit non ? Très petit ? Trop petit ? On parle de « Baby Steps », et celui-ci est vraiment un premier pas minuscule. Il permet cependant d'avancer à l'étape numéro 2 car, en lançant les tests, celui-ci échoue par absence de la fonction appelée.

Avant de passer à l'étape numéro 2, faisons une pause et soyons attentif au nommage de la fonction de test : test_game_can_be_created(). Les noms des fonctions de tests sont généralement des descriptions assez complètes de la condition de tests. Ils peuvent d'ailleurs être parfois assez long.

Ce qui est encore mieux, c'est de pouvoir le lire comme une phrase donc le sujet est le nom de la classe de test. Ainsi, nous obtenons :

    class ShogiGameTestCase(unittest.TestCase):
        def test_can_be_created(self):
            create_game()

Ce qui se lit, si on enlève mentalement la nomenclature obligatoire avec le framework de tests unittest ainsi que les obligations de légalité des nommages python : Shogi Game can be created.

Étape 2

L'étape 2 consiste à lancer le test et vérifier qu'il échoue. Il échoue, la fonction n'existe pas.

Plus tard, en conditions réelles et avec un peu d'expérience, il sera possible éventuellement d'éviter ce test. Cependant, je ne le conseille pas sur les toutes premières étapes d'une construction, car ces étapes qui ne passent pas juste à cause de code manquant permet de réfléchir en tant qu'utilisateur. On peut faire de belle découvertes en n'allant pas trop vite.

Étape 3

À présent, il s'agit de faire passer le test. Pour cela, le TDD indique qu'il faut écrire le code minimale pour que le code passe. Le TDDAIYMT ajoute que le code doit être écrit dans la méthode de test elle-même. Python nous permet de le faire avec des fonctions locale, dans d'autre cas, il faut vraiment écrire le code qui sur place.

Donc :

    class ShogiGameTestCase(unittest.TestCase):
        def test_can_be_created(self):
            def create_game():
                pass

            create_game()

Et voilà !

« Mais c'est n'importe quoi ! ». Si si, une partie des lecteurs s'est dit ça au moment de lire le code ci-dessus. Surtout si j'ajoute que ça y est, le premier test est terminé. Il passe. Et en TDDAIYMT, les étapes suivantes de refactor ne seront pas faites car il n'y a ni duplication de code, ni concepts à regrouper. Nous verrons cela bientôt.

Retour en 1

Il nous faut donc réfléchir à un nouveau test. « Nouveau » est important. Car si on réutilise le même, il n'y a aucune chance d'avoir de la duplication, et donc aucune chance de passer aux étapes de refactor.

Plus tard, ce premier test pourra paraître complètement redondant, et il pourra être éliminé par déduplication, pourvu que la couverture de code soit gardée. Nous y reviendront.

Pour le moment, nous appelons une création de jeu. Une possibilité pour trouver un second test est de revenir sur la liste des simplifications que nous avons fait avant. Par exemple, le dernier concept que nous avions enlevé était celui d'un jeu ayant un gagnant.

Au début d'une partie, il n'y a pas de gagnant. La premier test que j'ai écrit était celui-ci :

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

Il se lit « Shogi Game has no winner at start ». Mais ce test ne me convenait pas. Il n'introduit en effet pas uniquement le concept de victoire, mais aussi le fait que create_game() retourne un objet. C'est aller trop vite, car rien ne nous dit pour le moment que le concept de victoire doit être lié à un objet général de jeu.

Je préfère réécrire comme cela :

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

Étape 2, 3 et 4

Le test ne passe pas, puisqu'il manque les fonctions, donc :

    class ShogiGameTestCase(unittest.TestCase):
        def create_game(self):
            pass

        def test_has_no_winner_at_start(self):
            def has_winner():
                return False

            self.create_game()
            self.assertFalse(has_winner())

Voilà qui est intéressant. Nous avons maintenant une duplication de code. Nous pouvons donc passer à la partie refactor.

Étape 5

Pour le moment, les réflexions que nous avons eu étaient assez mécaniques : comment réduire au minimum les tests et le code pour le faire passer. Lorsque l'on passe à l'étape de refactor, les choix d'architectures deviennent important.

On pourrait par exemple commencer à introduire ce fameux objet de jeu à cet endroit. Mais est-ce qu'on veut plutôt attendre voir si un concept d'état du jeu se dégage en observant certaines conditions ?

Le TDDAIYMT nous oblige à nous concentrer sur l'essentiel en ajoutant des conditions très strictes à la création et au déplacement du code. Il n'autorise la création d'abstractions que lorsque le design du code est amélioré et dans le détail :

  1. Une nouvelle méthode n'est extraite que s'il y a assez de duplication de code dans les méthodes de tests. La méthode est initialement extraite dans la méthode de test (pas de création de classe).
  2. Une nouvelle classe est créée lorsqu'un groupement clair de méthodes émerge et lorsque la classe de test devient trop peuplée ou trop longue.

Dans notre cas, la duplication est au niveau de create_game() qui est donc extraite au niveau de la classe de test. Python nous oblige à ajouter le mot-clé self :

    class ShogiGameTestCase(unittest.TestCase):
        def create_game(self):
            pass

        def test_can_be_created(self):
            self.create_game()

        def test_has_no_winner_at_start(self):
            def has_winner():
                return False

            self.create_game()
            self.assertFalse(has_winner())

Ad lib.

Je vais accélérer un peu le processus en n'indiquant plus systématiquement les étapes. Nous sommes revenu à la première et nous devons trouver un nouveau test, minimal, allant dans la direction permettant la résolution du problème.

Puisque nous avons commencé avec le concept de gagnant, continuons dans cette veine. Bifurquer pour se mettre à poser ou déplacer des pions ajouterait beaucoup de complexité.

Garder en tête, toujours : « Baby steps ».

Comment faire pour passer d'un état où il n'y a pas de gagnant à un état où il y a un gagnant ? Au Dôbutsu Sôgi, il y a un gagnant si un Lion est capturé. Voici le code incluant le minimum pour faire passer le test.

    def test_has_a_winner_if_a_lion_got_captured(self):
        def capture_lion():
            pass

        def has_winner():
            return True

        self.create_game()
        capture_lion()
        self.assertTrue(has_winner())

Notez encore une fois la simplicité : une fonction capturant un Lion a été ajoutée. C'est tout. Pas besoin de plateau, de déplacement, de concept de joueur. On écrit uniquement ce que l'on veut faire. Il est très probable que cette fonction soit effacée dans le futur, mais pour le moment, elle décrit exactement ce que l'on test et elle a permis de révéler un début de duplication de la fonction has_winner().

Contrairement au cas précédent, ici, has_winner() n'a pas le même corps dans les deux derniers tests. L'un renvoie False, l'autre renvoie True. Dans l'étape de refactor, il va donc falloir ajouter le code nécessaire au rapprochement des deux fonctions. Commençons par implémenter que la capture du Lion entraîne un gagnant.

    def test_has_a_winner_if_a_lion_got_captured(self):
        self.lion_was_captured = False

        def capture_lion():
            self.lion_was_captured = True

        def has_winner():
            return self.lion_was_captured

        self.create_game()
        capture_lion()
        self.assertTrue(has_winner())

        del self.lion_was_captured

Attention : le del à la fin de la fonction est nécessaire pour laisser le framework de test dans un état inchangé. Si cette action n'était pas faite, un test qui serait lancé par la suite hériterait d'une variable « corrompue », perturbant ainsi le fonctionnement.

L'utilisation d'une variable d'instance ici est nécessaire pour pouvoir être utilisée dans les deux fonctions. Il y a peut-être mieux, je vous laisser commenter sur le billet.

Nous sommes toujours en refactor. Ce code est à présent reporté dans le test précédent pour vérifier qu'il fonctionne toujours :

    def test_has_no_winner_at_start(self):
        self.lion_was_captured = False

        def capture_lion():
            self.lion_was_captured = True

        def has_winner():
            return self.lion_was_captured

        self.create_game()
        self.assertFalse(has_winner())

        del self.lion_was_captured

Les tests passent et une grosse duplication a été crée, on peut donc extraire le tout dans la classe de tests. L'extraction doit se faire normalement méthode par méthode, membre par membre.

La première étape pourrait être l'extraction de capture_lion() comme ceci :

    class ShogiGameTestCase(unittest.TestCase):
        def create_game(self):
            pass

        def capture_lion(self):
            self.lion_was_captured = True

        def test_can_be_created(self):
            self.create_game()

        def test_has_no_winner_at_start(self):
            self.lion_was_captured = False

            def has_winner():
                return self.lion_was_captured

            self.create_game()
            self.assertFalse(has_winner())

            del self.lion_was_captured

        def test_has_a_winner_if_a_lion_got_captured(self):
            self.lion_was_captured = False

            def has_winner():
                return self.lion_was_captured

            self.create_game()
            self.capture_lion()
            self.assertTrue(has_winner())

            del self.lion_was_captured

Toujours le concept de « Baby Steps ». Si je passe d'un état Vert (les tests passent) à un était Rouge (les tests ne passent pas) pendant une étape de refactor, alors la toute petite modification qui vient d'être faite en est la cause ; il est alors assez simple de revenir en arrière puis de comparer les deux états pour comprendre l'erreur.

Danger important à cette étape : écrire du code non testé ! Le code écrit en refactor doit être du code d'amélioration, du code nécessaire au déplacement et extraction inhérent au langage. L'étape de refactor ne doit pas faire apparaître de nouveaux concepts.

L'extraction terminée, voici ce que cela peut donner :

    class ShogiGameTestCase(unittest.TestCase):
        def setUp(self):
            self.lion_was_captured = False

        def tearDown(self):
            del self.lion_was_captured

        def create_game(self):
            pass

        def capture_lion(self):
            self.lion_was_captured = True

        def has_winner(self):
            return self.lion_was_captured

        def test_can_be_created(self):
            self.create_game()

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

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

On notera l'utilisation de setUp() et tearDown() qui sont des fonctions respectivement exécutées au début et à la fin de chacun des tests par le framework unittest.

Cela fait trois méthodes et un membre dans la classe de tests qui s'occupent de faire passer les tests. Je n'y vois pas encore de classe se dessiner clairement. J'arrête donc l'étape de refactor ici.

Pause

Tout cela peut paraître un nombre absolument effrayant de nombreuses petites étapes. Est-ce que l'on ne perdrait pas son temps à exécuter constamment les tests ? À être tatillon sur un déplacement de code ? Est-ce qu'on n'aurait pas mieux fait d'écrire directement les méthodes dans une classe ?

Il est probable que dans le temps d'écrire ce qui vient de l'être, une méthode sans test aurait pu déjà avoir une classe avec des pions qui pourraient être bougés. Le code aurait été exécuté bien moins souvent et on aurait l'impression d'être bien plus rapide.

Jusqu'au moment où un nouveau morceau de code brise le système jusqu'à maintenant correct. Quelle partie de tout ce qui vient d'être écrit est fautif ? On revient en arrière, ou plus généralement on commente des parties de code pour arriver par dichotomie à l'erreur. Puis on s'embourbe, on refactor, mais sans rien pour nous assurer des non regressions. On n'avance plus.

Le TDD est plus lent, mais son exercice régulier permet tout de même d'être assez véloce. Surtout, le TDD permet de lisser l'effort tout au long du développement plutôt que de le concentrer sur des moments particuliers de « ça marche plus ! ». Et lorsque effectivement cela ne marche plus, ou bien qu'un refactor est nécessaire pour éclaircir les choses, le code est sous contrôle et les modifications peuvent être faites avec assurance.

À bientôt pour un prochain épisode, où j'espère pouvoir introduire le concept de « Mock Objects ».