Un test écrit après le code tend à vérifier ce que le code fait, pas ce qu’il était censé faire. Le développement piloté par les tests (TDD) corrige cela en inversant l’ordre : vous écrivez le test, vous le regardez échouer, puis vous écrivez le plus petit bout de code qui le fait réussir. Comme le test existe avant le code qu’il vérifie, il ne peut pas valider silencieusement tout ce que le code renvoie. Kent Beck a formalisé cette boucle sous le nom red, green, refactor dans son livre de 2002 Test-Driven Development: By Example. Le nom est resté.

Ce que fait vraiment le cycle red-green-refactor

Chaque passage du cycle comporte trois étapes, et en sauter une le transforme en autre chose.

Red. Écrivez un test pour un comportement qui n’existe pas encore, puis exécutez-le. Il doit échouer. S’il réussit du premier coup, soit le comportement existe déjà, soit le test ne vérifie rien de réel. Une exécution rouge prouve que le test peut détecter le bug qu’il est censé détecter.

Green. Écrivez la plus petite quantité de code de production qui fait réussir le test. Pas le meilleur code. Le code minimal. Renvoyer une valeur codée en dur est une étape green légitime, tant que vous savez que c’est ce que vous avez fait et que vous ne vous arrêtez pas là.

Refactor. Une fois le test vert, nettoyez ce que vous venez d’écrire : supprimez la duplication, ou extrayez une fonction si cela se justifie. Le test doit rester vert pendant toute l’opération, car c’est ainsi que vous savez que le comportement n’a pas changé, seulement la forme du code.

Choisissez ensuite le comportement suivant et recommencez. Une itération prend quelques minutes : une branche if, un cas limite, jamais une fonctionnalité entière.

Un exemple de TDD en Python : le kata FizzBuzz

FizzBuzz est assez petit pour que vous observiez le rythme plutôt que le problème. Pour un nombre donné, renvoyez "Fizz" s’il est divisible par 3, "Buzz" s’il est divisible par 5, "FizzBuzz" s’il est divisible par les deux, et le nombre lui-même sinon.

Commencez par le cas qui ne correspond à aucun des trois :

1
2
3
4
5
6
# test_fizzbuzz.py
from fizzbuzz import fizzbuzz


def test_returns_the_number_as_a_string():
    assert fizzbuzz(1) == "1"
1
2
3
4
$ pytest -q
ImportError while importing test module 'test_fizzbuzz.py'.
E   ModuleNotFoundError: No module named 'fizzbuzz'
1 error in 0.01s

Rouge, et vous n’avez même pas eu besoin de provoquer l’échec exprès : fizzbuzz n’existe pas encore, donc pytest ne peut même pas importer le module de test. Cela reste un rouge valable : le correctif est le même dans les deux cas. La plus petite chose qui le fait réussir :

1
2
3
# fizzbuzz.py
def fizzbuzz(n):
    return str(n)
1
2
3
$ pytest -q
.
1 passed in 0.01s

Green. Comportement suivant : les multiples de 3.

1
2
def test_returns_fizz_for_multiples_of_three():
    assert fizzbuzz(3) == "Fizz"

Cela échoue (assert '3' == 'Fizz'), et le correctif minimal tient en une branche :

1
2
3
4
def fizzbuzz(n):
    if n % 3 == 0:
        return "Fizz"
    return str(n)

Les multiples de 5 suivent la même logique, ajoutez donc le test et le elif correspondant. Écrivez ensuite le cas qui attrape l’étourderie des deux premières étapes : un nombre divisible par les deux.

1
2
def test_returns_fizzbuzz_for_multiples_of_fifteen():
    assert fizzbuzz(15) == "FizzBuzz"
1
2
3
4
$ pytest -q
...F
E   assert 'Fizz' == 'FizzBuzz'
3 passed, 1 failed in 0.01s

15 % 3 == 0 est vérifié en premier, donc la fonction renvoie "Fizz" avant même de regarder 15 % 5. L’ordre compte ici. Le correctif est une branche pour 15 qui s’exécute avant les deux autres :

1
2
3
4
5
6
7
8
def fizzbuzz(n):
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
1
2
3
$ pytest -q
....
4 passed in 0.01s

