Mokona Guu Center

Test Driven Development : mouvement et capture

Publié le

Puisque j'ai commencé à implémenter les tests de mouvements des pièces de Dôbutsu Shogi, je vais continuer dans cette veine pour cette session.

Pour le moment, nous pouvons déplacer une pièce d'un ou deux mouvements. Cependant, aucune gestion de validité n'est gérée. Que se passe-t-il si deux pions sont installés au même endroit ? Que se passe-t-il si un mouvement fait sortir le pion du tablier ? Comment faire en sorte que les déplacements respectent les règles du jeu ?

Pièce de Shôgi en bois

Je me pose un instant pour réfléchir. Je vois deux grandes possibilités à la gestion des déplacements : soit la classe ShogiGame refuse les déplacements en provoquant une erreur, soit elle ignore les déplacements erronés tout en permettant d'obtenir la liste des mouvements licites.

Je préfère la seconde possibilité. Si je me projette dans le futur, le programme servira essentiellement à parcourir un arbre de possibilités avec deux IAs qui ne chercheront pas à tricher.

Au delà, si jamais je voulais ensuite transformer le logiciel dans un mode interactif avec un joueur humain ou bien une IA externe que je ne contrôlerais pas, alors ignorer les mouvements illicites suffit. Une interface pour joueur humain peut se contenter d'avoir accès aux mouvements licites pour dialoguer avec l'humain et lui proposer des choix ; l'état du jeu sera systématiquement consulté depuis le système.

Sur un jeu comme le Dôbutsu Shôgi, le nombre de choix est assez limité. Sur un jeu aux choix plus nombreux, l'interface pourra émettre des requêtes de sous ensemble de choix (de type « que puis-je faire avec ce pion », par exemple).

Les pièces ne s'empilent pas

Mon premier test de cette session est donc de vérifier qu'une nouvelle pièce posée à un endroit où une pièce est déjà présente ne fera rien.

    def test_ignores_a_new_piece_if_a_piece_is_already_at_the_given_location(self):
        self.game.drop(PIECE_PAWN, (0, 0))
        self.game.drop(PIECE_ELEPHANT, (0, 0))
        self.assertEqual(PIECE_PAWN, self.game.get_piece_at((0, 0)))

Le test échoue en disant que la piece trouvée à l'emplacement est une PIECE_NONE (représentée par la chaîne vide). Cela peut étonner, étant donné que deux pièces viennent d'être posées. Mais un regard à l'implémentation de récupération d'une pièce sur le plateau lève le doute :

    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

Cette fonction considère qu'une pièce n'est présente que si elle est la seule à l'endroit demandé. Le contenu de pieces_on_board après le placement des deux pièces est {'P': (0, 0), 'E': (0, 0)}, et donc la fonction considère qu'il n'y a pas de pièce en (0, 0).

Résoudre le test est simple, il suffit de ne valider drop que si aucune pièce n'est au préalable présente sur le tablier. Techniquement, cela amène à :

  • créer une fonction drop locale qui n'appelle game.drop que si un appel à get_piece_at renvoie None.
  • vérifier que le test passe.
  • extraire le drop local vers celui de ShogiGame.

Faire l'exercice mental est indispensable pour vérifier que rien ne se met en travers de ces opérations. Puisque rien ne se met en travers, il est maintenant temps de gagner en vélocité en se passant de ces opérations et en implémentant directement dans le drop de ShogiGame.

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

Attention : il est indispensable lorsque l'on commence à prendre l'habitude de ses raccourcis d'effectuer mentalement les opérations d'extraction. Un raccourci malheureux est vite arrivé.

Les pièces sont sur le tablier

Deuxième test de validité, une pièce doit être placée sur le tablier. Dans le même esprit, placer une pièce en dehors du tablier est tout simplement ignoré.

Question corollaire, que se passe-t-il si on appel get_piece_at avec des coordonnées en dehors du tablier ? Ici, on ne peut ignorer, la fonction doit renvoyer quelque chose. Je décide de renvoyer PIECE_NONE, il n'y a en effet pas de pièce de jeu à cet endroit.

    def test_ignores_a_dropped_piece_outside_the_board(self):
        self.game.drop(PIECE_PAWN, (-1, 0))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((-1, 0)))

Un premier test doit être minimal. Il est tentant d'envoyer directement plusieurs coordonnées invalides pour toutes les vérifier. Cependant, cela va demander la création d'un code important pour tester plusieurs cas. Je préfère définir la structure avec un cas simple, je généraliserai après.

