Le kata du « Bowling Game »

Une technique d'entraînement au développement

Salut !

Aujourd’hui je voudrais vous montrer une technique d’entraînement au développement que j’ai récemment adoptée. Il s’agit du code kata.

Cet exercice qui prend moins de 30 minutes par jour peut vous permettre de progresser dans de nombreux aspects du développement. On peut utiliser les katas pour :

  • S’entraîner au Test-Driven Development (TDD) et au Simple Design,
  • Apprendre à utiliser efficacement ses outils de travail,
  • Mieux expliquer aux autres la façon dont on raisonne, par exemple en parlant à une peluche sur notre bureau,
  • Apprendre à connaître quelqu’un au travers de sa façon de penser, et donc à mieux coopérer avec cette personne, en réalisant un kata "en multi-joueur", c’est-à-dire en une courte séance pair programming.

Son principe est de prendre un problème, peu importe si dont on en connait déjà la solution, et s’entraîner à le résoudre en réalisant chaque geste délibérément de bout en bout. En essayant d’optimiser toutes les micro-décisions et les gestes que nous réalisons pour converger vers cette solution. Pour ce faire, je vais vous montrer un kata très populaire de Robert C. Martin, qui consiste à calculer le score d’une partie de bowling américain.

Je vais vous montrer comment je réalise ce kata en Go en vous détaillant toutes les questions que je me pose au fur et à mesure, mais on s’en fiche du langage et des outils: vous n’avez pas du tout besoin de connaître Go pour suivre, juste de savoir déjà programmer dans un langage quelconque. Vous pouvez réaliser cet exercice dans n’importe quelles conditions, de préférence les conditions dans lesquelles vous désirez vous améliorer, en Javascript sous iOS, en C# sous Windows, en Rust sous Linux…

En l’occurrence, ce kata est aussi pour moi l’occasion de vous montrer comment on raisonne en TDD et pourquoi c’est avantageux.

Énoncé du problème

L’objectif de cet exercice est d’implémenter un composant qui calcule le score d’une partie de bowling américain.

Une partie se déroule en 10 tours. À chaque tour, le joueur dispose de deux essais pour faire tomber 10 quilles disposées en triangle. Le score de base est le nombre de quilles que le joueur a réussi à faire tomber pendant le tour.

Lorsque le joueur arrive à faire tomber les dix quilles en deux lancers, cela s’appelle un spare. Le score d’un spare est de 10, auquel on ajoute le nombre de quilles tombées au lancer suivant.

Lorsque le joueur arrive à faire tomber les dix quilles en un seul lancer, cela s’appelle un strike. Le score d’un strike est de 10, auquel on ajoute le total de quilles tombées aux deux lancers suivants.

Lors du dernier tour :

  • si le joueur réalise un spare, il dispose d’un lancer bonus pour permettre de calculer son score ;
  • si le joueur réalise un strike, il dispose de deux lancers bonus pour permettre de calculer son score.

Cela signifie qu’une partie de bowling se termine en 21 lancers maximum (10 spare d’affilée).

Le composant que l’on cherche à implémenter n’est pas responsable de valider le nombre de coups joués dans une partie ni que les coups sont "légaux". On part de l’hypothèse que cette validation est réalisée avant d’interagir avec ce composant, et donc qu’il sera systématiquement appelé avec une entrée bien formée.

Le véritable but de cet exercice est de minimiser le nombre d’efforts à concéder et de questions que nous avons besoin de nous poser pour parvenir au résultat le plus simple possible qui satisfasse cet énoncé.

C’est pour cette raison que l’on se moque de connaître la solution et qu’il est bon de répéter ce kata quotidiennement (mais pas plus d’une fois par jour) : le plus important est d’être capable d’y parvenir en réalisant délibérément chaque geste, de manière à mémoriser les questions que l’on se pose plutôt que les réponses que nous leur apportons.

Écrire le premier test

