TDD In Space
Par Christian Sperandio
- 11 minutes de lecture - 2187 motsDécouverte d’un nouveau type de planète
Jusqu’à récemment nous connaissions 2 types de planètes:
- les planètes sphériques (plus ou moins) comme la Terre
- les planètes sous la forme d’un disque, souvent reposé sur 4 éléphants qui eux-mêmes reposent sur une tortue
Les scientifiques ont découverts les “Flat Square Planet”, des planètes de forme carrée. Afin d’en savoir plus, la NASA et l’ESA ont décidé de developper un nouveau rover pour étudier une planète de ce type.
Thomas Pesquet, qui est responsable du projet, vous a demandé de développer la partie commande du rover. Cette partie consiste à envoyer au rover des commandes pour ses déplacements sur la planète. Thomas a également demandé que tout le développement autour du rover soit réalisé en TDD.
Présentation du projet
Le rover est déployé sur la planète à une certaine position et orientation. Une opérateur envoie ensuite une série de commandes afin de déplacer le rover sur la planète. A tout moment, il est possible de demander au rover sa position.
Le rover gère les commandes suivantes:
- tourner vers la gauche
- tourner vers la droite
- avancer
- reculer
La position du rover est sous la forme de coordonnées (x,y) avec x sur l’axe Est/Ouest et y sur l’axe Nord/Sud.
L’orientation du rover définit dans quelle direction le rover avance et est définie pour les points cardinaux suivants: Nord, Est, Sud, Ouest.
Ci-dessous un schéma représentant le contexte:
Jetons nous à l’eau (plutôt dans l’espace)
Le développeur crée un nouveau projet et comme l’équipe suit les principes du TDD, commence à écrire son premier test. Avec le TDD, chaque test doit être le plus simple possible, le plus “unitaire”: on fait du baby step. Au lieu de tenter d’implémenter directement une fonctionnalité complexe, nous allons l’implémenter progressivement.
Dans notre cas, on part sur l’opération de faire avancer le rover.
func TestRoverGoForward(t *testing.T) {
// TODO: Ecrire le test
}
C’est un début 🙂
L’écriture d’un test suit la logique du : Given, When, Then
Given place le contexte du test. Dans notre cas, ce sera la création d’une entité rover à une position et dans une orientation.
When définit l’action qui va être réalisée: le rover va avancer d’une position.
Then est notre assertion : nous vérifions que le rover est bien à la position attendue.
Cela donne:
func TestRoverGoForward(t *testing.T) {
// Given
rover := NewRover(
Coordinate{2, 2},
West,
)
// When
rover.GoForward()
// Then
expected := Coordinate{1, 2}
actual := rover.Position
if expected != actual {
t.Errorf("Expected rover position %v, got %v", expected, actual)
}
}
Nous voyons que l’écriture de ce test nous oriente vers l’API de notre implémentation. Nous avons un besoin, et nous traduisons ce besoin sans préjuger du “comment”.
Appliquons la mécanique du TDD, nous avons écrit le test donc exécutons le ! Sans surprise, le test est en erreur car aucune fonction ni structure n’est définie.
Félicitations ! Vous avez un test “rouge”. Maintenant, il faut le faire passer au vert le plus rapidement et simplement possible.
Du rouge au vert
La première chose que nous pouvons faire est de définir nos objets valeurs que sont la position et la direction. Pour cela, cette implémentation est suffisante:
type Direction int
const (
West Direction = iota
)
type Coordinate struct {
X int
Y int
}
Il nous reste maintenant la partie “Rover”. Comme ci-dessus, écrivons le code plus simple:
type Rover struct {
Position Coordinate
Direction Direction
}
func NewRover(position Coordinate, direction Direction) *Rover {
return &Rover{
Position: position,
Direction: direction,
}
}
func (r *Rover) GoForward() {
}
Si nous relançons le test, la compilation se fait avec succès, mais le test est toujours en échec avec cette erreur:
Expected rover position {1 2}, got {2 2}
Cela est normal 🙂 Nous n’avons implémenté aucune logique dans la fonction GoFroward.
Allons-y, écrivons cette logique qui permet de faire passer le test au vert le plus rapidement:
func (r *Rover) GoForward() {
r.Position = Coordinate{1, 2}
}
Si nous relançons le test… il passe, devenant ainsi “vert”.
Ce genre de code laisse souvent les nouveaux venus au TDD perplexe, ils ne voient pas l’intérêt de faire cela. Le premier réflexe étant de coder directement la logique complète de la fonction GoForward.
On désire faire passer au vert le temps le plus rapidement et le plus simplement possible afin de valider notre conception. Par exemple, nous venons de définir qu’un Rover est défini avec une position et une orientation, que la méthode pour le faire avancer est sans argument et ne retourne rien, que la position encapsulée dans le rover est modifié. Et à première vue, cela tient la route.
Premier refacto
Après le passage au vert, nous rentrons dans la troisième phase du TDD: le refactoring. C’est cette phase qui doit prendre le plus temps pendant le développement du projet. Pour certains systèmes, on fait passer le test du rouge au vert en 5 minutes mais la phase de refactoring dure plusieurs heures.
C’est rarement le cas au début car nous avons peu de code écrit 🙂 Cela se produit plutôt lors d’un changement important dans le design suite à un besoin particulier ou l’impact de la dette technique.
On peut avoir de la dette technique avec du TDD?
Le TDD n’est pas une solution miracle. C’est un moyen pour faire le design le plus concis qui réponde aux besoins. Nous gagnons également en contrôle de la regression grâce aux tests écrits pendant le développement.
Mais rien n’empêche de faire un mauvais design ou d’avoir une implémentation de mauvaise qualité. La qualité du code dépend de nombreuses autres compétences des développeurs.
Sur le premier test, il y a souvent peu de choses à refactoriser. Nous pouvons renommer le test en TestRoverGoForwardToWest pour être plus précis. Concernant l’encapsulation ou la définition des responsabilités, nous pouvons valider.
Simple vs. Simpliste
J’aurais pu faire un premier code de moins bonne qualité en ne faisant pas directement l’énumération pour la direction et l’objet valeur de la position.
On aurait un Rover du genre:
type Rover struct {
X,Y int
Direction String
}
et dans le test, on aurait déclaré un rover comme suit:
rover := NewRover(2, 2, "WEST")
C’est à ce moment qu’il ne faut pas confondre “simple” avec “simpliste”. Ce n’est pas parce que nous faisons du TDD, que nous devons oublier nos connaissances en développement en se disant qu’on fera le “vrai code” lors du refacto.
Le cercle vertueux
Continuons le développement en écrivant un second test. Les règles sont les suivantes (et seront identiques pour tous les prochains tests):
- le nouveau test fait avancer dans l’implémentation du besoin
- il est tout aussi unitaire que le pécédent (baby step)
- il ne doit pas passer i.e. être rouge
Quel candidat serait parfait ? Pourquoi ne pas faire avancer le Rover quand il est orienté vers le Nord ? Nous allons ainsi mettre à l’épreuve notre implémentation de la fonction GoForward – peu de surprise sur le résultat 😉
func TestRoverGoForwardToNorth(t *testing.T) {
// Given
rover := NewRover(
Coordinate{2, 2},
North,
)
// When
rover.GoForward()
// Then
expected := Coordinate{2, 3}
actual := rover.Position
if expected != actual {
t.Errorf("Expected rover position %v, got %v", expected, actual)
}
}
Le code de test est assez explicite sur son intention. Par contre, il ne passe pas à cause de la nouvelle valeur North. Ajoutons la et relançons le test. Cette fois, on a une erreur sur son assertion:
Expected rover position {2 3}, got {1 2}
Avouons-le, nous ne sommes pas surpris 🙂
Test rouge implique de le faire passer au vert rapidement.
Action: modifier l’implémentation de la fonction GoForward
pour faire passer ce test, et ne pas impacter le premier.
func (r *Rover) GoForward() {
if r.Direction == West {
r.Position = Coordinate{1, 2}
} else if r.Direction == North {
r.Position = Coordinate{2, 3}
}
}
Avec ce code nos tests sont tous vert. Champagne !
Maintenant, nous entrons dans la phase de refacto et nous allons y aller par étape.
Vert tu es, Vert tu resteras
Une chose simple à mettre en oeuvre est de remplacer les constantes en réalisant le calcul:
func (r *Rover) GoForward() {
if r.Direction == West {
r.Position.X--
} else if r.Direction == North {
r.Position.Y++
}
}
Dès que le changement est réalisé, on relance les tests. Cela doit être un réflexe: tout changement implique l’exécution des tests pour valider que nous sommes toujours dans le vert.
Pouvons-nous en rester là ? La réponse est non 🙂 Nous voyons apparaitre un magnifique code smell (pensez OCP) avec cette combinaison de if… else if. Et c’est là que cela devient intéressant: le refacto ne se limite pas à renommer ou faire de l’extraction de méthode. Nous avons plusieurs possibilités et je ne vais pas prendre celle du design pattern stratégie (overkill dans ce contexte). Nous allons lier un direction avec un vecteur de déplacement.
type Direction Coordinate
var (
West = Direction{-1, 0}
North = Direction{0, 1}
)
Direction et vecteur de déplacement sont liés, nous pouvons maintenant l’intégrer dans la fonction:
func (r *Rover) GoForward() {
r.Position.X = r.Position.X + r.Direction.X
r.Position.Y = r.Position.Y + r.Direction.Y
}
Devinez ce que nous allons faire… On relance les tests pour valider que tout est toujours ok.
=== RUN TestRoverGoForwardToWest
--- PASS: TestRoverGoForwardToWest (0.00s)
=== RUN TestRoverGoForwardToNorth
--- PASS: TestRoverGoForwardToNorth (0.00s)
PASS
Nous avons supprimé la violation du Open Close Principle mais le code de GoForwardn’est pas encore “clean”. L’intention du code est d’additionner des coordonnées alors rendons ce point expressif:
func (r *Rover) GoForward() {
r.Position = r.Position.Add(r.Direction)
}
func (c Coordinate) Add(d Direction) Coordinate {
return Coordinate{
X: c.X + d.X,
Y: c.Y + d.Y,
}
}
Notre refacto est pas mal. Relançons les tests: toujours vert. Il est peut être temps d’écrire un troisième test, non ? Je vais répondre par la négative 🙂 Notre code de production est pas mal (ok, je me lance des fleurs) mais regardons celui des tests:
func TestRoverGoForwardToWest(t *testing.T) {
// Given
rover := NewRover(
Coordinate{2, 2},
West,
)
// When
rover.GoForward()
// Then
expected := Coordinate{1, 2}
actual := rover.Position
if expected != actual {
t.Errorf("Expected rover position %v, got %v", expected, actual)
}
}
func TestRoverGoForwardToNorth(t *testing.T) {
// Given
rover := NewRover(
Coordinate{2, 2},
North,
)
// When
rover.GoForward()
// Then
expected := Coordinate{2, 3}
actual := rover.Position
if expected != actual {
t.Errorf("Expected rover position %v, got %v", expected, actual)
}
}
La répétition est évidente, et en devinant où nous allons (tester les directions manquantes), cela va s’accentuer.
C’est un point qui est régulièrement négligé: le refacto doit aussi concerner le code des tests. Dans notre contexte, nous allons remplacer ces tests par un test “paramétré”. En Go, cela se traduit par l’utilisation d’une table de tests:
func TestRover_GoForward(t *testing.T) {
tests := []struct {
name string
initialPosition Coordinate
direction Direction
expectedPosition Coordinate
}{
{"Rover goes forward to west", Coordinate{2, 2}, West, Coordinate{1, 2}},
{"Rover goes forward to north", Coordinate{2, 2}, North, Coordinate{2, 3}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rover := NewRover(
test.initialPosition,
test.direction,
)
rover.GoForward()
actual := rover.Position
if test.expectedPosition != actual {
t.Errorf("Expected rover position %v, got %v", test.expectedPosition, actual)
}
})
}
}
Encore une fois, nous validons ce changement en exécutant les tests: il faut que cela deviennent un mantra 🙂
Je vous laisse les clés du Rover
Il reste encore pas mal de choses à faire avant que le rover puisse se poser sur une nouvelle planète:
- avancer dans les directions Sud et Est
- reculer
- tourner
- gérer les bords de la planète
- …
Je vais laisse continuer cette aventure 😀
J’espère vous avoir transmis l’envie de faire du TDD. Je sais que cela demande un changement d’état d’esprit et qu’une des difficultés concerne les baby steps. Pour y arriver, il n’y pas d’autre solution que d’essayer, se tromper, réessayer et réussir.
Je tiens à rappeler que la Grande A’Tuin ainsi que les Flat Square Planets sont une pure invention. Je remercie également Thomas Pesquet pour sa participation involontaire.
Je me suis inspiré du Kata Mars Rover que vous trouverez sur ce site .
Mais où est le dummy test ?
L’expérience a montré qu’il est utile, lors de la création d’un projet, de créer un test qui valide la configuration de l’environnement de développement.
Dans l’écosystème Java, il arrive souvent qu’on ait besoin de configurer l’outils de build (par exemple Maven ou Gradle) pour définir les repositories, le proxy, qu’on ait bien la dépendance avec jUnit, etc. Pour valider ce point dès le début, il est fréquent d’écrire un “dummy test” de cette forme (en Java):
Assertions.assertEquals(0, 1)
L’exécution de ce test doit échouer seulement à cause de l’assertion.
On écrit d’abord un test qui échoue pour rester dans la logique du TDD ;)
Il suffit ensuite de faire passer ce test en modifiant l’assertion, le relancer pour vérifier que tout est ok sur l’environnement et afin on supprime ce test: il n’a pas pour objectif de rester dans le projet.
J’avoue que je n’ai jamais ressenti le besoin d’écrire un Dummy test lors des développements en Go.