Voilà une résolution locale possible.

    def test_ignores_a_dropped_piece_outside_the_board(self):
        self.game.drop(PIECE_PAWN, (-1, 0))
        self.game.pieces_on_board = dict([pieces for pieces in self.game.pieces_on_board.items() if pieces[1][0] >= 0])
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((-1, 0)))

Je veux montrer par là qu'il n'est pas nécessaire de résoudre localement par fonction locale. Il est possible de faire plus direct, ce qui est parfois nécessaire dans certains langages moins flexibles en syntaxe que Python.

Cette session se focalise sur la prise de distance de la méthode « pure ». J'ai déjà mentionné que lorsque l'on s'éloigne de la méthode « pure », il est important d'imaginer ce que cela aurait donné. Je préfère le répéter.

Donc. Il faudrait à présent décrire un nouveau test avec un autre type de coordonnées invalides, (0, -1) par exemple. La résolution aurait alors été du même style, mais avec la clause if différente :

    self.game.pieces_on_board = dict([pieces for pieces in self.game.pieces_on_board.items() if pieces[1][1] >= 0])

Cela aurait créé du code dupliqué à un paramètre prèt, ce qui permet de se dire dès à présent que l'extraction d'un drop au niveau de la classe de tests avec appel de la fonction drop de ShogiGame est valide.

    def drop(self, piece, location):
        self.game.drop(piece, location)
        self.game.pieces_on_board = dict([pieces for pieces in self.game.pieces_on_board.items()
                                          if (pieces[1][0] >= 0 and pieces[1][1] >= 0)])

    def test_ignores_a_dropped_piece_outside_the_board(self):
        self.drop(PIECE_PAWN, (-1, 0))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((-1, 0)))

        self.drop(PIECE_PAWN, (0, -1))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((0, -1)))

Les tests sont au vert. Et cela demande tout de suite une passe de refactoring.

La première est au niveau de drop. La régénération de liste est fonctionnelle, mais :

  • la clause if est longue et va s'allonger.
  • on créé une nouvelle liste à chaque appel à drop, alors qu'il serait plus simple tout simplement de ne pas ajouter la pièce aux coordonnées invalides.

Je m'occupe de ces deux cas l'un après l'autre en modifiant tout d'abord drop comme ceci :

    def _is_location_valid(self, location):
        return location[0] >= 0 and location[1] >= 0

    def drop(self, piece, location):
        self.game.drop(piece, location)
        self.game.pieces_on_board = dict([pieces for pieces in self.game.pieces_on_board.items()
                                          if self._is_location_valid(pieces[1])])

Puis je passe le test d'emplacement valide a priori :

    def drop(self, piece, location):
        if self._is_location_valid(location):
            self.game.drop(piece, location)

Les tests passent.

Pourquoi avoir fait le refactoring en deux étapes ? Par principe de « baby steps ». En n'effectuant qu'une petite modification à chaque étape et en vérifiant que les tests passent toujours, j'évite de mauvaises surprises après avoir déplacé une grande quantité de code.

Dans ce genre d'étapes, il est très rare que je me permette un raccourcis, car elles sont la source de nombreuses erreurs.

Je peux maintenant ajouter un troisième test et compléter _is_location_valid. Pour rappel, un tablier de Dôbutsu Shôgi fait 3 par 4 cases.

    def _is_location_valid(self, location):
        return location[0] >= 0 and location[1] >= 0 and location[0] <= 2

    def drop(self, piece, location):
        if self._is_location_valid(location):
            self.game.drop(piece, location)

    def test_ignores_a_dropped_piece_outside_the_board(self):
        self.drop(PIECE_PAWN, (-1, 0))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((-1, 0)))

        self.drop(PIECE_PAWN, (0, -1))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((0, -1)))

        self.drop(PIECE_PAWN, (3, 0))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at((3, 0)))

En refactoring ici, il y a deux choses à faire :

  • passer les coordonnées limites de _is_location_valid sous forme de constantes.
  • enlever la duplication dans le test.
    BOARD_MINIMAL_X = 0
    BOARD_MINIMAL_Y = 0
    BOARD_MAXIMAL_X = 2

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

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

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

Ce qui nous permet d'ajouter facilement un quatrième cas d'invalidité que je vous laisserai découvrir dans le source complet.