Le principe du TDD est de boucler rapidement sur les trois étapes suivantes :

  1. RED : On écrit le minimum de tests possible pour que les tests échouent,
  2. GREEN : On ajoute le code le plus simple possible pour faire passer tous les tests,
  3. REFACTOR : C’est ici que l’on prend soin du code et que l’on peut factoriser ou renommer les choses, les tests garantissent que nous ne casserons pas le travail que nous avons déjà fait.

Mais la toute première chose à faire est de créer un environnement (ou "projet") pour travailler.

Dans mon cas, je vais réaliser ce kata en Go, et je vais donc taper les lignes suivantes :

$ rm -rf ./bowling && mkdir bowling && cd bowling
$ go mod init bowling

Puis je vais ouvrir mon éditeur (vim) dans lequel je vais créer un fichier dans lequel je vais écrire mon premier test.

Le rôle des tous premiers tests est hyper important. Ils servent à réfléchir à l’interface publique du code que nous allons écrire.

Le premier élément d’interface auquel nous ayons à faire est le constructeur. C’est pourquoi j’ai écrit le test suivant dans un fichier bowling_test.go.

package bowling

import "testing"

func TestNewGame(t *testing.T) {
    game := NewGame()
    if game == nil {
        t.Fatal("NewGame() returned nil")
    }
}

Évidemment, ce test ne compile pas puisqu’il fait appel à la fonction NewGame() qui n’est pas définie. Nous pouvons d’ailleurs le vérifier dans la console (dans la réalité j’utilise la commande :GoTest dans Vim) :

$ go test
# bowling [bowling.test]
./bowling_test.go:6:10: undefined: NewGame
FAIL    bowling [build failed]

Nous sommes donc à l’état RED. Notre mission est d’écrire juste ce qu’il faut de code pour que ce test passe au vert. Je vais écrire ce code dans le fichier bowling.go que voici :

package bowling

type Game struct{}

func NewGame() *Game {
    return new(Game)
}

Je relance les tests. Ils passent avec succès : nous sommes GREEN. C’est l’heure de "refactoriser", sauf qu’il n’y a rien à refactoriser pour l’instant, donc notre but est d’écrire un nouveau test qui échoue.

Dans notre cas, nous voulons calculer un score de bowling. Nous savons qu’une partie de bowling se compose d’un nombre variable de lancer et nous savons que nous ne calculerons le score que de parties finies. Nous savons aussi que nous ne passerons aucune donnée invalide à notre composant.

La partie la plus simple possible au bowling est une partie nulle (on envoie la boule dans la gouttière à chaque coup). C’est pourquoi j’ai rajouté le test suivant dans bowling_test.go pour la simuler.

package bowling

import "testing"

func TestNewGame(t *testing.T) {
    game := NewGame()
    if game == nil {
        t.Fatal("NewGame() returned nil")
    }
}

func TestGutterGame(t *testing.T) {
    game := NewGame()
    if game == nil {
        t.Fatal("NewGame() returned nil")
    }
    for i := 0; i < 20; i++ {
        game.Roll(0)
    }
    score := game.Score()
    if score != 0 {
        t.Fatalf("game.Score() should be 0, got %d.", score)
    }
}

Évidemment, ce test ne compile pas, car il utilise 2 méthodes qui ne sont pas définies :

  • game.Roll(int) qui sert à enregistrer un lancer,
  • game.Score() int qui sert à récupérer le score final.

Bien qu’il ne compile pas, ce test m’a poussé à réfléchir aux fonctions que je vais écrire. C’est l’API la plus simple possible.

Nous voici donc à nouveau à l’état RED. Il suffit de définir ces méthodes pour que le test passe :

package bowling

type Game struct{}

func NewGame() *Game {
    return new(Game)
}

func (g *Game) Roll(pins int) {}

func (g *Game) Score() int {
    return 0
}

Je relance les tests. Ils passent : nous sommes GREEN.

