Dans l'épisode précédent, le système permettait de capturer des pièces sur le tablier d'un Dôbutsu Shôgi. Cela se traduisait par la disparition d'une pièce lorsqu'une autre pièce effectuait un mouvement de capture explicite vers la position de la pièce capturée.

Aujourd'hui, je continue dans cette veine, car la capture au Shôgi ne consiste pas à retirer une pièce de la partie comme cela l'est aux Échecs.

La capture porte bien son nom : le joueur qui a capturé la pièce la prend pour son compte. Elle pourra être par la suite être parachutée, mouvement que j'ai déjà implémenté auparavant.

Shogi_silver.jpg

Ce que je dois implémenter est donc : je suis un joueur qui déplace un de mes pions sur l'emplacement d'un pion adverse et je prends ce pion dans ma réserve.

« Joueur » ? « Adversaire » ? Jusqu'à maintenant, je déplaçais des pions sur un tablier. Or chacun de ces pions appartient à l'un ou l'autre des joueurs, ce qui n'est pas du tout représenté par le système.

C'est MA pièce !

Je ne veux pas introduire la notion de joueur pour le moment. Mais puisqu'il y a deux réserves de pions capturés, je dois introduire une notion d'appartenance de pion.

Mes tests mettent en situation une pièce de poussin/pion (PIECE_PAWN) et une pièce d'éléphant (PIECE_ELEPHANT). Par chance, je n'ai fais aucun test de poussin prenant un poussin ou d'éléphant prenant un éléphant, je peux donc tout à fait décider tout en restant dans les règles du jeu que le poussin appartient à un joueur et l'éléphant à l'autre joueur.

Il y a plusieurs manières de représenter l'appartenance des pièces. On pourrait représenter les éléphants par les chaînes "E1" et "E2" par exemple. Je choisi d'utiliser un tuple (pièce, joueur) avec pour numéro de joueurs les numéros 1 et 2. Le joueur 0 est réservé pour être associé à l'absence de pièce.

Attention : interdiction de modifier les constantes dès maintenant ! Aucun test n'a été écrit.

J'en écrit donc un premier :

    def test_has_a_piece_controlled_by_player_1(self):
        def get_piece_controller((piece, controller_index)):
            return controller_index
        self.assertEqual(1, get_piece_controller(PIECE_PAWN))

Ce qui me permet d'écrire

PIECE_PAWN = ("P", 1)

Le fait d'avoir utilisé les constantes pour représenter les pions permet ce changement sans qu'aucun des autres tests ne soit affecté. On trouve ici un grand intérêt à tout nommer plutôt que d'utiliser des valeurs en place. Imaginez le travail de réécriture sinon.

Vous devinez la suite :

    def test_has_a_piece_controlled_by_player_2(self):
        def get_piece_controller((piece, controller_index)):
            return controller_index
        self.assertEqual(2, get_piece_controller(PIECE_ELEPHANT))

Permet d'écrire :

PIECE_ELEPHANT = ("E", 2)

Et :

    def test_has_no_controller_on_empty_places(self):
        def get_piece_controller((piece, controller_index)):
            return controller_index
        self.assertEqual(0, get_piece_controller(PIECE_NONE))

D'écrire :

PIECE_NONE = ("", 0)

Tous les tests passent. On peut donc à présent être plus clair dans les constantes avec un petit chercher remplacer :

PIECE_PAWN_P1 = ("P", 1)
PIECE_ELEPHANT_P2 = ("E", 2)
PIECE_NONE = ("", 0)

En refactoring, get_piece_controller() est extraite directement en dehors de la classe de tests, en fonction indépendante. Elle n'a en effet aucun rapport avec l'instance ShogiGame.

Adversaires

À présent que les pièces sont contrôlées par des joueurs, je peux vérifier que la capture est invalide si le joueur tente de capturer une de ses pièces.

Les cas de captures valides sont déjà écrits, je n'en écrit pas plus. Mais on peut tenter l'exercice mentalement : que se passerait-il si j'écrivais une fonction qui testerait la capture d'un pion du joueur 1 par un pion du joueur 2 ?

Réponse : la fonction de test passerait.