Qu'en est-il de toutes les autres combinaisons invalides ? Il y en a beaucoup trop pour tous les tester, je me contente ici de cas limites. Si jamais un bug apparaissait plus tard à cause d'autres cas limites non testés, j'ai créé lors de ma construction en TDD un framework qui me permettra facilement d'ajouter le cas problématique. Les tests ne passeront plus et je pourrais résoudre le bug en suivant la méthode.

Il existe un autre type de TDD, nommé « Property based TDD ». Ce type de tests envoie aux fonctions un grand ensemble d'entrées créées par des « générateurs ».

Cela permet de couvrir beaucoup plus de cas au prix d'un temps de tests (un peu ?) plus long. Je n'en parlerai pas plus ici, du moins pour le moment. Le « Property based TDD » implique d'autres manières de construire les tests. « Baby steps » même en apprentissage...

Quelques considérations

Si je regarde un peu l'état de ShogiGame, je fais deux constatations :

  • _is_location_valid n'est pas une fonction membre, self n'est pas nécessaire pour répondre à la question et sa place logique serait hors de cette classe.
  • ShogiGame a essentiellement des méthodes concernant l'état des pièces sur le tablier.
  • Il n'y a pas de relation entre has_winner et les pièces sur le tablier.

Il me semble intéressant de continuer les tests vers l'implémentation d'une capture de pièce.

Capture !

J'ai actuellement une possibilité de mouvement (move) et de parachutage (drop). Je préfère gérer la capture comme une autre méthode, afin que l'action soit explicite. Cela évite de gérer des cas d'erreurs, ShogiGame se contentant d'ignorer ce qui n'est pas valide.

Donc tout d'abord, ShogiGame doit ignorer une capture non explicite.

    def test_ignores_a_move_to_a_location_where_there_is_an_occupied_piece(self):
        self.game.drop(PIECE_PAWN, (0, 0))
        self.game.drop(PIECE_ELEPHANT, (1, 1))
        self.game.move((0, 0), (1, 1))

        self.assertEqual(PIECE_PAWN, self.game.get_piece_at((0, 0)))
        self.assertEqual(PIECE_ELEPHANT, self.game.get_piece_at((1, 1)))

J'effectue mentalement la solution locale et l'extraction pour arriver à :

    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

Puis j'effectue une capture explicite :

    def test_allows_a_pawn_to_capture_an_elephant(self):
        self.game.drop(PIECE_PAWN, (0, 1))
        self.game.drop(PIECE_ELEPHANT, (1, 1))
        self.game.capture((0, 1), (1, 1))

        self.assertEqual(PIECE_NONE, self.game.get_piece_at((0, 1)))
        self.assertEqual(PIECE_PAWN, self.game.get_piece_at((1, 1)))

capture n'existe pas. Je la remplace avec une résolution locale simple.

    def test_allows_a_pawn_to_capture_an_elephant(self):
        self.game.drop(PIECE_PAWN, (0, 1))
        self.game.drop(PIECE_ELEPHANT, (1, 1))

        self.game.pieces_on_board[PIECE_PAWN] = (1, 1)
        self.game.pieces_on_board[PIECE_ELEPHANT] = None

        self.assertEqual(PIECE_NONE, self.game.get_piece_at((0, 1)))
        self.assertEqual(PIECE_PAWN, self.game.get_piece_at((1, 1)))

Je fait de même avec l'éléphant qui capture le poussin.

    def test_allows_an_elephant_to_capture_a_pawn(self):
        self.game.drop(PIECE_PAWN, (0, 0))
        self.game.drop(PIECE_ELEPHANT, (1, 1))

        self.game.pieces_on_board[PIECE_PAWN] = None
        self.game.pieces_on_board[PIECE_ELEPHANT] = (0, 0)

        self.assertEqual(PIECE_NONE, self.game.get_piece_at((1, 1)))
        self.assertEqual(PIECE_ELEPHANT, self.game.get_piece_at((0, 0)))

Voici un nouvel exemple de simplification de duplication pour faire apparaître les motifs, en « Baby Steps ». Je commence par nommer tous les éléments des deux tests en from_location et to_location pour le déplacement, et from_piece et to_piece pour les pièces concernées.