Regardons nos tests : le test TestNewGame est entièrement contenu dans le test TestGutterGame. Autrement dit, si ce dernier échoue, TestNewGame ne nous donnera aucune information supplémentaire. Ce test est donc redondant : il faut le supprimer. Ce que l’on peut faire, par contre, c’est factoriser ce code dans une fonction newGame afin de ne pas avoir à récrire cette vérification à chaque test que nous écrirons :

func TestGutterGame(t *testing.T) {
    game := newGame(t)
    for i := 0; i < 20; i++ {
        game.Roll(0)
    }
    score := game.Score()
    if score != 0 {
        t.Fatalf("game.Score() should be 0, got %d.", score)
    }
}

func newGame(t *testing.T) *Game {
    game := NewGame()
    if game == nil {
        t.Fatal("NewGame() returned nil")
    }
    return game
}

Du reste, il n’y a pas de code à bouger, mais il est idiomatique en Go de documenter toutes les fonctions et types publics (dont le nom commence par une majuscule) que nous créons, en écrivant pour cela des commentaires spéciaux, sans quoi golint nous fera les gros yeux. Faisons ça dès maintenant pour ne pas avoir à y repenser plus tard.

package bowling

// A Game models a single bowling game and allows to compute its end score.
type Game struct{}

// NewGame creates a new Game.
func NewGame() *Game {
    return new(Game)
}

// Roll records the number of pins that went down during a single roll.
func (g *Game) Roll(pins int) {}

// Score computes the score of the game.
func (g *Game) Score() int {
    return 0
}

Et voilà, le projet est démarré.

Calculer le score d'une partie "normale"

Il est temps d’écrire un nouveau test.

Jusqu’à présent, nous avons réalisé une partie où on ne tombait aucune quille à chaque coup. Essayons maintenant une partie où nous tombons une seule quille à chaque fois. Cette partie devrait être composée de 20 lancers, et son score final devrait être 20.

Pour ce faire, j’ai copié-collé notre premier test puis je l’ai modifié pour donner ceci :

package bowling

import "testing"

func TestGutterGame(t *testing.T) {
    game := newGame(t)
    for i := 0; i < 20; i++ {
        game.Roll(0)
    }
    score := game.Score()
    if score != 0 {
        t.Fatalf("game.Score() should be 0, got %d.", score)
    }
}

// XXX copy-pasta
func TestAllOnes(t *testing.T) {
    game := newGame(t)
    for i := 0; i < 20; i++ {
        game.Roll(1)
    }
    score := game.Score()
    if score != 20 {
        t.Fatalf("game.Score() should be 20, got %d.", score)
    }
}

Vous connaissez l’adage : « si tu copies-colles quelque chose, c’est sûrement que tu as quelque chose à refactoriser ».

Sauf que l’on n’en est pas à l’étape où on refactorise les choses. C’est pour cette raison que j’ai laissé ce commentaire // XXX copy-pasta. Mon éditeur va faire ressortir ce XXX avec un fond coloré pour attirer mon attention et ne pas l’oublier quand il sera temps de refactoriser.

En attendant, les tests échouent, évidemment :

bowling_test.go|30| game.Score() should be 20, got 0.

Il va donc falloir faire passer ce test. La solution la plus simple est d’accumuler le score dans un attribut de la structure Game :

package bowling

// A Game models a single bowling game and allows to compute its end score.
type Game struct {
    score int
}

// NewGame creates a new Game.
func NewGame() *Game {
    return new(Game)
}

// Roll records the number of pins that went down during a single roll.
func (g *Game) Roll(pins int) {
    g.score += pins
}

// Score computes the score of the game.
func (g *Game) Score() int {
    return g.score
}

Les tests passent, nous sommes GREEN. C’est maintenant que l’on refactorise nos tests.

Le but d’une refactorisation des tests n’est pas de rendre le code le plus court possible, mais de rendre chaque test le plus lisible possible.