Or, une nouvelle fonction de test doit provoquer un échec. C'est indispensable. Un test qui ne provoque pas d'échec est un test superflu. Or la maintenance des tests a un coût, et on ne tient pas à maintenir des tests inutiles.

Rappel : « une nouvelle fonction de test doit provoquer un échec ».

Je reprends mon schéma de test de capture pour tester la nouvelle règle :

    def test_ignores_a_capture_if_capturing_own_piece(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P2, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        ((source_piece, source_location), (dest_piece, dest_location)) = list_of_pieces

        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(source_location))
        self.assertEqual(PIECE_PAWN_P2, self.game.get_piece_at(dest_location))

Échec, évidemment. L'emplacement source est vide alors qu'aucune pièce n'aurait du avoir bougé, dans l'esprit du système expliqué la dernière fois : un mouvement invalide est ignoré.

Ce qui est résolu en ajoutant un test dans la fonction capture() :

    def capture(self, source_location, dest_location):
        source_piece = self.game.get_piece_at(source_location)
        dest_piece = self.game.get_piece_at(dest_location)

        if (source_piece != PIECE_NONE and dest_piece != PIECE_NONE and
           get_piece_controller(source_piece) != get_piece_controller(dest_piece)):
            self.game.pieces_on_board[source_piece] = dest_location
            self.game.pieces_on_board[dest_piece] = dest_piece

Fonction capture() qui se trouve d'ailleurs toujours dans la classe de tests malgré sa forte dépendence à l'instance de ShogiGame. Puisque les tests passent, j'en profite pour la déplacer. Extraction facilitée par le fait qu'il n'y a qu'un seul appel à capture(), celui de la fonction d'aide _place_source_and_destination_pieces_from_list_then_capture().

À présent dans ShogiGame, la fonction capture() montre un manque d'uniformité dans le nom de ses paramètres. J'uniformise avec ceux de la fonction move() déjà présente.

Interlude

J'ai besoin de faire le point.

Il arrive toujours un moment dans un développement où on a besoin d'avoir une vision globale de ce qui a été fait jusqu'à maintenant. C'est particulièrement vrai lorsqu'un développement s'étale, comme celui-ci, sur de petites séances espacées. Le TDD n'échappe pas à ces moments.

Il facilite cependant la mise au point. Il suffit de lire les tests :

  • Conditions de victoire
    • A Shogi Game has no winner at start
    • A Shogi Game has a winner if a lion got captured
  • Parachutage
    • A Shogi Game has a dropped pawn on its board
    • A Shogi Game has a dropped elephant on its board
    • A Shogi Game ignores a dropped piece outside the board
  • Déplacements normaux
    • A Shogi Game can move a piece on the board
    • A Shogi Game can move a piece twice on the board
    • A Shogi Game can move a piece twice and get to first location
    • A Shogi Game ignores a new piece if a piece is already at the given location
    • A Shogi Game ignores a move to a location where there is an occupied piece
  • Contrôle des pièces
    • A Shogi Game has a piece controlled by player 1
    • A Shogi Game has a piece controlled by player 2
    • A Shogi Game has no controller on empty places
  • Captures
    • A Shogi Game allows an elephant to capture a pawn
    • A Shogi Game ignores a capture if source has no piece
    • A Shogi Game ignores a capture if destination has no piece
    • A Shogi Game ignores a capture if capturing own piece

Note : cette liste a été faite en extrayant le nom de tests suivi de quelques chercher/remplacer. J'ai ensuite juste ajouté les sections et trié les tests. J'en profite pour réordonner les tests dans le code de la même façon.

C'est pas mal. Que manque-t-il encore comme règles du jeu au niveau de la capture ? La mise en place du pion capturé dans la réserve.

La réserve

Le test de la réserve vérifiera qu'une capture ajoute à la reserve la pièce capturée, avec changement de controlleur.

Cela peut se faire en implémentant un conteneur et vérifiant avant/après que la pièce capturée est bien apparue dedans. Cela peut aussi se faire en vérifiant que ShogiGame émet un signal vers un autre objet avec comme paramètre la pièce capturée.

