Mokona Guu Center

Test Driven Development : la réserve pour de vraie

Publié le

À l'épisode précédent, j'avais utilisé un Mock Object pour révéler un début d'interface d'une classe Tray représentant la réserve dans laquelle vont les pions capturés au Dôbutsu Shôgi.

Dans cet épisode, je vais effectuer l'implémentation réelle de cette réserve.

Pièce de Shôgi en bois

Mais tout d'abord

À la fin de l'article précédent, je m'apercevais qu'une des conditions de victoire n'avait pas été testée. Je vous proposais d'écrire le test pour exercice. L'avez-vous fait ?

Voici quelle était ma première proposition :

    def test_has_a_winner_if_a_player_1_lion_is_on_the_other_side(self):
        self.game.drop(PIECE_LION_P1, (1, BOARD_MAXIMAL_Y))
        self.assertTrue(self.game.has_winner())

Cela m'oblige à définir PIECE_LION_P1, qui n'est pas suffisant et qui nécessite de quoi faire renvoyer True à has_winner().

Cependant, ça ne va pas fonctionner. Au moment de l'extraction du code dupliqué (un test par lion) va se poser la question d'où mettre la condition de victoire. La seule fonction étant appelée est drop et le passage à une condition de victoire sera donc dans drop. Or on ne parachute pas de Lion, celui-ci ne peut pas se trouver en cours de jeu dans la réserve.

La condition de victoire doit donc être soit vérifiée à l'appel de has_winner (potentiellement coûteux si has_winner est appelée souvent) soit être vérifiée et retenue après un move.

Voici donc une seconde proposition :

    def test_has_a_winner_if_a_player_1_lion_is_on_the_other_side(self):
        drop_position = (1, BOARD_MAXIMAL_Y - 1)
        self.game.drop(PIECE_LION_P1, drop_position)
        self.game.move(drop_position, (1, BOARD_MAXIMAL_Y))

        self.assertTrue(self.game.has_winner())

Que je peux résoudre comme ceci :

    def test_has_a_winner_if_a_player_1_lion_is_on_the_other_side(self):
        drop_position = (1, BOARD_MAXIMAL_Y - 1)
        self.game.drop(PIECE_LION_P1, drop_position)
        self.game.move(drop_position, (1, BOARD_MAXIMAL_Y))

        if self.game.pieces_on_board[PIECE_LION_P1][1] == BOARD_MAXIMAL_Y:
            self.game.lion_as_captured = True

        self.assertTrue(self.game.has_winner())

Forcer lion_as_captured n'est pas très naturel, que je renomme en win_condition_happened. J'ajoute le test pour l'autre joueur.

    def test_has_a_winner_if_a_player_2_lion_is_on_the_other_side(self):
        drop_position = (1, 1)
        self.game.drop(PIECE_LION_P2, drop_position)
        self.game.move(drop_position, (1, 0))

        if self.game.pieces_on_board[PIECE_LION_P2][1] == 0:
            self.game.win_condition_happened = True

        self.assertTrue(self.game.has_winner())

Au passage, vous remarquerez qu'un nouveau concept a été introduit : celui de l'orientation du tablier. Le lion du joueur 1 démarrera en coordonnées (1, 0) et celui du joueur 2 en coordonnées (1, 3).

Je peux maintenant extraire la mise à jour de win_condition_happened dans move.

    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

                if self.pieces_on_board[PIECE_LION_P1][1] == BOARD_MAXIMAL_Y:
                    self.win_condition_happened = True
                if self.pieces_on_board[PIECE_LION_P2][1] == 0:
                    self.win_condition_happened = True

Ça ne marche pas ! En effet, même si dans un jeu normal, le Lion est forcément présent, ce n'est pas le cas dans les tests que j'ai écrit jusqu'à maintenant. J'ai besoin d'une fonction qui me renvoie de manière certaine l'emplacement d'une pièce donnée.

Mais je suis actuellement en refactor. Je n'ai donc pas le droit d'écrire de nouveau code. Je pourrais, bien sûr. J'ai des tests qui échouent et je pourrais tenter de les résoudre dès maintenant.

Probablement que j'y arriverais... probablement. Dans certains cas, c'est aussi à ce moment que de fil en aiguille, on écrit deux ou trois méthodes qui ne sont testées qu'indirectement. On s'emmêle les pinceaux et au final, on doit revenir en arrière.