Pour cette raison, factorisons les trois étapes de chaque test :

  • initialisation (newGame(t) fait déjà le travail),
  • exercice du code (lancer game.Roll() dans une boucle),
  • vérification (comparaison de game.Score() avec la valeur attendue).

Voici ce que cela donne chez moi :

package bowling

import "testing"

func TestGutterGame(t *testing.T) {
    game := newGame(t)
    rollMany(game, 0, 20)
    checkScore(t, game, 0)
}

func TestAllOnes(t *testing.T) {
    game := newGame(t)
    rollMany(game, 1, 20)
    checkScore(t, game, 20)
}

func newGame(t *testing.T) *Game {
    game := NewGame()
    if game == nil {
        t.Fatal("NewGame() returned nil")
    }
    return game
}

func rollMany(game *Game, pins, count int) {
    for i := 0; i < count; i++ {
        game.Roll(pins)
    }
}

func checkScore(t *testing.T, game *Game, expected int) {
    score := game.Score()
    if score != expected {
        t.Fatalf("Score should be %d, got %d.", expected, score)
    }
}

Gérer les "spares"

Ça y est, vous êtes bien échauffés ? :)

Il faut maintenant que notre code soit capable de compter des spares. Pour cela, écrivons un test qui réalise un spare (6, puis 4 quilles) et un lancer non-nul (3 quilles), puis une partie nulle le reste du temps. Le score du spare devra être (6 + 4) + 3 = 13, et le core total 13 + 3 = 16.

func TestOneSpare(t *testing.T) {
    game := newGame(t)
    game.Roll(6)
    game.Roll(4)
    game.Roll(3)
    rollMany(game, 0, 17)
    checkScore(t, game, 16)
}

Et bien sûr ce test échoue avec le message : Score should be 16, got 13.

C’est maintenant que nous allons devoir nous gratter la tête. Commençons par jeter un oeil un peu plus critique à nos méthodes :

// Roll records the number of pins that went down during a single roll.
func (g *Game) Roll(pins int) {
    g.score += pins
}

// Score computes the score of the game.
func (g *Game) Score() int {
    return g.score
}

Nous avons :

  • une méthode dont le nom et la documentation indiquent qu’elle enregistrent les coups, alors qu’elle calcule le score,
  • une méthode dont le nom et la documentation indiquent qu’elle calcule le score, alors qu’elle ne fait rien si ce n’est retourner le score déjà calculé.

Ceci est indicatif d’un problème de design. Nous allons donc avoir besoin de faire des changements relativement profonds dans notre code, alors commençons par commenter le test TestOneSpare, afin de retourner à l’état GREEN précédent, et pouvoir changer de design tout en nous assurant que notre code fonctionne au moins aussi bien qu’avant.

Au lieu de ne garder que le score final en mémoire, nous devrions garder chaque coup, puis calculer le score final dans la méthode. En Go, on peut faire cela avec une slice d’entiers, dont la capacité maximale, 21 éléments, est connue dès le départ.

En modifiant mon code ainsi, le code passe à nouveau tous les tests (sauf TestOneSpare, oui) :

package bowling

// A Game models a single bowling game and allows to compute its end score.
type Game struct {
    rolls []int
}

// NewGame creates a new Game.
func NewGame() *Game {
    return &Game{make([]int, 0, 21)}
}

// Roll records the number of pins that went down during a single roll.
func (g *Game) Roll(pins int) {
    g.rolls = append(g.rolls, pins)
}

// Score computes the score of the game.
func (g *Game) Score() (score int) {
    for _, roll := range g.rolls {
        score += roll
    }
    return
}

Je peux maintenant décommenter TestOneSpare et revenir à l’état RED.

Pour gérer les spares, maintenant, nous avons besoin de compter le score de la même façon que nous comptons manuellement les points au bowling, en considérant la partie comme 10 tours ayant chacun un nombre variable de lancers.

Voici comment je passe GREEN :