La réécriture donne ceci :

    def test_allows_a_pawn_to_capture_an_elephant(self):
        from_location = (0, 1)
        to_location = (1, 1)

        from_piece = PIECE_PAWN
        to_piece = PIECE_ELEPHANT

        self.game.drop(from_piece, from_location)
        self.game.drop(to_piece, to_location)

        self.game.pieces_on_board[from_piece] = to_location
        self.game.pieces_on_board[to_piece] = None

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(from_location))
        self.assertEqual(from_piece, self.game.get_piece_at(to_location))

    def test_allows_an_elephant_to_capture_a_pawn(self):
        from_location = (0, 1)
        to_location = (1, 1)

        from_piece = PIECE_ELEPHANT
        to_piece = PIECE_PAWN

        self.game.drop(to_piece, to_location)
        self.game.drop(from_piece, from_location)

        self.game.pieces_on_board[to_piece] = None
        self.game.pieces_on_board[from_piece] = to_location

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(from_location))
        self.assertEqual(from_piece, self.game.get_piece_at(to_location))

Et la duplication devient facile à factoriser. À noter que durant le renommage, j'ai fais une erreur en me mélangeant les pinceaux entre les pièces de départ et d'arrivée ; une erreur qu'il aurait été très possible de faire lors d'une modification de fonction dans du code sans tests. Ici, les tests m'ont immédiatement indiqué que quelque chose ne fonctionnait plus comme avant, j'ai donc immédiatement pu agir.

Extraire une fonction capture peut se faire de cette manière (je n’inclue que le premier test, le second est similaire).

    def capture(self, from_location, to_location):
        from_piece = self.game.get_piece_at(from_location)
        to_piece = self.game.get_piece_at(to_location)

        self.game.pieces_on_board[from_piece] = to_location
        self.game.pieces_on_board[to_piece] = to_piece

    def test_allows_a_pawn_to_capture_an_elephant(self):
        from_location = (0, 1)
        to_location = (1, 1)

        from_piece = PIECE_PAWN
        to_piece = PIECE_ELEPHANT

        self.game.drop(from_piece, from_location)
        self.game.drop(to_piece, to_location)

        self.capture(from_location, to_location)

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(from_location))
        self.assertEqual(from_piece, self.game.get_piece_at(to_location))

“Note a posteriori : un lecteur me fait remarquer que self.game.pieces_on_board[to_piece] = to_piece apparaît de nulle part tout à coup. En effet. Ce n'est pas faux (les tests passent), mais c'est incorrect. La pièce à un emplacement invalide, c'est donc comme si elle n'était pas placée. mais le tableau est incohérent.”

Les tests passent, nous sommes en refactoring. Je trouve que préciser les pièces est pénible et demanderait beaucoup de code inutile à un utilisateur du système.

Cela se fait en deux temps. D'abord en effectuant le nouveau calcul dans la fonction.

    def capture(self, (from_piece, from_location), (to_piece, to_location)):
        from_piece = self.game.get_piece_at(from_location)
        to_piece = self.game.get_piece_at(to_location)

        self.game.pieces_on_board[from_piece] = to_location
        self.game.pieces_on_board[to_piece] = to_piece

Puis après vérification que les tests passent, élimination des paramètres inutiles.

    def capture(self, from_location, to_location):
        from_piece = self.game.get_piece_at(from_location)
        to_piece = self.game.get_piece_at(to_location)

        self.game.pieces_on_board[from_piece] = to_location
        self.game.pieces_on_board[to_piece] = to_piece

Problème : si une des deux positions ne contient pas de pièces, une clé PIECE_NONE va être ajoutée à pieces_on_board. Qu'à cela ne tienne, ajoutons un nouveau test.

Au moment d'écrire le nom du test, je trouve un meilleur nom pour les variables de pièces prenantes et prises, je change donc les noms, je lance les tests. Tout va bien, je peux continuer.

    def test_ignores_a_capture_if_source_location_has_no_piece(self):
        source_location = (0, 1)
        dest_location = (1, 1)

        dest_piece = PIECE_PAWN

        self.game.drop(dest_piece, dest_location)

        self.capture(source_location, dest_location)

        self.assertEqual(PIECE_NONE, self.game.get_piece_at(source_location))
        self.assertEqual(dest_piece, self.game.get_piece_at(dest_location))

Que je fais passer avec cette modification :

    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:
            self.game.pieces_on_board[source_piece] = dest_location
            self.game.pieces_on_board[dest_piece] = dest_piece

