28-07-2019, 06:48 PM
Je vais implémenter l'envoi d'une unité pour exploiter un gisement de ressources.
J'écris un premier scénario de test.
J'ai déjà testé et implémenté le dispatch des commandes StartGame et DeployStartingUnit donc je ne recommence pas. J'utilise le même plateau de jeu dans la plupart de mes tests.
Pour commencer, je crée un scénario qui doit mener à une erreur. J'essaye ici d'affecter une unité à un gisement présent sur une zone trop éloignée du territoire où se situe l'unité.
A la première exécution des tests, ça explose de partout : la commande UnitStartsExploitingDeposit n'existe pas. Je crée la commande, avec les clés dont j'ai besoin.
Exécution suivante : l'aggregate game n'a aucune fonction de décision pour cette commande. Je la définis.
Nouvelle exécution des tests, le dispatch retourne :ok au lieu de {:error, :deposit_is_too_far}. Je corrige pour que ça marche.
Le test passe ! Je peux passer au scénario suivant. On se rend bien compte que je joue les idiots : mon prochain test va mettre en avant qu'on ne doit pas toujours retourner cette erreur.
Comme ce test va rester comme il est, je vais bien devoir contrôler véritablement si le gisement est trop loin. Le prochain test va me forcer à affiner l'implémentation de ma fonction de décision.
En pratique, on peut tout à fait commencer dès maintenant à écrire les conditions.
Mon cas d'erreur suivante : le gisement n'est carrément pas sur le plateau.
Bien sûr, l'exécution de ce test échoue puisque l'erreur retournée n'est pas la bonne. Je peux commencer à écrire une implémentation de bonne foi.
Comme on peut voir, je commence à extraire des choses (units et board) de l'état de l'aggregate, qui est transmis comme premier argument à la fonction de décision.
En écrivant ça, je me suis dit que ça vaudrait aussi le coup de vérifier que l'unité existe bien. Donc j'écris un scénario de test.
Comme on peut voir, les tests se ressemblent beaucoup, ce n'est donc pas bien fastidieux à écrire.
C'est mieux. Parfois on sent venir les cas, parfois on se les prend par surprise. A chacun de placer le curseur sur ce qu'il convient de faire entre avoir un code très défensif ou non.
Est-ce que la validité des arguments est contrôlée préalablement au dispatch des messages ? Ou bien est-ce la fonction de décision qui doit faire tout ce travail de contrôle ?
Dans mon cas, puisque la fonction de décision dispose de l'état de l'aggregate, il semble logique qu'elle contrôle tout ce qui est du ressort de l'aggregate.
Je peux donc maintenant passer aux cas de fonctionnement normal de cette commande.
Cette fois, ça ne fonctionne pas parce que notre condition n'a pas de "else".
Ensuite, ça ne marchera pas parce que l'event UnitStartedExploitingDeposit n'existe pas, je le crée :
Maintenant ça ne marche pas parce que l'aggregate ne dispose pas d'une fonction de mutation pour cet event.
Là je gère l'event mais je n'en fais rien. On note que l'aggregate a autant de fonctions de décision (execute) et de mutation (apply) qu'il y a de commandes et d'events à gérer.
Comme le langage Elixir est compilé, on n'a pas besoin de faire un "switch" à l'intérieur pour savoir à quel type de commande ou d'event on à affaire, ça se fait à l'entrée dans la fonction.
Ça s'appelle du "pattern matching", et ça ressemble un peu à la surcharge qu'on peut trouver en Java ou C++ : on appelle la bonne fonction selon la tronche des arguments qui rentrent.
Ici, notre fonction de mutation ne mute rien du tout : la fonction reçoit son ancien état ainsi que l'événement et doit retourner le nouvel état. Ici, je retourne directement l'ancien état, donc l'aggregate reste dans le même état.
Jusque là, j'ai bien testé le comportement de ma fonction de décision, sans trop me soucier de comment elle y arrivait. Généralement, on évite de trop s'intéresser à l'implémentation de la fonctionnalité testée.
Le but des tests, c'est de s'assurer que la fonctionnalité… fonctionne, même après avoir modifié son implémentation (que ce soit pour optimiser le code, le simplifier, chasser des bugs etc.). Le plus souvent, les tests déjà écrits ne bougent pas, ou peu.
Si on commence à tester l'implémentation plutôt que le comportement, on risque de casser des tests et donc de se donner du travail supplémentaire, en plus de risquer une fragilisation de la suite de tests : est-ce que les tests que j'ai dû modifier testent toujours bien les comportements ?
Me voici donc à un moment où il faut choisir : tester l'état de l'aggregate (la structure Seelies.Game) ou bien tester son comportement ?
Pour éviter de tester la structure directement, je vais construire un module dont le but sera de modifier cette structure, et ce module aura sa propre suite de tests. Ça implique que je vais devoir éviter de toucher à la structure "manuellement" (sans passer par ce module).
Ici, mon module devra avoir une fonction qui sert à signaler que telle unité exploite telle ressource. Il me faudra aussi des fonctions pour interroger l'état et savoir si une unité est déjà en train d'exploiter, pour arrêter l'exploitation etc.
Déjà, cet article vous aura permis de voir ma façon d'approcher TDD (ici dans une architecture avec event sourcing, nouvelle pour moi).
Pour résumer :
- je commence par écrire un test, il ne passe pas (ça s'écrit généralement en rouge dans le terminal dans lequel on exécute les tests).
- j'écris le minimum de code nécessaire pour que le test passe (ça s'écrit alors en vert).
- je modifie le code au besoin (optimiser, clarifier, etc.) et en toute confiance : le test doit rester vert.
- je recommence.
Dans le jargon TDD, on parle parfois de cycle "red, green, refactor" pour désigner ça.
Ce n'est pas parfait mais ça permet d'avoir confiance en son code. Ça sert également de documentation : si on laisse de côté le projet un moment, au retour il suffit de lancer et lire les tests pour savoir où on en était.
C'est intéressant de coupler ça un outil de source control comme Git : on commit quand les tests passent au vert et on peut trifouiller le cœur léger pour améliorer le code : si on casse tout, il suffit de revenir à la version précédente.
J'écris un premier scénario de test.
test "Can't exploit a deposit from a distant area" do
:ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
:ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
{:error, :deposit_is_too_far} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d5", time: 60 })
end
J'ai déjà testé et implémenté le dispatch des commandes StartGame et DeployStartingUnit donc je ne recommence pas. J'utilise le même plateau de jeu dans la plupart de mes tests.
Pour commencer, je crée un scénario qui doit mener à une erreur. J'essaye ici d'affecter une unité à un gisement présent sur une zone trop éloignée du territoire où se situe l'unité.
A la première exécution des tests, ça explose de partout : la commande UnitStartsExploitingDeposit n'existe pas. Je crée la commande, avec les clés dont j'ai besoin.
defmodule Seelies.UnitStartsExploitingDeposit do
defstruct [:game_id, :unit_id, :deposit_id, :time]
end
Exécution suivante : l'aggregate game n'a aucune fonction de décision pour cette commande. Je la définis.
defmodule Seelies.Game do
# ...
def execute(game, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
[]
end
# ...
end
Nouvelle exécution des tests, le dispatch retourne :ok au lieu de {:error, :deposit_is_too_far}. Je corrige pour que ça marche.
def execute(game, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
{:error, :deposit_is_too_far}
end
Le test passe ! Je peux passer au scénario suivant. On se rend bien compte que je joue les idiots : mon prochain test va mettre en avant qu'on ne doit pas toujours retourner cette erreur.
Comme ce test va rester comme il est, je vais bien devoir contrôler véritablement si le gisement est trop loin. Le prochain test va me forcer à affiner l'implémentation de ma fonction de décision.
En pratique, on peut tout à fait commencer dès maintenant à écrire les conditions.
Mon cas d'erreur suivante : le gisement n'est carrément pas sur le plateau.
test "Can't exploit a nonexistent deposit" do
:ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
:ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
{:error, :deposit_not_found} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d1000", time: 60 })
end
Bien sûr, l'exécution de ce test échoue puisque l'erreur retournée n'est pas la bonne. Je peux commencer à écrire une implémentation de bonne foi.
defmodule Seelies.Game do
# ...
def execute(%Seelies.Game{units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: _time }) do
cond do
not Seelies.Board.has_deposit?(board, deposit_id) ->
{:error, :deposit_not_found}
not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
{:error, :deposit_is_too_far}
end
end
# ...
end
defmodule Seelies.Board do
# ...
def has_deposit?(board, deposit_id) do
Enum.any?(board.areas, fn ({_area_id, area}) -> Map.has_key?(area.deposits, deposit_id) end)
end
def is_deposit_in_range?(board, deposit_id, territory_id) do
area_ids = board.territories[territory_id].area_ids
Enum.any?(area_ids, fn (area_id) ->
Map.has_key?(board.areas[area_id].deposits, deposit_id)
end)
end
end
Comme on peut voir, je commence à extraire des choses (units et board) de l'état de l'aggregate, qui est transmis comme premier argument à la fonction de décision.
En écrivant ça, je me suis dit que ça vaudrait aussi le coup de vérifier que l'unité existe bien. Donc j'écris un scénario de test.
test "Can't send a nonexistant unit to exploit a deposit" do
:ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
:ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
{:error, :unit_not_found} = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1000", deposit_id: "d1", time: 60 })
end
Comme on peut voir, les tests se ressemblent beaucoup, ce n'est donc pas bien fastidieux à écrire.
defmodule Seelies.Game do
# ...
def execute(%Seelies.Game{units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: _time }) do
cond do
units[unit_id] == nil ->
{:error, :unit_not_found}
not Seelies.Board.has_deposit?(board, deposit_id) ->
{:error, :deposit_not_found}
not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
{:error, :deposit_is_too_far}
end
end
# ...
end
C'est mieux. Parfois on sent venir les cas, parfois on se les prend par surprise. A chacun de placer le curseur sur ce qu'il convient de faire entre avoir un code très défensif ou non.
Est-ce que la validité des arguments est contrôlée préalablement au dispatch des messages ? Ou bien est-ce la fonction de décision qui doit faire tout ce travail de contrôle ?
Dans mon cas, puisque la fonction de décision dispose de l'état de l'aggregate, il semble logique qu'elle contrôle tout ce qui est du ressort de l'aggregate.
Je peux donc maintenant passer aux cas de fonctionnement normal de cette commande.
test "Unit starts exploiting the deposit" do
:ok = Seelies.Router.dispatch(%Seelies.StartGame{game_id: 42, board: board()})
:ok = Seelies.Router.dispatch(%Seelies.DeployStartingUnit{game_id: 42, unit_id: "u1", territory_id: "t1", unit_type: :ant})
:ok = Seelies.Router.dispatch(%Seelies.UnitStartsExploitingDeposit{game_id: 42, unit_id: "u1", deposit_id: "d1", time: 60 })
assert_receive_event(Seelies.UnitStartedExploitingDeposit, fn (event) ->
assert event.game_id == 42
assert event.unit_id == "u1"
assert event.deposit_id == "d1"
assert event.time == 60
end)
end
Cette fois, ça ne fonctionne pas parce que notre condition n'a pas de "else".
def execute(%Seelies.Game{game_id: game_id, units: units, board: board}, %Seelies.UnitStartsExploitingDeposit{unit_id: unit_id, deposit_id: deposit_id, time: time }) do
cond do
units[unit_id] == nil ->
{:error, :unit_not_found}
not Seelies.Board.has_deposit?(board, deposit_id) ->
{:error, :deposit_not_found}
not Seelies.Board.is_deposit_in_range?(board, deposit_id, units[unit_id].territory_id) ->
{:error, :deposit_is_too_far}
true ->
%Seelies.UnitStartedExploitingDeposit{game_id: game_id, unit_id: unit_id, deposit_id: deposit_id, time: time}
end
end
Ensuite, ça ne marchera pas parce que l'event UnitStartedExploitingDeposit n'existe pas, je le crée :
defmodule Seelies.UnitStartedExploitingDeposit do
@derive Jason.Encoder
defstruct [:game_id, :unit_id, :deposit_id, :time]
end
Maintenant ça ne marche pas parce que l'aggregate ne dispose pas d'une fonction de mutation pour cet event.
defmodule Seelies.Game do
# ...
def apply(game = %Seelies.Game{}, %Seelies.UnitStartedExploitingDeposit{game_id: game_id, unit_id: unit_id, deposit_id: deposit_id, time: time}) do
game
end
# ...
end
Là je gère l'event mais je n'en fais rien. On note que l'aggregate a autant de fonctions de décision (execute) et de mutation (apply) qu'il y a de commandes et d'events à gérer.
Comme le langage Elixir est compilé, on n'a pas besoin de faire un "switch" à l'intérieur pour savoir à quel type de commande ou d'event on à affaire, ça se fait à l'entrée dans la fonction.
Ça s'appelle du "pattern matching", et ça ressemble un peu à la surcharge qu'on peut trouver en Java ou C++ : on appelle la bonne fonction selon la tronche des arguments qui rentrent.
Ici, notre fonction de mutation ne mute rien du tout : la fonction reçoit son ancien état ainsi que l'événement et doit retourner le nouvel état. Ici, je retourne directement l'ancien état, donc l'aggregate reste dans le même état.
Jusque là, j'ai bien testé le comportement de ma fonction de décision, sans trop me soucier de comment elle y arrivait. Généralement, on évite de trop s'intéresser à l'implémentation de la fonctionnalité testée.
Le but des tests, c'est de s'assurer que la fonctionnalité… fonctionne, même après avoir modifié son implémentation (que ce soit pour optimiser le code, le simplifier, chasser des bugs etc.). Le plus souvent, les tests déjà écrits ne bougent pas, ou peu.
Si on commence à tester l'implémentation plutôt que le comportement, on risque de casser des tests et donc de se donner du travail supplémentaire, en plus de risquer une fragilisation de la suite de tests : est-ce que les tests que j'ai dû modifier testent toujours bien les comportements ?
Me voici donc à un moment où il faut choisir : tester l'état de l'aggregate (la structure Seelies.Game) ou bien tester son comportement ?
Pour éviter de tester la structure directement, je vais construire un module dont le but sera de modifier cette structure, et ce module aura sa propre suite de tests. Ça implique que je vais devoir éviter de toucher à la structure "manuellement" (sans passer par ce module).
Ici, mon module devra avoir une fonction qui sert à signaler que telle unité exploite telle ressource. Il me faudra aussi des fonctions pour interroger l'état et savoir si une unité est déjà en train d'exploiter, pour arrêter l'exploitation etc.
Déjà, cet article vous aura permis de voir ma façon d'approcher TDD (ici dans une architecture avec event sourcing, nouvelle pour moi).
Pour résumer :
- je commence par écrire un test, il ne passe pas (ça s'écrit généralement en rouge dans le terminal dans lequel on exécute les tests).
- j'écris le minimum de code nécessaire pour que le test passe (ça s'écrit alors en vert).
- je modifie le code au besoin (optimiser, clarifier, etc.) et en toute confiance : le test doit rester vert.
- je recommence.
Dans le jargon TDD, on parle parfois de cycle "red, green, refactor" pour désigner ça.
Ce n'est pas parfait mais ça permet d'avoir confiance en son code. Ça sert également de documentation : si on laisse de côté le projet un moment, au retour il suffit de lancer et lire les tests pour savoir où on en était.
C'est intéressant de coupler ça un outil de source control comme Git : on commit quand les tests passent au vert et on peut trifouiller le cœur léger pour améliorer le code : si on casse tout, il suffit de revenir à la version précédente.