// Score computes the score of the game.
func (g *Game) Score() (score int) {
    var i int
    for turn := 0; turn < 10; turn++ {
        if g.rolls[i]+g.rolls[i+1] == 10 { // spare
            score += g.rolls[i] + g.rolls[i+1] + g.rolls[i+2]
        } else { // regular
            score += g.rolls[i] + g.rolls[i+1]
        }
        i += 2
    }
    return
}

Autrement dit, à chaque tour, je regarde si le tour est un spare ou non. S’il l’est, il compte comme la somme des deux lancers du tour courant et du prochain lancer, sinon, il compte normalement comme la somme des quilles tombées en deux coups.

Admettons que ce code n’est pas très lisible. Ça tombe bien, parce que c’est le moment de le refactoriser. D’abord, cette variable i n’est pas super bien nommée parce qu’on ne sait pas vraiment à quoi elle fait référence. Elle est utilisée pour être un indice dans le tableau de lancers, je me propose donc de la renommer rollIndex.

Ensuite, ces calculs polluent un peu la compréhension du code. Plutôt que d’expliquer que nous comptons des spares ou des coups réguliers dans des commentaires, faisons du code qui se documente tout seul en extrayant ces calculs dans des méthodes dont le nom est explicite. Voici ce que ça donne.

// Score computes the score of the game.
func (g *Game) Score() (score int) {
    var rollIndex int
    for turn := 0; turn < 10; turn++ {
        if g.isSpare(rollIndex) {
            score += g.countSpare(rollIndex)
        } else {
            score += g.countRegular(rollIndex)
        }
        rollIndex += 2
    }
    return
}

func (g *Game) isSpare(rollIndex int) bool {
    return g.rolls[rollIndex]+g.rolls[rollIndex+1] == 10
}

func (g *Game) countSpare(rollIndex int) int {
    return g.rolls[rollIndex] + g.rolls[rollIndex+1] + g.rolls[rollIndex+2]
}

func (g *Game) countRegular(rollIndex int) int {
    return g.rolls[rollIndex] + g.rolls[rollIndex+1]
}

C’est tout de même plus facile à suivre, vous ne trouvez pas ?

Essayons un autre test avec les spares, maintenant, en faisant tomber 5 quilles à chaque lancer. Chaque tour va être un spare, et le dernier tour sera composé de 3 lancers. Le score final devrait être de 10 * (10 + 5), soit 150.

func TestAllFives(t *testing.T) {
    game := newGame(t)
    rollMany(game, 5, 21)
    checkScore(t, game, 150)
}

Et… ce test passe tout seul, donc on en a fini avec les spares ! :)

Gérer les "strikes"

Nous entamons la dernière partie du kata. Il faut maintenant que nous soyons capables de prendre en compte les strikes. Et pour cela nous allons… ?

Écrire un nouveau test, bien sûr ! :D

Nous allons réaliser un strike, puis tomber successivement 4 et 3 quilles au prochain tour, puis complètement rater les 8 tours (16 lancers) suivants. Le score du strike devrait être 17, le score total de la partie devrait donner 24.

func TestOneStrike(t *testing.T) {
    game := newGame(t)
    game.Roll(10)
    game.Roll(4)
    game.Roll(3)
    rollMany(game, 0, 16)
    checkScore(t, game, 24)
}

Et ce test… échoue avec un débordement d’indice : panic: runtime error: index out of range [19] with length 19.

Ce n’est pas une grande surprise : un strike est un tour qui se joue en un lancer. Notre code ne sait pas gérer ce genre de tours. Apprenons-lui :

// Score computes the score of the game.
func (g *Game) Score() (score int) {
    var rollIndex int
    for turn := 0; turn < 10; turn++ {
        if g.rolls[rollIndex] == 10 {
            score += g.rolls[rollIndex] + g.rolls[rollIndex+1] + g.rolls[rollIndex+2]
            rollIndex++
        } else if g.isSpare(rollIndex) {
            score += g.countSpare(rollIndex)
            rollIndex += 2
        } else {
            score += g.countRegular(rollIndex)
            rollIndex += 2
        }
    }
    return
}