Un pas en arrière

Autant revenir en arrière tout de suite et être prudent. Je replace les conditions de victoire dans les tests pour revenir à un état où les tests passent.

J'ai maintenant deux choix. Un choix simple et un choix plus complexe.

Le choix complexe est de se dire qu'ajouter une nouvelle méthode à ShogiGame pour connaître l'emplacement d'une pièce donnée est de trop. L'interface de la classe s'étoffe un peu trop. La suite logique est donc d'extraire via un Mock Object l'interface d'un objet représentant les pièces sur le tablier.

Je préfère laisser cette étape à plus tard si nécessaire. Je me contente de la version simple qui commence par une factorisation du test dans la classe de test (la méthode rigoureuse, que j'ai voulu ici éviter en faisant l'extraction trop rapidement).

Je ne mets que le test pour le joueur 1, le second test est symétrique.

    def _get_piece_location(self, piece):
        return self.game.pieces_on_board[piece]

    def test_has_a_winner_if_a_player_1_lion_is_on_the_other_side(self):
        drop_position = (1, BOARD_MAXIMAL_Y - 1)
        self.game.drop(PIECE_LION_P1, drop_position)
        self.game.move(drop_position, (1, BOARD_MAXIMAL_Y))

        if self.get_piece_location(PIECE_LION_P1)[1] == BOARD_MAXIMAL_Y:
            self.game.win_condition_happened = True

        self.assertTrue(self.game.has_winner())

À présent, afin de pouvoir traiter le cas d'une pièce qui n'est pas sur le tablier, j'écris le test correspondant à cette situation :

    def test_gives_invalid_location_for_piece_not_on_board(self):
        location = self._get_piece_location(PIECE_LION_P1)
        self.assertEqual(INVALID_LOCATION, location)

Qui ne fonctionne pas car la pièce n'existe pas dans le tableau associatif (j'obtiens une KeyError). Cela me permet de transformer _get_piece_location() :

    def get_piece_location(self, piece):
        return self.game.pieces_on_board.get(piece, INVALID_LOCATION)

Je peux maintenant extraire la fonction get_piece_location vers ShogiGame en même temps que les tests de victoire dans move. Je le fais à travers la création dans la classe de test d'une fonction check_lions_position_after_move() qui regroupe les deux tests et que je peux remanier.

Je vous laisse voir le résultat dans le code complet à la fin de l'article.

Et le cas complexe ?

Le cas complexe aurait été meilleur, l'état actuel de code nous le montre. Comme quoi, faire tout de suite les choix courageux qui demandent un peu plus de boulot paie généralement sur le plan de la qualité et de la flexibilité.

Que s'est passe-t-il avec la version simple ?

J'ai créé une fonction get_piece_location(), publique dans ShogiGame qui est une fonction d’utilité interne à la classe et qui devrait donc être privée.

Cependant, afin de la créer, j'ai du écrire un test qui oblige la fonction à être publique.

Ce n'est pas correct. Même si c'est fonction peut servir un jour de manière publique, ce n'est pas le cas aujourd'hui. Elle ne devrait donc pas l'être.

C'est d'ailleurs aussi le cas de get_piece_at() écrite dès les premiers tests. À ce moment là, get_piece_at() était nécessaire pour les premiers tests et sa seule présence ne nécessitait pas de sortir les gros moyens pour extraire un classe représentant un tablier.

L'arrivée de get_piece_location() donne un indice que cette classe devrait être créée.

Je me le note dans un coin. Mais ceci n'était censée être qu'une aparté. Je reviens au sujet que j'avais laissé en suspens à l'épisode précédent : l'implémentation de la réserve.

Retour à la réserve

À l'épisode précédent, j'étais arrivé à montrer le besoin d'existence d'une classe de cette forme :

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

Afin de l'implémenter, j'ai besoin de deux choses. La première est un nouveau fichier tray_tests.py dans lequel cette classe va être construite. La seconde est un liant entre Tray et ShogiGame.

En effet, ShogiGame est testé unitairement via l'utilisation d'un Mock Object. Le Mock Object n'ayant aucune liaison avec une classe réelle, il y a un risque de désynchronisation lors de l'évolution de l'une des deux classes.