De nouveau vert, et le fichier de tests contient désormais quelque chose qui mérite d’être refactoré : quatre fonctions presque identiques. Les regrouper en un seul test paramétré compte comme un refactor au même titre que tout ce que vous feriez sur fizzbuzz.py, tant que le vert tient pendant toute l’opération.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    "n, expected",
    [
        (1, "1"),
        (3, "Fizz"),
        (5, "Buzz"),
        (15, "FizzBuzz"),
    ],
)
def test_fizzbuzz(n, expected):
    assert fizzbuzz(n) == expected

Le bug d’ordre n’est sorti que parce que le test pour 15 existait avant le correctif. Écrivez le code en premier et arrêtez-vous à « 3 donne Fizz, 5 donne Buzz, ça a l’air bon », et fizzbuzz(15) renvoie "Fizz" sans rien dans la suite pour le signaler.

Pourquoi le code écrit test-first finit faiblement couplé

Le TDD change la conception avant qu’aucun test ne s’exécute, parce que vous devez écrire le code de façon à ce que le test puisse l’appeler. Prenez une classe qui construit elle-même son repository vers la base de données, l’exemple même par lequel commence Qu’est-ce que la Dependency Injection. Vous ne pouvez pas lui passer un faux dans un test, parce que rien dans sa signature ne vous permet d’en substituer un. Écrire le test en premier pousse cette dépendance vers un paramètre ou un argument de constructeur avant même que le code de production existe pour y résister. Vous n’ajoutez pas le faible couplage après coup, en bonus. Sans cela, le test que vous êtes sur le point d’écrire ne s’exécute pas.

La même pression pousse les fonctions à ne faire qu’une seule chose. Si un test doit préparer trois éléments d’état sans rapport pour vérifier un comportement, la fonction testée fait trois choses sans rapport. Découpez-la, et le test devient plus court.

Quand ne pas utiliser le TDD

Le cycle a besoin de quelques conditions : vous connaissez le comportement voulu avant de l’écrire, les tests se relancent en quelques secondes, et le code a un point d’ancrage où insérer un test double. Perdez-en une seule et le cycle cesse de se rentabiliser. Même quand toutes ces conditions sont réunies, il a un angle mort.

Vous ne connaissez pas encore la forme de la chose. Si vous explorez une API inconnue ou que vous faites un spike pour voir si une approche est faisable, écrivez d’abord le code jetable. Apprenez quelle est la bonne abstraction, puis écrivez les tests contre la version que vous comptez garder. Les tests pour du code que vous allez supprimer, c’est un effort dépensé deux fois.

La boucle de feedback est lente. Le TDD implique de relancer les tests après chaque petite modification. Une suite qui touche une vraie base de données ou le réseau à chaque exécution transforme chacune de ces relances en attente. Tests Unitaires vs Tests d’Intégration vs Tests End-to-End explique pourquoi la rapidité au niveau unitaire vient du remplacement de ces dépendances. Si les seuls tests que vous pouvez écrire pour un bout de code sont des tests d’intégration lents, le tester en TDD signifie attendre, et les gens arrêtent de faire ce qui les fait attendre.

Il n’y a pas encore de point d’ancrage. Le code legacy, avec des constructeurs qui font appel à une infrastructure réelle et des fonctions qui s’appellent directement entre elles, ne peut pas être développé en TDD depuis son état actuel. Il n’y a nulle part où mettre un double. Qu’est-ce que le Spaghetti Code couvre la façon d’y entrer : un characterization test qui fixe d’abord le comportement existant, écrit contre le code tel qu’il est. Une fois ce filet en place, red-green-refactor fonctionne pour le changement suivant.

Le mocking excessif laisse quand même passer une mauvaise conception. Un test qui mocke chaque collaborateur peut passer au vert sur une fonction qui est un désastre en interne, tant que ses appels correspondent à ce que les mocks attendent. Le TDD garde votre attention sur la seule unité devant vous. De temps en temps, il faut quand même prendre du recul et se demander si les unités ont la bonne forme.

Comment commencer avec le TDD : corriger un bug

Choisissez un bug, pas une fonctionnalité, pour votre premier essai. Écrivez un test qui le reproduit : appelez la fonction avec l’entrée qui la casse, vérifiez la sortie que vous voulez vraiment obtenir, et regardez-le échouer pour la bonne raison avant de toucher au correctif. Le correctif devient l’étape green, et le test reste dans la suite pour que le bug ne puisse pas revenir sans être remarqué. Committez à chaque passage. Une trace de commits petits et toujours verts, c’est ce qui rend git bisect rapide la prochaine fois que quelque chose casse et que personne ne se souvient où.

Articles associés