Nous sommes GREEN à nouveau. Procédons à la refacto :

  • Comme pour les spares, isolons les calculs dans des méthodes aux noms explicites,
  • En Go spécifiquement, cette suite de if {} else if {} else peut se récrire avec un switch qui sera plus lisible.
// Score computes the score of the game.
func (g *Game) Score() (score int) {
    var rollIndex int
    for turn := 0; turn < 10; turn++ {
        switch {
        case g.isStrike(rollIndex):
            score += g.countStrike(rollIndex)
            rollIndex++
        case g.isSpare(rollIndex):
            score += g.countSpare(rollIndex)
            rollIndex += 2
        default:
            score += g.countRegular(rollIndex)
            rollIndex += 2
        }
    }
    return
}

func (g *Game) isStrike(rollIndex int) bool {
    return g.rolls[rollIndex] == 10
}

func (g *Game) countStrike(rollIndex int) int {
    return g.rolls[rollIndex] + g.rolls[rollIndex+1] + g.rolls[rollIndex+2]
}

Voilà, un beau code bien propre. Nous pouvons maintenant écrire un dernier test, qui compte ce qui se passe lorsque l’on réalise une partie parfaite, c’est-à-dire, 10 strikes de suite, puis deux lancers "bonus" parfaits. Le score final d’une partie parfaite est 10 * (10 + 10 + 10) = 300.

func TestPerfectGame(t *testing.T) {
    game := newGame(t)
    rollMany(game, 10, 12)
    checkScore(t, game, 300)
}

Et… ce test passe avec succès, donc nous n’avons plus rien à faire. Mission accomplie.


Et voilà comment on aboutit à un code, simple, lisible et couvert de tests à 100%.

Ce que je vous recommande grandement de faire, c’est d’apprendre à réaliser ce kata dans votre langage de prédilection. Lorsque vous répéterez ce kata, ne vous contentez pas de faire du "par coeur" : réfléchissez à chaque étape, essayez peut-être de prendre une autre décision et de dérouler le reste du kata pour voir où cela vous mène.

Il est recommandé de travailler régulièrement sur un même kata avant d’en apprendre et d’en pratiquer un autre, car ce n’est pas la réalisation de cet exercice qui nous sert à progresser, mais toutes les questions que l’on se pose entre deux exécutions du même kata.

30 commentaires

Comment peut-on croire que ça marche en conditions cadrées si ça ne marche pas en conditions réelles ?

Tu es sûr de ne pas avoir fait d’erreur ici ? Parce que, de mon point de vue, la question « Comment peut-on croire que ça marche en conditions réelles si ça ne marche pas en conditions cadrées ? » a un sens, la tienne, moins. Parce que des choses qui dans marchent en exercice mais échouent lamentablement en vrai… ben, c’est la norme, non ?

Pour reprendre la natation, je sais nager en condition cadrée (piscine), moins bien en condition réelle (aller sauver quelqu’un qui se noie, nager dans une rivière avec un courant…). Pour que le TDD marche, il faut que le code soit testable, etc. On pose un cadre pour faciliter l’opération, donc sans la facilité, ça peut plus facilement échouer.

Dans tout tes exemples, tu procèdes à l’envers du kata (dans la manière dont je le comprends) : tu identifies un problème d’abord, puis tu ponds un exercice à même de le mettre en exacerbe ou l’isoler pour le travailler spécifiquement. Le kata n’est pas spécifique, il ne répond pas un problème préalablement identifié. Tes profs identifiaient un problème précis, chez toi en particulier, et cadrait l’exercice. Là, on te poses un problème, et au mieux tu dois toi-même chercher les points de blocage. Une bonne analogie, ce serait plutôt de proposer à un collègue de résoudre tel exercice / kata, en utilisant le TDD, les classes abstraites, ou que sais-je sur quoi il bloque, pour lui faire mieux comprendre le concept.