Il y a deux choix :

  • soit commencer à écrire des tests fonctionnels qui vont lier l'ensemble des éléments du programme.
  • soit utiliser une fonctionnalité du framework de Mock pour spécifier cette liaison.

Je vais faire le second choix, plus sûr. Les éléments du jeu ne sont pas encore assez nombreux pour dérouler des tests fonctionnels qui utiliseraient des phases du jeu.

Je créé donc le fichier avec ce contenu :

    import unittest


    class TrayTestCase(unittest.TestCase):
        def test_can_be_created(self):
            Tray()

Étant donné que j'ai déjà une idée de l'interface, j'y ajoute le code de class Tray, avec son add_piece() qui ne fait rien.

J'y ajoute immédiatement un test sur cette méthode.

    def test_can_receive_pieces():
        tray = Tray()
        tray.add_piece(PIECE_PAWN_P1)

Qui ne passe pas car PIECE_PAWN_P1 est inconnu. Normal, ce symbole est défini dans le fichier d'à côté. La méthode me demanderais de définir localement PIECE_PAWN_P1 pour faire passer le test puis factoriser avec l'autre symbole. Pas de danger ici, je déplace tous les symboles dans un pieces.py dont j'importe les symboles dans les fichiers qui en ont besoin.

C'est bon, ça passe.

La deuxième étape était donc de lier le Mock Object à la classe Tray. Ainsi, lorsque des fonctions non définies dans Tray seront appelées, le framework de mock nous le signalera par une erreur.

Dans doubutsugame_tests.py, mon fichier, initial, j'ajoute un import de la classe Tray. Comme Tray est dans le fichier de tests, j'importe pour le moment tray_tests mais en changeant l'espace de nom, pour prévoir le futur moment où Tray sera dans son propre fichier.

    import tray_tests as tray

Puis je change le setUp de ShogiGameTestCase afin que le Mock Object soit lié à Tray :

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

Je suis un peu ennuyé cependant. J'ai écrit du nouveau code sans avoir de test qui échoue. Cela vient du fait que j'aurais probablement du lier le Mock Object à une classe dès le début. Pour vérifier que cela fonctionne, j'enlève la méthode add_piece de Tray, je lance les tests de ShogiGameTestCase. Ça échoue. Puis, je remets la méthode en place.

Nouveau parachutage

Maintenant que j'ai un début d'implémentation de réserve, j'ai envie de pousser dans ce sens. Je pourrais très bien compléter la classe avec des fonctions pour enlever une pièce de la réserve ou encore vérifier qu'une pièce est présente.

Seulement je considère que c'est aller trop vite. Qui me dit que les méthodes que je vais implémenter vont être utiles ? L'expérience, un peu. Mais l'expérience me dit aussi qu'il est courant d'implémenter des méthodes qui ne serviront jamais.

Et quelles seraient les signatures de ces méthodes ? Comment vont-elles être appelées ? L'expérience peut me donner des idées, mais l'expérience me dit aussi que pour bien définir l'appel d'une fonction, rien n'est mieux que commencer à l'appeler.

Puisque j'ai envoyé des pièces vers la réserve, la suite logique serait de la ramener sur le plateau.

Mon problème est que drop() est déjà implémentée avec des pièces directement spécifiées, créées sur le moment, à l'appel. Et que je me sers de drop() pour créer mes situations de départ de tests.

Est-ce vraiment un problème ? Voyons cela. Mon test suivant, dans ShogiGameTestCase, est de vérifier que lorsqu'un drop() a lieu, la réserve reçoit une demande de pièce.

    def test_takes_its_drop_pieces_from_the_tray(self):
        self.game.drop(PIECE_ELEPHANT_P2, (0, 0))
        self.tray.get_piece.assert_called_once_with(PIECE_ELEPHANT_P2)

J'obtiens alors l'erreur suivante : AssertionError: Expected to be called once. Called 0 times.

    def test_takes_its_drop_pieces_from_the_tray(self):
        piece_to_drop = PIECE_ELEPHANT_P2
        self.game.drop(piece_to_drop, (0, 0))
        self.tray.get_piece(piece_to_drop)
        self.tray.get_piece.assert_called_once_with(piece_to_drop)