Je choisi cette seconde solution pour deux raisons. La première raison est que ShogiGame a déjà deux responsabilités (conditions de victoires et manipulation des pions sur le plateau), ce qui est une de trop. La seconde raison est que cela va permettre d'introduire un nouvel outil du TDD : le « mock object ».

Mock Object

Un « Mock Object » est un « objet qui imite » le comportement d'un autre. Il sert à simuler le comportement d'un objet sans en utiliser le fonctionnement réel. À vrai dire, cet objet peut être complètement fictif.

Je vais donc pouvoir tester qu'un objet reçoit bien des signaux émis depuis ShogiGame, sans l'implémenter.

En plus de cela, un Mock Object offre d'autres services : la programmation de ses réponses ainsi que des vérifications de son utilisation.

Il existe des frameworks de Mock Objects pour les langages avec un environnement de tests et j'en utiliserai un peu après. Mais pour commencer, je vais programmer un fonctionnement sans aide, afin de montrer le principe.

Je voudrais pouvoir écrire quelque chose comme ça :

    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        tray.expect("add_piece", PIECE_PAWN_P1)
        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.assertTrue(tray.verify())

Mais il manque pour cela un objet tray avec une méthode add_piece vérifiant que la pièce mentionnée a bien été reçue.

Ça se fait passer facilement avec :

    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        tray = MockTray()
        tray.expect("add_piece", PIECE_PAWN_P1)
        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.assertTrue(tray.verify())


class MockTray:
    def expect(self, function_name, argument):
        pass

    def verify(self):
        return True

Le principe du MockObject avec ce type d'appel[1] est qu'on le configure tout d'abord en lui disant que l'on s'attend à ce qu'il reçoive un appel de sa méthode add_piece avec l'argument PIECE_DAWN_P1.

À la fin du test, on appel une fonction qui vérifie que tout ce qui était attendu s'est bien produit.

Pour créer de la duplication, j'ai besoin d'un autre test qui vérifie ce qu'il se passe dans la cas d'une capture invalide (et donc ignorée).

    def test_does_not_signal_an_invalid_capture(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P2, (1, 1)))

        tray = MockTray()
        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.assertTrue(tray.verify())

Problème : ce test passe. C'est donc un test invalide.

Le problème ici est que je ne suis plus en train de tester le jeu de Shogi, mais je démarre les tests pour une construction d'un framework de Mock Object. Ce qui n'est pas le sujet, et devrait être fait à part. Tester deux choses en même temps n'amènera rien de bon.

Le Framework

Pour mes Mock Objects, je vais utiliser le framework Mock qui a l'avantage d'être devenu le standard pour Python 3.3 et supérieur. Je construis ces articles avec un Python 2.7, j'installe donc le package nécessaire (pip install mock en ligne de commande administrateur pour une installation globale au système par exemple).

Le framework « Mock » s'utilise un peu différement de la manière dont j'ai écrit les tests précédents. Je vais d'abord créer un object de type « Mock ». Celui-ci a la particularité de pouvoir recevoir n'importe quel type d'appel. Il offre ensuite des méthodes (ex : assert_called_once_with) et attributs (ex : called) pour vérifier quels appels ont été faits.

Voici une version avec ce framework, résolu par l'appel explicite à add_piece.

    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        tray = mock.Mock()
        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        tray.add_piece(PIECE_PAWN_P1)
        tray.add_piece.assert_called_once_with(PIECE_PAWN_P1)

J'ai besoin d'un nouveau test pour créer de la duplication. Je pourrais retenter le test de la capture invalide tout de suite, mais ça serait compliqué. En effet, aucun code n'est appelé sur tray réellement, donc le test va passer et le teste sera invalide. Il faudrait quelques contorsions qui ne correspondraient pas au principe de « Baby Steps » ni de « code minimal » pour que le test échoue. Ça serait un mauvais choix.

Je vais donc mentalement dupliquer une capture d'une autre pièce et commencer à intégrer l'appel à tray dans la méthode de capture.

Injection

Pour que ShogiGame puisse appeler un Tray, il lui faut une instance de cette classe qui reste à écrire. Pour cela, j'utilise de l'injection de dépendences.