Ensuite, il y a autre chose dans de ce que je dis : un exercice lent n’a de sens que si on peut derrière le mettre en pratique, ce qui signifie typiquement un exercice intermédiaire pour faire le pont. En tout cas, chez moi, que ce soit pour les choses intellectuelles ou physiques.

Pour ma réaction, ce n’est aucun des trois :

  1. Faire des erreurs est normal, et fait partie du processus de résolution des problèmes.

Ce n’est pas « pas de bol », puisque ce sera pareil a prochaine fois, mais ce sera un autre problème. J’y repense si je rencontre le même problème (en allant fouiller ma mémoire ou des notes — le problème a intérêt de ne pas être vieux), ou je me corrige vraiment s’il apparait 2–3 fois. Mais par défaut, c’est normal, et ça va dans un coin de ma tête, pas plus.

Et si tu avais pris une mauvaise décision juste parce que tu ne t’étais pas posé une question importante beaucoup plus tôt ? Et si tu pouvais repasser ce moment de la résolution de ton ticket au ralenti, que tu changeais une décision, et que tu essayais de dérouler la suite de ton raisonnement pour voir ce que ça aurait fait ?

Ça ne marche pas, je connais la solution. Vraiment, je veux dire, je pars de A, passe par B, ne sais pas vraiment où je vais (à C, en l’occurrence). C’est le cas réel. Je pars de A, je décide de passer par D, mais je sais que vais à C. C’est l’exercice. Comment veux-tu comparer les deux situations ? Je sais où je vais !

Ça me rappelle quand j’avais interrogé une prof de langue sur comment m’améliorer sur un point de grammaire que je ne comprenais pas bien. Sa réponse : refais les exercices vus en cours. La mienne : je me souviens de la réponse, donc je peux remplir l’exercice de façon juste sans rien comprendre. Ça ne m’aide pas.

On ne pense vraiment pas pareil, je crois. C’est plutôt amusant, de mon point de vue. ^^ Ça me rappelle des discussions que j’ai eu avec des perfectionnistes. Pour moi, l’humain est doué pour prendre rapidement des décisions (la plupart du temps) suffisamment juste. Prendre des bonnes décisions, rationnelles, pesées, etc, c’est long, et l’humain est prodigieusement inefficace dans la chose.

+0 -0

Je pars de A, je décide de passer par D, mais je sais que vais à C.

Tu as tout dit. Le but est justement de partir du principe qu'on ne sait pas si on va à C en passant par D. C’est d’ailleurs pour ça qu’il faut tout décomposer délibérément (ce qui demande de ralentir), donc être capable de faire abstraction de la solution précédente.

Ça ne marche pas forcément pour tout le monde.

+0 -0

AHMA @nohar tu peux coller ce dernier message en intro du billet, pour moi c’est tout ce qu’il lui manquait pour être tout à fait clair et partageable massivement (à des devs).

SpaceFox

cc @Spacefox :

Plutôt que de modifier ce billet, j’ai tenu compte de ta remarque dans celui que j’ai publié aujourd’hui : https://zestedesavoir.com/billets/3970/args-un-kata-dense-et-realiste/

Ça m’aura permis de montrer les choses avec un exemple réel tiré de ma propre expérience.

J’ai aussi tenu compte des remarques de @Renault pour le coup, en expliquant ce que j’ai appris avec le kata en question.

+2 -0