Et le test symétrique :

    def test_ignores_a_capture_if_dest_location_has_no_piece():
        source_location = (0, 1)
        dest_location = (1, 1)

        source_piece = PIECE_ELEPHANT

        self.game.drop(source_piece, source_location)

        self.capture(source_location, dest_location)

        self.assertEqual(source_piece, self.game.get_piece_at(source_location))
        self.assertEqual(PIECE_NONE, self.game.get_piece_at(dest_location))

Que je fais passer avec :

    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:
            self.game.pieces_on_board[source_piece] = dest_location
            self.game.pieces_on_board[dest_piece] = dest_piece

Tout cela fait beaucoup de duplication dans les tests, je factorise. De plus test_allows_a_pawn_to_capture_an_elephant et test_allows_an_elephant_to_capture_a_pawn testent essentiellement la même chose, j'enlève le premier.

Enlever des tests ?

Je viens d'enlever un test. C'est une opération de nettoyage valide, mais qu'il faut bien peser. Dans ce cas ci, les deux tests avaient servis à créer la première implémentation de capture mais sont redondants.

La multiplication des tests peut amener à un ralentissement du lancement des tests et donc de la vélocité de développement. D'un autre côté, s'ils sont là, c'est que normalement il y avait une raison, et il les garder est intéressant.

Un outil d'aide à la décision peut être de vérifier la couverture de code : si en enlever un test la couverture de code est conservée, le test est peut-être inutile (peut-être !). Si la couverture du code diminue, le test doit être gardé.

Couverture de code ?

La couverture de code est une métrique qui indique quelles lignes (ou parfois plus détaillé, quelles instructions) ont été exécutées dans un programme. Puisque tout le code qu'on a écrit provient de tests et que le code que l'on a écrit est minimal pour faire passer les tests, la couverture doit normalement se trouver à 100%.

Dans la pratique, un programme qui n'a pas été fait en TDD et dont les tests sont faits a posteriori est souvent très difficile à amener à 100% de couverture de code.

    $ coverage run -m unittest doubutsugame_tests
    .............
    ----------------------------------------------------------------------
    Ran 13 tests in 0.001s

    OK

    $ coverage report -m 
    Name                 Stmts   Miss  Cover   Missing
    --------------------------------------------------
    doubutsugame_tests     118      0   100%   

C'est bon. 100% !

Pour terminer, voici le code en l'état à la fin de cet article.

    import unittest

    PIECE_PAWN = "P"
    PIECE_ELEPHANT = "E"
    PIECE_NONE = ""

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


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

        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


    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):
            location = (0, 0)
            self.game.drop(PIECE_ELEPHANT, location)
            self.assertEqual(PIECE_ELEPHANT, 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, source)

            self.game.move(source, destination)

            self.assertEqual(PIECE_ELEPHANT, 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, 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, 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, self.game.get_piece_at(locations[0]))
            self.assertEqual(PIECE_NONE, self.game.get_piece_at(locations[1]))
            self.assertEqual(PIECE_ELEPHANT, 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, (0, 0))
            self.game.drop(PIECE_ELEPHANT, (0, 0))
            self.assertEqual(PIECE_PAWN, self.game.get_piece_at((0, 0)))

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

        def test_ignores_a_move_to_a_location_where_there_is_an_occupied_piece(self):
            self.game.drop(PIECE_PAWN, (0, 0))
            self.game.drop(PIECE_ELEPHANT, (1, 1))
            self.game.move((0, 0), (1, 1))

            self.assertEqual(PIECE_PAWN, self.game.get_piece_at((0, 0)))
            self.assertEqual(PIECE_ELEPHANT, self.game.get_piece_at((1, 1)))

        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:
                self.game.pieces_on_board[source_piece] = dest_location
                self.game.pieces_on_board[dest_piece] = dest_piece

        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.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, (0, 0)), (PIECE_PAWN, (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_NONE, self.game.get_piece_at(source_location))
            self.assertEqual(source_piece, self.game.get_piece_at(dest_location))

        def test_ignores_a_capture_if_source_location_has_no_piece(self):
            list_of_pieces = ((None, (0, 1)), (PIECE_PAWN, (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_NONE, self.game.get_piece_at(source_location))
            self.assertEqual(dest_piece, self.game.get_piece_at(dest_location))

        def test_ignores_a_capture_if_dest_location_has_no_piece(self):
            list_of_pieces = ((PIECE_ELEPHANT, (0, 1)), (None, (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(source_piece, self.game.get_piece_at(source_location))
            self.assertEqual(PIECE_NONE, self.game.get_piece_at(dest_location))