Ce qui me donne l'erreur : AttributeError: Mock object has no attribute 'get_piece'.

J'ajoute la méthode à Tray.

À présent, le test qui demande à ce qu'une pièce qui n'est pas dans la réserve n'apparaisse pas sur le tablier. Je rappelle la philosophie initiale du système : une opération invalide est ignorée. Donc si la pièce n'est pas sur la réserve et qu'on demande à la parachuter, elle n'est tout simplement pas parachutée.

    def test_does_not_drop_a_piece_if_not_on_the_tray(self):
        location = (0, 0)
        piece_to_drop = PIECE_ELEPHANT_P2
        self.game.drop(piece_to_drop, location)

        self.tray.get_piece(piece_to_drop)

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(location))

Ne passe pas, puisque l'éléphant est bien parachuté.

Afin de faire passer le test, je programme le Mock Object pour qu'un appel à get_piece() renvoi PIECE_NONE. Puis j'implémente la résolution du test en prenant la valeur de retour de get_piece() et en vérifiant que je récupère bien la pièce que j'ai demandé. Si oui, j'appel drop(), si non, je ne l'appelle pas.

    def test_does_not_drop_a_piece_if_not_on_the_tray(self):
        location = (0, 0)
        piece_to_drop = PIECE_ELEPHANT_P2
        self.tray.get_piece.return_value = PIECE_NONE

        piece_on_tray = self.tray.get_piece(piece_to_drop)
        if piece_on_tray == piece_to_drop:
            self.game.drop(piece_to_drop, location)

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(location))

Ça passe, forcément. L'intéressant est alors de reporter ce code dans le test précédent.

    def test_takes_its_drop_pieces_from_the_tray(self):
        location = (0, 0)
        piece_to_drop = PIECE_ELEPHANT_P2
        self.tray.get_piece.return_value = PIECE_NONE

        piece_on_tray = self.tray.get_piece(piece_to_drop)
        if piece_on_tray == piece_to_drop:
            self.game.drop(piece_to_drop, location)

        self.tray.get_piece.assert_called_once_with(piece_to_drop)

Ça passe toujours.

Note de design : get_piece() renvoie la pièce demandée ou PIECE_NONE. Pourquoi ne pas renvoyer True ou False ? Car sémantiquement, ce n'est pas correct. Une fonction has_piece() pourrait répondre par un booléen. Mais une fonction get_piece() doit renvoyer une pièce.

C'est un fait malheureusement souvent oublié et qui se retrouve dans de nombreuse bases de code. Les fonctions doivent avoir un sens, elles doivent pouvoir se lire et la valeur de retour doit être cohérente. Et pour avoir une valeur de retour, le nom doit être une question dont la réponse est vrai ou faux.

Revenons au test. J'ai de la duplication, je peux donc factoriser dans la classe de tests.

    def drop_from_tray(self, piece_to_drop, location):
        piece_on_tray = self.tray.get_piece(piece_to_drop)
        if piece_on_tray == piece_to_drop:
            self.game.drop(piece_to_drop, location)

Avant d'ajouter un nouveau test, je reporte dans ma classe Tray réelle ce que j'ai déterminé par ses appels.

    class TrayTestCase(unittest.TestCase):
        def test_returns_piece_none_when_getting_absent_piece(self):
            tray = Tray()
            received_piece = tray.get_piece(PIECE_PAWN_P1)
            self.assertEqual(received_piece, PIECE_NONE)

Qui ne passe pas puisque la fonction get_piece() ne retourne rien. Je lui fait donc retourner PIECE_NONE puis j'écris un second test.

    def test_returns_asked_piece_when_getting_present_piece(self):
        asked_piece = PIECE_PAWN_P1
        tray = Tray()
        tray.add_piece(asked_piece)

        received_piece = tray.get_piece(asked_piece)
        self.assertEqual(received_piece, asked_piece)

Qui passe si j'implémente directement les méthodes de Tray.

    class Tray:
        def __init__(self):
            self.pieces = []

        def add_piece(self, piece):
            self.pieces.append(piece)

        def get_piece(self, piece):
            if piece in self.pieces:
                return piece
            return PIECE_NONE