Merci pour ce petit tuto, je me suis mis récemment au TDD, mais sans cette notion de kata. J’ai repris ton exemple, et dès ma première itération j’ai senti que le résultat ne convenait pas tel quel, notamment le fait qu’avoir une seule classe n’était pas suffisant pour respecter le principe de responsabilité unique (https://fr.wikipedia.org/wiki/Principe_de_responsabilit%C3%A9_unique), en effet la classe Game gère la gestion des tours (le jeu lui même donc) mais aussi le calcul de scores. J’ai donc voulu créer une 2ème classe appelée Turn, qui comprend les 2 lancers d’un seul tour (avec un potentiellement nul dans le cas d’un strike) et a un lien vers le lancer suivant, qui est capable de calculer en autonomie son propre score La classe Game s’occupe juste de remplir les différents Turn et de renvoyer le score final qui est la somme des 10 scores. Bref, l’important n’est pas tant ce que j’ai codé que le problème au niveau TDD que ça soulève.

J’ai créé une seconde classe (lors du refactoring bien entendu, l’interface de ma classe Game ne change pas) et je ne sais pas comment tester mon programme. Dois-je seulement écrire du test pour la classe Game ou dois-je aussi en créer pour la classe Turn, sachant que cette dernière n’est pas demandée par l’énoncé ? J’ai l’impression que vu que je l’ai fait lors du refactoring je n’ai pas besoin de tester cette classe, mais ça me fait bizarre. Autant ne pas tester unitairement toutes les méthodes d’une classe, je l’ai intégré, mais ne rien tester d’une classe, même si on peut la considérer "privée" ne me satisfait pas. Mais on passe les tests donc…

+0 -0

La question à se poser, ce serait plutôt : est-ce que ce test (et tous ses cas) couvre la classe Game ET classe Turn à 100% ? Si oui, alors il est inutile d’écrire plusieurs tests. Sinon, c’est qu’il manque un cas.

Ce que tu testes (ou ce que tu programmes) ce n’est pas "une classe", ni deux ou trois : c’est le comportement d’une unité de code. Cette unité peut être éclatée sur une ou plusieurs fonctions ou une ou plusieurs classes, mais au final, le principe du test est vraiment de vérifier qu’à une entrée donnée, ton code retourne la sortie voulue.

Et pour ça, il est important de se poser la bonne question : "quel est le comportement que je teste ?". J’en parle en détails dans le kata suivant.

Je ne suis pas d’accord avec toi sur l’application du SRP ici : il ne veut pas dire "une classe est responsable de faire une chose et une seule", mais plutôt, "chaque composant doit avoir une et une seule raison de changer". Dans notre cas, la classe Game ici ne gère absolument pas les tours ni la validation des entrées, on peut imaginer que si on avait à rajouter ça dans un vrai programme, on renommerait cette classe en GameScore et on l’utiliserait par composition dans une classe Game. Et bingo ! C’est le SRP et le OCP à l’oeuvre : on réutiliserait cette classe sans avoir à la modifier de l’intérieur.

Le but du TDD n’est pas d’atteindre un certain niveau de pureté sur les principes SOLID, mais plutôt un certain niveau de confiance quand on estime la tâche, qu’on envoie le code en production, ou qu’on le refactorise.

+0 -0

Merci pour ta réponse.

C’est vrai que dans ce cas il n’y aurait pas besoin de mieux tester la classe créée étant donné qu’elle n’est pas utilisée par ailleurs, c’est le principe du kata. A voir dans la vraie vie :)

Par rapport au SRP, je ne suis pas d’accord avec toi pour dire que ta classe Game ne gère pas les tours, ton code en fait explicitement l’usage:

for turn := 0; turn < 10; turn++

Imagine on change les règles du jeu : une partie est constituée de 15 tours au lieu de 10, tu devras aussi modifier cette classe de score. Avec ma solution tu peux faire autant de tours que tu veux, le calcul se fera de la même façon et je ne toucherai pas à cette classe. De même si on change la définition du comptage des points au sein d’un tour.

Mais je suis un peu d’accord que c’est hors-sujet par rapport au sujet du billet et qu’une fois qu’on a confiance dans nos tests on peut refactoriser comme bon nous semble. Et puis les règles du bowling ne changeront pas de sitôt donc on est sauvé ;)

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte