Test Driven Development : le premier joueur
Publié le
Rappel de l'épisode précédent là où je l'avais laissé. J'avais sélectionné deux nouvelles règles du jeu à implémenter. Le fait que le premier joueur était déterminé au hasard et le fait que chaque pièce était soumise à des déplacements contraints.
Du hasard
Je commence donc pas la sélection du premier joueur. Le concept de premier joueur se situe au niveau d'une session de jeu, c'est donc vers session_tests.py
que je me dirige.
Mais comment vérifier qu'un résultat est aléatoire ?
On pourrait imaginer un test qui lance plusieurs session et vérifie que le joueur choisi est parfois l'un, parfois l'autre.
On ne le fait pas...
Pour deux raisons. Même si les générateurs pseudos aléatoire ont tendance à ne pas les sortir, un tirage \"0, 0, 0, 0, 0, 0, 0\" est un tirage parfaitement valable d'une séquence aléatoire. Alors, combien d'itération devrais-je choisir ?
La seconde raison, c'est qu'on n'est pas là pour vérifier la distribution du générateur utilisé.
Alors comment fait-on ?
Lorsque l'on ne veut pas être dépendant d'un résultat, il y a un moyen simple de le contrôler : il suffit de le fournir.
def test_is_started_with_a_designated_first_player(self):
self.session.start_game(first_player=P1)
self.assertEqual(P1, self.session.get_active_player())
J'utilise un paramètre nommé pour un appel de fonction qui lancera la session. Je vérifie ensuite que la session a le bon joueur actif.
Je n'aime pas trop cet accesseur get_active_player()
car pour le moment, il ne me sert que dans le test. Mais il va me permettre de progresser.
La résolution est ultra simple :
def start_game(self, first_player):
pass
def get_active_player(self):
return P1
Mais cette histoire de get_active_player()
ne me plait vraiment pas. Actuellement, je n'ai à aucun moment besoin de savoir quel est le joueur actif. L'idée est plutôt que lorsqu'un tour de jeu est déroulé, l'objet joueur actif soit appelé pour lui demander quel est son choix.
C'est la philosophie derrière Player
et PlayerDispatcher
.
Mais pour cela, la session de jeu a besoin de connaître les joueurs.
Voici une proposition :
def test_starts_a_session_with_two_players(self):
player_1 = mock.Mock(Player)
player_2 = mock.Mock(Player)
self.session.start_game(players=[player_1, player_2], first_player=P1)
Pour que cela fonctionne, je dois séparer Player
(et j’entraîne avec PlayerDispatcher
) dans un fichier player.py
, je mets les bons import
dans les bons fichiers.
Je peux ensuite modifier la fonction start_game()
.
Mais si j'écris ceci :
def start_game(self, players, first_player):
pass
Alors le test précédent sur la décision du premier joueur ne passe plus, car l'argument players
n'est pas spécifié. Je pourrais mettre une valeur par défaut au paramètre comme liste vide, ce qui m'obligerait aussi à mettre une valeur par défaut à first_player
.
def start_game(self, players=[], first_player=P1):
pass
Ça fait beaucoup de choses faites juste pour qu'un test passe. Et tout cela ne semble pas aller dans la bonne direction. Cela signifierait que je peux démarrer le système sans joueur, mais avec un premier joueur ? Ça n'a pas de sens.
Je récapitule : il faut donner à la session deux joueurs et un premier joueur.
Et c'est exactement ce que fait PlayerDispatcher
. La classe reçoit deux objets joueurs, dont un est actif et l'autre non. Parfait, pourquoi refaire le boulot ?
J'efface mes deux tests, start_game()
et get_active_player()
et je recommence :
def test_starts_with_a_player_dispatcher(self):
player_1 = mock.Mock(Player)
player_2 = mock.Mock(Player)
dispatcher = PlayerDispatcher(player_1, player_2)
self.session.start_game(dispatcher)
D'accord, mais alors cette histoire d'aléatoire, comment est-ce que je la passe ?
Ok, pause !
Il y a un truc qui ne passe pas, c'est évident.
Dans l'épisode précédent, j'avais dit que Session
était une fabrique. Une fabrique prend quelques paramètre pour produire un objet. Mais là, il semblerait que je doive passer un certain nombre d'objets que je doive configurer au préalable.
Ça ne colle pas.
Session
n'est pas une fabrique. Je créé une vrai fabrique :
class SessionFactoryTestCase(unittest.TestCase):
def test_creates_a_session(self):
session = SessionFactory().create()
self.assertIsNotNone(session)
class SessionFactory:
def create(self):
return Session()
Je vérifie ensuite que cette session créée l'a été avec deux joueurs :
def test_creates_a_session_with_two_players(self):
self.assertEqual(2, len(self.session.get_players()))
Qui passe avec une méthode toute simple dans Session
:
def get_players(self):
return [None, None]
Puis que ces deux joueurs sont différents :
def test_creates_a_session_with_two_different_players(self):
players = self.session.get_players()
self.assertEqual(2, len(players))
self.assertTrue(players[0] != players[1])
Remarque : pourquoi pas self.assertNotEqual(players0, players1)
? Sémantiquement, assertNotEqual()
a en premier paramètre une valeur connue attendue. Or ici, c'est une comparaison de deux valeurs inconnues.
Ça passe avec, par exemple :
def get_players(self):
return [None, 1]
Et finalement, le joueur active doit être l'un des deux joueurs :
def test_creates_a_session_with_one_of_the_player_active(self):
players = self.session.get_players()
active_player = self.session.get_active_player()
self.assertTrue(active_player == players[0] or active_player == players[1])
Ce genre de tests est un peu ennuyeux car il se peut qu'il soit faux... de temps en temps, puisqu'il fait intervenir l'aléatoire. Il peut être très pénible de voir un test échouer en intégration continue puis le lancer soi même et le voir passer.
Il faut garder ces tests simples pour pouvoir les analyser simplement.
Pour le moment, le test peut passer avec ceci :
def get_active_player(self):
return None
Bien, à présent que ces tests sont mis en place, je peux continuer au point où j'en étais avec Session
.
J'en étais à ce test :
def test_starts_with_a_player_dispatcher(self):
player_1 = mock.Mock(Player)
player_2 = mock.Mock(Player)
dispatcher = PlayerDispatcher(player_1, player_2)
self.session.start_game(dispatcher)
J'étais resté sur l'idée d'utiliser un PlayerDispatcher
pour donner à la Session
la notion de joueurs, dont celui actif.
Ça me semble correct, mais d'où viennent les joueurs ? De la fabrique. Mais la fabrique doit-elle créer les joueurs ? Probablement pas, vu que ceux-ci peuvent être de types différents (des IAs différentes par exemple) et que de toute façon, actuellement, il n'y a pas d'implémentation concrète d'un joueur.
Donc en fait, test_starts_with_a_player_dispatcher
n'est pas bon, je peux effacer le test.
Par contre, la Session
doit être créée avec un ensemble de joueurs.
Je change donc la création d'une session :
def setUp(self):
self.player_1 = mock.Mock(Player)
self.player_2 = mock.Mock(Player)
self.session = Session([self.player_1, self.player_2])
Que je fais passer comme ceci, afin de ne briser aucun test :
def __init__(self, players=[]):
...
Puisque la fabrique doit transmettre les joueurs à la session, je change aussi le test de création de la fabrique :
class SessionFactoryTestCase(unittest.TestCase):
def setUp(self):
self.player_1 = mock.Mock(Player)
self.player_2 = mock.Mock(Player)
self.session = SessionFactory().create([self.player_1, self.player_2])
Que je fais passer comme ceci :
class SessionFactory:
def create(self, players):
return Session()
À présent, le test test_creates_a_session_with_two_different_players()
n'a plus tout à fait le même sens, car on connaît les joueurs passés.
Je change le test comme ceci :
def test_creates_a_session_with_the_given_players(self):
players = self.session.get_players()
self.assertEqual(2, len(players))
self.assertIn(self.player_1, players)
self.assertIn(self.player_2, players)
Ce qui m'oblige afin de faire passer le test à créer la tuyauterie de transmission des joueurs en paramètre de la fabrique jusqu'à la session :
class SessionFactory:
def create(self, players):
return Session(players)
class Session:
def __init__(self, players=[]):
# ... Board initialization
self._players = players
def _place_row(self, pieces, player, row):
for x, piece in enumerate(pieces):
if piece is not None:
self.board.place_piece(Piece(piece, player), (x, row))
def get_players(self):
return self._players
def get_active_player(self):
return self._players[0]
start_game()
disparaît car il n'y a plus de tests pour cette fonction de toute façon vide.
Tout passe ?
Ok, je peux introduire mon dispatcher.
def test_ask_move_to_active_player(self):
self.session.ask_player_move()
self.player_1.ask_move.assert_called_once_with()
Que je fais passer avec :
def ask_player_move(self):
self.get_active_player().ask_move()
J'étends ensuite le test :
def test_ask_move_to_active_player(self):
self.session.ask_player_move()
self.player_1.ask_move.assert_called_once_with()
self.session.ask_player_move()
self.player_2.ask_move.assert_called_once_with()
Et voilà le dispatcher, ouf !
class Session:
def __init__(self, players=[]):
# ... Board initialization
self._dispatcher = PlayerDispatcher(players[0], players[1])
def _place_row(self, pieces, player, row):
for x, piece in enumerate(pieces):
if piece is not None:
self.board.place_piece(Piece(piece, player), (x, row))
def get_players(self):
return self._dispatcher.players
def get_active_player(self):
return self._dispatcher.active_and_opponent[0]
def ask_player_move(self):
self._dispatcher.ask_player_move()
Et l'aléatoire ?
Comme je l'ai dit plus haut, je ne teste pas l'aléatoire. Je dois me contenter de l'ajouter sans test. C'est une entorse au principe du TDD.
Note : si vous connaissez ou trouvez un moyen d'écrire un test qui entraînerait l'écriture d'un choix aléatoire, je suis intéressé.
L'ensemble des joueurs que je passe à SessionFactory
est mélangé via la fonction sample()
et envoyé à la construction de Session
.
class SessionFactory:
def create(self, players):
return Session(random.sample(players, len(players)))
Les tests passent. Je les lance quelques fois, histoire d'être certain... tout en sachant qu'il n'y a pas de certitude.
Le mouvement des pièces
Bien, la session est créée avec un tablier en position de départ, un premier joueur s’apprête à jouer. Il est donc temps d'implémenter la restriction des mouvements des pièces.
Dans la philosophie du système, un mouvement sera ignoré si le déplacement n'est pas valable. Il faut donc le valider lorsqu'il est demandé, c'est à dire dans ShogiGame
.
Une première idée serait de tester des déplacements valides et invalides dans ShogiGameTestCase
. Cependant, cela alourdirait ces tests là, cela provoquerait probablement l'implémentation de la validation dans ShogiGame
qui a la responsabilité de faire respecter les règles, mais plutôt en utilisant des classes pour l'aider.
Ce que je vais vérifier plutôt, c'est que les déplacements et captures font appel à une aide extérieur. Ensuite, je pourrai tester cette classe extérieure.
Pour ce premier test, peu importe que le mouvement soit valide ou pas, ce qui est obligatoire, c'est l'appel du validateur de mouvement. Incidemment, cela évite de faire échouer un certain nombre de tests déjà écrit qui auraient des déplacements invalides d'un point de vue des restrictions de mouvements (les tests de capture ou encore les tests de déplacement hors du tablier).
def test_calls_a_movement_validator_when_moving_a_piece(self):
validator = mock.Mock()
self.game.drop(PIECE_PAWN_P1, (1, 1))
self.game.move((1, 1), (1, 2))
validator.is_movement_valid.assert_called_once_with(PIECE_PAWN_P1, (1, 1), (1, 2))
Ici, j'y vais doucement avec une résolution locale.
def test_calls_a_movement_validator_when_moving_a_piece(self):
validator = mock.Mock()
piece = PIECE_PAWN_P1
from_location = (1, 1)
to_location = (1, 2)
self.game.drop(piece, from_location)
validator.is_movement_valid(piece, from_location, to_location)
self.game.move(from_location, to_location)
validator.is_movement_valid.assert_called_once_with(piece, (1, 1), (1, 2))
Ça passe. Même chose pour la capture.
def test_calls_a_movement_validator_when_capturing_a_piece(self):
validator = mock.Mock()
piece = PIECE_PAWN_P1
from_location = (1, 1)
to_location = (1, 2)
self.game.drop(piece, from_location)
self.game.drop(PIECE_PAWN_P2, to_location)
validator.is_movement_valid(piece, from_location, to_location)
self.game.capture(from_location, to_location)
validator.is_movement_valid.assert_called_once_with(piece, from_location, to_location)
Je peux en extraire l’existence d'une interface de validation de mouvement.
class MovementValidator:
def is_movement_valid(self, piece, source, destination):
pass
Je change les validator = mock.Mock()
en validator = mock.Mock(MovementValidator)
, puis je factorise.
J'amène d'abord validator
dans setUp()
(avec transformation en self.validator
du coup). Puis j'ajoute un argument validator
à l'init()
de ShogiGame
.
class ShogiGameTestCase(unittest.TestCase):
def setUp(self):
self.tray = mock.Mock(tray.Tray)
self.tray.pop_piece.side_effect = lambda x: x
self.board = Board()
self.validator = mock.Mock(MovementValidator)
self.game = ShogiGame(self.board, self.tray, self.validator)
Tout passe toujours.
Je modifie alors _move_piece()
avec un appel à is_movement_valid()
.
def _move_piece(self, source, destination, piece_change_function):
two_pieces = self._get_piece_couple(source, destination)
if not self._can_move_piece_piece(two_pieces):
return
source_piece = two_pieces[0]
if not self._validator.is_movement_valid(source_piece, source, destination):
return
self._teleport_piece(source, (destination, piece_change_function(source_piece)))
self._check_lions_position_after_move_piece()
test_calls_a_movement_validator_when_moving_a_piece
échoue car la fonction de validation est appelée deux fois. Normal, je l'enlève du test, et ça passe.
Je fais de même avec la capture :
def _piece_capture_piece(self, source, destination, piece_change_function):
two_pieces = self._get_piece_couple(source, destination)
if not self._piece_can_capture_piece(two_pieces):
return
self.board.remove_piece(destination)
source_piece, destination_piece = two_pieces
if not self._validator.is_movement_valid(source_piece, source, destination):
return
self._teleport_piece(source, (destination, piece_change_function(source_piece)))
if destination_piece.is_a_lion():
self.win_condition_happened = True
self.tray.push_piece(destination_piece.get_piece_controlled_by_opponent())
Histoire d'être certain de la valeur de retour du mon Mock Object de validation, j'ajoute self.validator.is_movement_valid.return_value = True
au setUp()
de mes tests. Ça passe toujours.
Bug !
Avez-vous repéré le bug que j'ai introduit ? À vrai dire, comme il n'est pas testé, il n'apparaît pas, mais il apparaîtra un jour où l'autre.
Voici le test qui le révèle :
def test_denies_capture_if_validator_invalid_movement(self):
piece = PIECE_PAWN_P1
from_location = (1, 1)
to_location = (1, 2)
self.validator.is_movement_valid.return_value = False
self.game.drop(piece, from_location)
self.game.drop(PIECE_PAWN_P2, to_location)
self.game.capture(from_location, to_location)
self.assertEqual(piece, self.game.get_piece_at(from_location), msg="Source piece was removed")
self.assertEqual(PIECE_PAWN_P2, self.game.get_piece_at(to_location), msg="Destination piece was removed")
Si la validation de mouvement renvoie que le mouvement est invalide, alors le mouvement entier doit être ignoré. Cela se passe bien dans le cas de move()
(vous pouvez écrire le test si vous le voulez) mais pas dans le cas d'une capture()
. Je garde donc le test qui échoue et je corrige le bug en déplaçant self.board.remove_piece(destination)
après le test de validation.
Duplication
Tout cela amène de la duplication dans _move_piece()
et _piece_capture_piece()
au niveau de la validation du mouvement. Cependant, pour le moment, enlever la duplication amène à un code plus complexe ou moins performant.
Alors je passe. Je verrai plus tard.
Dans le prochain épisode, je pourrai implémenter les contraintes de mouvements réelles en testant MovementValidator
.