Cependant, je dois aussi faire en sorte qu'une pièce prise soit enlevée de la réserve. J'en profite aussi pour factoriser la création de tray dans les tests et d'en profiter pour enlever le test de création d'une instance de Tray.

    def test_removes_taken_piece(self):
        asked_piece = PIECE_PAWN_P1
        self.tray.add_piece(asked_piece)

        self.tray.get_piece(asked_piece)
        received_piece = self.tray.get_piece(asked_piece)
        self.assertEqual(received_piece, PIECE_NONE)

Qui passe avec :

    def get_piece(self, piece):
        if piece in self.pieces:
            self.pieces.remove(piece)
            return piece
        return PIECE_NONE

Qui n'est peut-être pas la version la plus optimisée possible, car remove() sur la liste oblige à refaire le test qui vient d'être fait dans le if ... in .... Nous verrons ça plus tard. Je me laisse une note.

Je reviens maintenant à drop_from_tray(). Ça m'ennuie d'avoir deux fonctions pour le parachutage, l'une qui vient de la réserve, pendant une partie, et l'autre qui vient d'un peu nulle part, pour installer le tablier.

Je reviens donc sur le test def test_takes_its_drop_pieces_from_the_tray(self): pour ajouter une condition à celle déjà existante (qui pour rappel était le fait d'appeler tray.get_piece() avec la bonne pièce en paramètre).

La nouvelle condition est que la pièce a vraiment été placée.

    def test_takes_its_drop_pieces_from_the_tray(self):
        location = (0, 0)
        piece_to_drop = PIECE_ELEPHANT_P2
        self.tray.get_piece.return_value = PIECE_NONE

        self.drop_from_tray(piece_to_drop, location)

        self.tray.get_piece.assert_called_once_with(piece_to_drop)
        self.assertEqual(piece_to_drop, self.game.get_piece_at(location))

Et cela ne fonctionne pas, puisque le demande à tray.get_value() de répondre systématiquement PIECE_NONE. Il suffit de changer la valeur de retour à PIECE_ELEPHANT_P2 pour que le test passe.

Le problème, c'est que si je généralise drop_from_tray() dans le fonctionnement de drop() dès maintenant, aucun de mes autres test utilisant drop() ne va fonctionner. En effet, le Mock Object n'est pas configuré pour.

J'utilise une autre fonctionnalité des Mock Object : programmer un fonctionnement local. Dans ce framework, cela s'appelle un side_effect. D'autres frameworks peuvent utiliser d'autres systèmes, mais programmer les valeurs retournées par un Mock Object est essentiel.

Je programme le Mock Object pour que get_piece() renvoie ce qu'il reçoit en argument.

    self.tray.get_piece.side_effect = lambda x: x

Ainsi, tous les appels à get_piece() vont réussir. Cela fait passer le premier test, mais pas le second. Pour pouvoir créer la duplication, je dois écrire quelque chose comme ça pour le second test.

    self.tray.get_piece.side_effect = lambda x: x
    self.tray.get_piece.side_effect = None
    self.tray.get_piece.return_value = PIECE_NONE

Je révèle ainsi que je peux factoriser le side_effect qui passe toujours, mais je dois créer un fonctionnement qui reprogramme le side_effect pour le test où get_piece() doit renvoyer PIECE_NONE.

Voici donc le nouveau setUp() de la fonction de test et sa fonction pour changer de comportement.

    def setUp(self):
        self.tray = mock.Mock(tray.Tray)
        self.tray.get_piece.side_effect = lambda x: x

        self.game = ShogiGame(self.tray)

    def _tray_get_piece_returns_no_piece(self):
        self.tray.get_piece.side_effect = None
        self.tray.get_piece.return_value = PIECE_NONE

Après cela, je peux déplacer le test de drop_from_tray() dans drop(), puis, après avoir vérifié que les tests passent, changer les appels à drop_from_tray() en appels à drop(), puis finalement enlever drop_from_tray().Next

Conclusion

Dans cet épisode, j'ai donc créé une première implémentation de la réserve (Tray), et ShogiGame l'utilise pour y prendre ses pièces lors d'un parachutage et y envoyer les pièces lors d'une capture.

Il reste un problème cependant : la pièce qui est envoyée à la reserve conserve son joueur. Or lors d'une capture, la possession de la pièce passe à l'adversaire.

Voici un bon exercice pour la prochaine fois.

doubutsugame_tests.py

    import unittest
    import mock
    import tray_tests as tray
    from pieces import PIECE_PAWN_P1, PIECE_PAWN_P2, PIECE_ELEPHANT_P2, PIECE_LION_P1, PIECE_LION_P2, PIECE_NONE


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

    INVALID_LOCATION = (-100, -100)


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


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

        def capture_lion(self):
            self.win_condition_happened = True

        def has_winner(self):
            return self.win_condition_happened

        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):
            piece_on_tray = self.tray.get_piece(piece)
            if piece_on_tray == piece:
                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 get_piece_location(self, piece):
            return self.pieces_on_board.get(piece, INVALID_LOCATION)

        def _check_lions_position_after_move(self):
            if (self.get_piece_location(PIECE_LION_P1)[1] == BOARD_MAXIMAL_Y or
                    self.get_piece_location(PIECE_LION_P2)[1] == 0):
                        self.win_condition_happened = True

        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
                    self._check_lions_position_after_move()

        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] = None

                self.tray.add_piece(dest_piece)


    class ShogiGameTestCase(unittest.TestCase):
        def setUp(self):
            self.tray = mock.Mock(tray.Tray)
            self.tray.get_piece.side_effect = lambda x: x

            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_winner_if_a_player_1_lion_is_on_the_other_side(self):
            drop_position = (1, BOARD_MAXIMAL_Y - 1)
            self.game.drop(PIECE_LION_P1, drop_position)
            self.game.move(drop_position, (1, BOARD_MAXIMAL_Y))

            self.assertTrue(self.game.has_winner())

        def test_has_a_winner_if_a_player_2_lion_is_on_the_other_side(self):
            drop_position = (1, 1)
            self.game.drop(PIECE_LION_P2, drop_position)
            self.game.move(drop_position, (1, 0))

            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_gives_invalid_location_for_piece_not_on_board(self):
            location = self.game.get_piece_location(PIECE_LION_P1)
            self.assertEqual(INVALID_LOCATION, 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)

        def test_takes_its_drop_pieces_from_the_tray(self):
            location = (0, 0)
            piece_to_drop = PIECE_ELEPHANT_P2

            self.game.drop(piece_to_drop, location)

            self.tray.get_piece.assert_called_once_with(piece_to_drop)
            self.assertEqual(piece_to_drop, self.game.get_piece_at(location))

        def _tray_get_piece_returns_no_piece(self):
            self.tray.get_piece.side_effect = None
            self.tray.get_piece.return_value = PIECE_NONE

        def test_does_not_drop_a_piece_if_not_on_the_tray(self):
            self._tray_get_piece_returns_no_piece()

            location = (0, 0)
            piece_to_drop = PIECE_ELEPHANT_P2

            self.game.drop(piece_to_drop, location)

            self.assertEqual(PIECE_NONE, self.game.get_piece_at(location))