L'injection de dépendences consiste à donner aux objets qui en ont besoin les instances des autres objets avec lesquels ils vont interagir.

Pour comprendre ce à quoi sert l'injection de dépendences, réflechissons à ce que serait l'architecture de ShogiGame sans cette injection. Probablement que le potentiel Tray (peut-être sous la forme d'une simple paire de tableaux) serait alors un composant de ShogiGame, donc un objet interne.

Lorsque je présente les concepts derrière les tests, on me pose souvent la question : comment est-ce que je vérifie le fonctionnement des composants privés d'un objet ? La réponse est que dans un cas normal, on ne le fait pas[2].

Un objet privé est privé. On test un objet via son interface publique. Lorsque l'on cherche à tester un comportement interne, c'est-à-dire des détails d'implémentation, on s'expose à un premier danger majeur. Ce danger est la multiplication de fonctions d'accès à ces composants ce qui :

  • alourdi inutilement l'interface de l'objet,
  • créé une dépendance trop forte entre les tests et l'objet, ce qui rendra difficile les modifications ultérieures

Le second danger est la multiplication de tests avec une grande combinatoire sur les cas limites de l'objet.

L'injection permet donc, au prix d'un code de tuyauterie un peu plus lourde à la création des objets, de tester le fonctionnement des objets en indépendence d'une part et de tester les interactions entre les objets grâce aux Mock Objects.

J'ajoute donc une dépendance de ShogiGame vers un objet Tray via un Mock Object de la manière suivante

class ShogiGame:
    def __init__(self, tray):
        self.lion_was_captured = False
        self.pieces_on_board = {}
        self.tray = tray

class ShogiGameTestCase(unittest.TestCase):
    def setUp(self):
        self.tray = mock.Mock()
        self.game = ShogiGame(self.tray)

Le test se réécrit de cette manière

    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.tray.add_piece(PIECE_PAWN_P1)
        self.tray.add_piece.assert_called_once_with(PIECE_PAWN_P1)

Que l'on peut transformer en montant l'appel à add_piece jusqu'à capture.

class ShogiGame:
    def capture(self, source, destination):
        source_piece = self.get_piece_at(source)
        dest_piece = self.get_piece_at(destination)

        self.tray.add_piece(dest_piece)

        if (source_piece != PIECE_NONE and dest_piece != PIECE_NONE and
           get_piece_controller(source_piece) != get_piece_controller(dest_piece)):
            self.pieces_on_board[source_piece] = destination
            self.pieces_on_board[dest_piece] = dest_piece


class ShogiGameTestCase(unittest.TestCase):
    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.tray.add_piece.assert_called_once_with(PIECE_PAWN_P1)

J'ai délibéremment mis self.tray.add_piece(dest_piece) en dehors du test afin de pouvoir écrire mon fameux test sur la capture invalide.

    def test_does_not_signal_an_invalid_capture(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P2, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.assertFalse(self.tray.add_piece.called)

Qui est enfin un test valide, car il ne passe pas ! Et qui se corrige évidemment par la modification de capture.

    def capture(self, source, destination):
        source_piece = self.get_piece_at(source)
        dest_piece = self.get_piece_at(destination)

        if (source_piece != PIECE_NONE and dest_piece != PIECE_NONE and
           get_piece_controller(source_piece) != get_piece_controller(dest_piece)):
            self.pieces_on_board[source_piece] = destination
            self.pieces_on_board[dest_piece] = dest_piece

            self.tray.add_piece(dest_piece)

Interface, typage et autres considérations

Enfin, les Mock Objects ! Depuis le temps que j'en parle. J'espère avoir introduit le sujet correctement. Ils vont nous permettre d'avancer plus rapidement dans les articles suivants et de séparer les responsabilités dans des objets différents.

L'utilisation d'un Mock Object nous a permi de dégager une interface pour une classe à implémenter à peu près comme ceci :

class Tray:
    def add_piece(self, piece):
        pass

Mais qu'on n'a pas encore implémenté.

Dans un monde fortement typé, on pourrait créer une interface et utiliser un framework de Mock qui se baserait sur cette interface pour éviter des appels de fonctions qui ne respecteraient pas l'interface.

En Python, nous somme dans un monde typé dynamiquement, assez lâche, avec Duck Typing. Il y a cependant des moyens dans les frameworks de tests de signaler des appels à des fonctions non attendues.

Dans les mondes typés comme non typés, il reste quelques risques de divergeance entre un Mock Object et l'objet qu'il est censé immiter. Peut-être auront nous l'occasion de voir le problème.

En attendant, je vous donne rendez-vous au prochain article, dans lequel je continuerai d'implémenter la réserve pour enfin pouvoir implémenter la condition de victoire[3] correctement.

Le code complet en l'état actuel

import unittest
import mock


PIECE_PAWN_P1 = ("P", 1)
PIECE_PAWN_P2 = ("P", 2)
PIECE_ELEPHANT_P2 = ("E", 2)
PIECE_NONE = ("", 0)

BOARD_MINIMAL_X = 0
BOARD_MINIMAL_Y = 0
BOARD_MAXIMAL_X = 2
BOARD_MAXIMAL_Y = 3


def get_piece_controller((piece, controller_index)):
    return controller_index


class ShogiGame:
    def __init__(self, tray):
        self.lion_was_captured = False
        self.pieces_on_board = {}
        self.tray = tray

    def capture_lion(self):
        self.lion_was_captured = True

    def has_winner(self):
        return self.lion_was_captured

    def _is_location_valid(self, location):
        return (location[0] >= BOARD_MINIMAL_X and
                location[1] >= BOARD_MINIMAL_Y and
                location[0] <= BOARD_MAXIMAL_X and
                location[1] <= BOARD_MAXIMAL_Y)

    def drop(self, piece, location):
        if self._is_location_valid(location):
            if self._get_piece_at_location(location) is None:
                self.pieces_on_board[piece] = location

    def _get_piece_at_location(self, location):
        pieces_at_location = [p[0] for p in self.pieces_on_board.items() if p[1] == location]
        if len(pieces_at_location) == 1:
            return pieces_at_location[0]
        return None

    def get_piece_at(self, location):
        piece = self._get_piece_at_location(location)
        return piece or PIECE_NONE

    def move(self, source, destination):
        destination_piece = self._get_piece_at_location(destination)
        if destination_piece is None:
            source_piece = self._get_piece_at_location(source)
            if source_piece is not None:
                self.pieces_on_board[source_piece] = destination

    def capture(self, source, destination):
        source_piece = self.get_piece_at(source)
        dest_piece = self.get_piece_at(destination)

        if (source_piece != PIECE_NONE and dest_piece != PIECE_NONE and
           get_piece_controller(source_piece) != get_piece_controller(dest_piece)):
            self.pieces_on_board[source_piece] = destination
            self.pieces_on_board[dest_piece] = dest_piece

            self.tray.add_piece(dest_piece)


class ShogiGameTestCase(unittest.TestCase):
    def setUp(self):
        self.tray = mock.Mock()
        self.game = ShogiGame(self.tray)

    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_P1, location)
        self.assertEqual(PIECE_PAWN_P1, self.game.get_piece_at(location))

    def test_ignores_a_dropped_piece_outside_the_board(self):
        invalid_locations = ((-1, 0), (0, -1), (3, 0), (0, 4))

        for location in invalid_locations:
            self.game.drop(PIECE_PAWN_P1, location)
            self.assertEqual(PIECE_NONE, self.game.get_piece_at(location))

    def test_has_a_dropped_elephant_on_its_board(self):
        location = (0, 0)
        self.game.drop(PIECE_ELEPHANT_P2, location)
        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(location))

    def test_can_move_a_piece_on_the_board(self):
        source = (0, 0)
        destination = (1, 1)

        self.game.drop(PIECE_ELEPHANT_P2, source)

        self.game.move(source, destination)

        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(destination))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at(source))

    def _move_elephant(self, locations):
        self.game.drop(PIECE_ELEPHANT_P2, locations[0])
        self.game.move(locations[0], locations[1])
        self.game.move(locations[1], locations[2])

    def test_can_move_a_piece_twice_on_the_board(self):
        locations = [(0, 0), (1, 1), (2, 2)]

        self._move_elephant(locations)

        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(locations[2]))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at(locations[0]))

    def test_can_move_a_piece_twice_and_get_to_first_location(self):
        locations = [(0, 0), (1, 1), (0, 0)]

        self._move_elephant(locations)

        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(locations[0]))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at(locations[1]))
        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(locations[2]))

    def test_ignores_a_new_piece_if_a_piece_is_already_at_the_given_location(self):
        self.game.drop(PIECE_PAWN_P1, (0, 0))
        self.game.drop(PIECE_ELEPHANT_P2, (0, 0))
        self.assertEqual(PIECE_PAWN_P1, self.game.get_piece_at((0, 0)))

    def test_ignores_a_move_to_a_location_where_there_is_an_occupied_piece(self):
        self.game.drop(PIECE_PAWN_P1, (0, 0))
        self.game.drop(PIECE_ELEPHANT_P2, (1, 1))
        self.game.move((0, 0), (1, 1))

        self.assertEqual(PIECE_PAWN_P1, self.game.get_piece_at((0, 0)))
        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at((1, 1)))

    def test_has_a_piece_controlled_by_player_1(self):
        self.assertEqual(1, get_piece_controller(PIECE_PAWN_P1))

    def test_has_a_piece_controlled_by_player_2(self):
        self.assertEqual(2, get_piece_controller(PIECE_ELEPHANT_P2))

    def test_has_no_controller_on_empty_places(self):
        self.assertEqual(0, get_piece_controller(PIECE_NONE))

    def _place_source_and_destination_pieces_from_list_then_capture(self, list_of_pieces):
        for piece, location in list_of_pieces:
            if piece is not None:
                self.game.drop(piece, location)

        self.game.capture(list_of_pieces[0][1], list_of_pieces[1][1])

    def test_allows_an_elephant_to_capture_a_pawn(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        ((source_piece, source), (dest_piece, destination)) = list_of_pieces

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(source))
        self.assertEqual(source_piece, self.game.get_piece_at(destination))

    def test_ignores_a_capture_if_source_has_no_piece(self):
        list_of_pieces = ((None, (0, 1)),
                          (PIECE_PAWN_P1, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        ((source_piece, source), (dest_piece, destination)) = list_of_pieces

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(source))
        self.assertEqual(dest_piece, self.game.get_piece_at(destination))

    def test_ignores_a_capture_if_destination_has_no_piece(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 1)),
                          (None, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        ((source_piece, source), (dest_piece, destination)) = list_of_pieces

        self.assertEqual(source_piece, self.game.get_piece_at(source))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at(destination))

    def test_ignores_a_capture_if_capturing_own_piece(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P2, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        ((source_piece, source), (dest_piece, destination)) = list_of_pieces

        self.assertEqual(PIECE_ELEPHANT_P2, self.game.get_piece_at(source))
        self.assertEqual(PIECE_PAWN_P2, self.game.get_piece_at(destination))

    def test_signals_the_captured_piece_to_the_tray(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P1, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.tray.add_piece.assert_called_once_with(PIECE_PAWN_P1)

    def test_does_not_signal_an_invalid_capture(self):
        list_of_pieces = ((PIECE_ELEPHANT_P2, (0, 0)),
                          (PIECE_PAWN_P2, (1, 1)))

        self._place_source_and_destination_pieces_from_list_then_capture(list_of_pieces)
        self.assertFalse(self.tray.add_piece.called)

Notes

[1] Différents frameworks utilisent différentes méthodes, même si on peut les classer en quelques grandes familles.

[2] Il existe des cas où un ensemble d'outils permettent d'aller tester ces objets de manière temporaire pour les extraires d'un code qui a été construit sans les tests en tête. Mais c'est un sujet vaste que je ne traiterai pas ici.

[3] Note : il existe une seconde condition de victoire au Dôbutsu Shôgi, je m'aperçois que je l'ai complètement oubliée. Amener son Lion jusqu'au bord adverse entraîne la victoire. Sauriez-vous écrire le test correspondant ?