tray_tests.py

    import unittest
    from pieces import PIECE_PAWN_P1, PIECE_NONE


    class Tray:
        def __init__(self):
            self.pieces = []

        def add_piece(self, piece):
            self.pieces.append(piece)

        def get_piece(self, piece):
            """ Might be faster with an implementation not using a list with if..in then remove(). """
            if piece in self.pieces:
                self.pieces.remove(piece)
                return piece
            return PIECE_NONE


    class TrayTestCase(unittest.TestCase):
        def setUp(self):
            self.tray = Tray()

        def test_can_receive_pieces(self):
            self.tray.add_piece(PIECE_PAWN_P1)

        def test_returns_piece_none_when_getting_absent_piece(self):
            received_piece = self.tray.get_piece(PIECE_PAWN_P1)
            self.assertEqual(received_piece, PIECE_NONE)

        def test_returns_asked_piece_when_getting_present_piece(self):
            asked_piece = PIECE_PAWN_P1
            self.tray.add_piece(asked_piece)

            received_piece = self.tray.get_piece(asked_piece)
            self.assertEqual(received_piece, asked_piece)

        def test_removes_taken_piece(self):
            asked_piece = PIECE_PAWN_P1
            self.tray.add_piece(asked_piece)

            self.tray.get_piece(asked_piece)
            received_piece = self.tray.get_piece(asked_piece)
            self.assertEqual(received_piece, PIECE_NONE)