Blog Arolla

Tester ce que l’on ne maîtrise pas

Les vacances de fin d’année ont été un peu courtes. Disons plutôt le week-end, étant parti le vendredi 28 décembre, me voilà déjà de retour au travail le mardi 2 janvier... On ouvre le backlog de la nouvelle année, on prend une nouvelle tâche, on l’analyse et, en bon praticien du TDD, on écrit un nouveau test. On lance les tests et BAM! C’est le drame du nouvel an! Plusieurs tests sont en « carafe », pour une fois que ce n’est pas le vin 😉 Mais qui a bien pu casser les tests? Je suis certain d’avoir tout laissé en bon état en partant. Pardon? Personne n’a commité depuis plusieurs jours? C'est bizarre...
Je continue mes investigations et me décide à lancer un petit « git bissect » de derrière les fagots. Je choisis un commit que j’avais fait la semaine dernière, je lance... et BAM! Voilà le responsable : mon commit. Comment est-ce possible?! L’intégration continue aurait dû avoir planté depuis une semaine! En cherchant un peu plus, je finis par me rendre compte que le test échoue en comparant 2018 à 2017. Fichtre, Diantre... C’est donc la nouvelle année qui fait planter le test... Par expérience, j'aurais pu m'en douter étant que c'est la troisième fois en moins d’un an (les deux autres fois étaient liées au changement d’heure).
L’origine du problème était la présence de la date sous forme d’un appel dans le code et d’une date en dur dans le test. Mais, comment résoudre ce problème ?

La dépendance au temps

Pourquoi s’embêter à extraire cette dépendance ? Imaginons une fonction métier dont le but est d’augmenter l’année de 1. On pourrait tester cette fonction de la façon suivante :

@Test
public void should_give_the_good_value() {
    Year the_date = some_business_logic();
    Year expectedDate = Year.now().plusYears(1);
    assertThat(the_date).isEqualTo(expectedDate);
}

Le problème avec cette approche est que cela ne fonctionne pas de manière absolue. On pourrait même imaginer que le test s’exécute le 31 décembre à 23h59 et que la valeur attendue soit évaluée le lendemain à minuit (c’est un peu tiré par les cheveux, mais il suffit de remplacer les dates par des secondes pour voir que le problème est plausible). C’est une sorte de mini « race condition ». Ensuite, on se retrouve à avoir l’implémentation dans les tests, une forme de duplication assez commune. Enfin, on ne prédit plus le résultat attendu, ce qui est contraire à une approche TDD.

Pour résoudre ce problème, plusieurs approches sont possibles. On peut fournir la date en paramètre à l’appel de la fonction nous permettant ainsi d’obtenir une fonction pure, dont une des propriétés est que la sortie se déduit uniquement des entrées :

@Test
public void should_give_the_good_value() throws Exception {
    Year the_date = some_business_logic(Year.of(2017));
    assertThat(the_date).isEqualTo(Year.of(2018));
}

Si jamais, on a besoin d’évaluer régulièrement l’heure, il est possible de fournir le service sous forme d’une injection de dépendance classique :

public class Business {
    private ClockService clockService;
    public Business(ClockService clockService) {
        this.clockService = clockService;
    }
    Year some_business_logic() {
        Year now = clockService.now();
        return now.plusYears(1);
    }
}

Il devient alors facile d’injecter un mock, un dummy ou un stub (rayer la mention inutile) en tant qu’horloge. Sandro Mancuso présente cette approche dans la vidéo « Outside-In TDD » (https://youtu.be/XHnuMjah6ps).

Enfin, une autre façon de s'y prendre est de s’appuyer sur le framework JodaTime qui fournit des fonctions pour forcer la date et l’heure (via setCurrentMillisFixed par exemple). Une méthode qui est bien moins élégante que les précédentes.

Ceci dit, la date n’est pas la seule variable non prédictive que l’on peut rencontrer. On peut, par exemple, faire face à l’aléatoire lui-même.

Prédire l’aléatoire

Lors du « Global Day of Code Retreat » 2016 chez Arolla, avec les derniers participants restants à la fin de la journée, nous nous sommes lancé un petit défi : imaginons qu’au lieu d’avoir des cellules qui apparaissent ou disparaissent de manière prédictive, celles-ci vont mourir ou survivre de manière aléatoire, bien que toutes les conditions soient remplies. Et faisons-le en Haskell. Sacré défi! Comment tester alors qu'on ne sait pas quel résultat nous allons avoir?

Après quelques étapes, le problème ne nous est pas apparu plus compliqué que le problème classique du jeu de la vie. En effet, il nous a suffi d’introduire la variable aléatoire sous la forme d’un paramètre que nous contrôlions. Ainsi, l’aléatoire dans le code se réduit au même problème que celui des dates. (cf. le paragraphe précédent pour retrouver les solutions).

Allons plus loin. Lors d’échanges avec un stagiaire de notre équipe, il nous a semblé intéressant de mettre en œuvre un test a posteriori sur une implémentation de la méthode de Monte-Carlo. Si vous ne connaissez pas cette méthode, le principe est de calculer de manière aléatoire un grand nombre de valeurs et de compter le nombre de valeurs respectant une condition. Ici, plutôt que d’avoir un jeu de test de milliers de valeurs préfixées, nous avons utilisé une propriété des générateurs de nombres aléatoires :

@Test
public void monte_carlo_test() {
    MonteCarlo monteCarlo = new MonteCarlo(new Random(42));
    double ratio = monteCarlo.evaluate();
    assertThat(ratio).isCloseTo(0.9, Offset.offset(0.01));
}

En fixant la « graine » à la création, nous obtenons un générateur aléatoire tout à fait prédictif, permettant de remplacer un fichier de valeurs de tests difficile à maintenir. Il ne restera plus qu’à remplacer la classe Random par son implémentation sécurisée dans le code en production : SecureRandom.

Le dernier point : en introduisant de l’aléatoire dans les résultats, on contrevient aux principes FIRST, notamment le R (pour Repeatable). Ce principe est issu du livre Clean Code de Robert C. Martin. Pour qu’il soit « repeatable », le test :

  • Ne doit pas dépendre de son environnement

  • Doit avoir le même résultat quel que soit l’instant ou l’endroit où il s’exécute

Par exemple, un test reposant sur un identifiant généré aléatoire ne peut pas être considéré comme aléatoire car, le résultat n’est pas identique dans le temps.

@Test
public void random_uuid() throws Exception {
    UUID uuid = UUID.randomUUID();
    Entity e = new Entity(uuid, "state1");
    e.process();
    assertThat(e).isEqualTo(new Entity(uuid, "state2"));
}

Mais, est-ce que l’on peut et doit toujours contrôler l'incontrôlable ?

“Repetable everywhere”

La notion de reproductible s'entend aussi bien dans le temps que dans l'espace. Ainsi, des tests doivent avoir le même comportement quel que soit l'environnement où il s'exécute. Le test ne doit pas dépendre de l'environnement si celui-ci n'est pas défini dans le test.

Par exemple, pour formatter une date, nous pourrions faire:

@Test
public void should_have_a_correct_format() {
    DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM);
    String formattedDate = dateTimeFormatter.format(LocalDate.of(2018, 1, 30));
    Assert.assertEquals("Jan 30, 2018", formattedDate);  
}

Dans ce cas, le problème est que DateTimeFormatter va définir le format en se basant sur la locale du système. Cependant, si le poste du développeur est en français et l'intégration continue en anglais (ou un autre poste en anglais), le test va échoué. Ici, il aurait fallu forcer le contexte avec withLocale().

Tout est sous contrôle ?

Il existe bien évidemment des cas où il n’est pas possible d’injecter la date telle que nous la voulons. Par exemple, le code peut dépendre d’un framework avec la date en dur sur laquelle nous n’avons pas forcément la maîtrise. Ensuite, il peut être dans la nature même du système que de dépendre du temps.

Un exemple qui me vient en tête est le cas du framework Akka pour Scala :

class CheatyPlayerSpec extends TestKit(ActorSystem("test"))
  with WordSpecLike
  with ImplicitSender {
  implicit val timeout: Timeout = Timeout(1 second)
  "CheatyPlayer" should {
    "always cheat" in {
      val player = system.actorOf(Props[CheatyPlayer])
      player ! Play
      expectMsg(Cheat)
    }
  }
}

Les acteurs s’échangent des messages entre eux. Une durée (« timeout ») est définie et fait échouer le test si celui-ci dure plus qu’une certaine durée. Comme tout fonctionne de manière asynchrone, on espère globalement que tout se passera avant le déclenchement du timeout.

Inutile de dire que ce genre de test est à la fois lent et surtout instable (leur comportement est différent en fonction de la puissance de la machine, plus ou moins rapide).

Le principe FIRST ne s’applique pas à tous les tests. Dans le cas du property-based testing, les données sont générées aléatoirement. On vérifie si une propriété est toujours valide sur le résultat. On espère ainsi que le test soit toujours utile mais sans certitude. Les tests de plus haut niveau ne respectent pas non plus cette propriété. De nombreux paramètres peuvent intervenir, rendant souvent les tests fragiles et peu fiables (puissance des machines, réseau, …).

Les tests avec des dépendances non prédictives ont une fâcheuse tendance à échouer au moment où on s’y attend le moins. Cependant, une bonne hygiène de développement en essayant de s’abstraire le plus possible de ces contraintes permet d’obtenir un meilleur design et des tests robustes et fiables.

Ah... la nouvelle année... Cette année, je prends donc deux résolutions. La première : refaire un peu de sport. La seconde : ne plus avoir de tests qui dépendent du temps. Je me demande si j’arriverai à tenir toutes ces bonnes résolutions ou si je craquerai avant 😉

Plus de publications

1 comment for “Tester ce que l’on ne maîtrise pas

  1. Kévin Le Helley
    31 octobre 2018 at 12 h 18 min

    À noter concernant la dépendance au temps décrite dans la première partie de l’article que si l’on utilise les classes issues du package java.time ajouté à partir de Java 8, on préférera utiliser la méthode now(Clock) plutôt que now(). L’objet de type Clock sera géré à l’extérieur de l’instance, ce qui permet par exemple d’utiliser l’horloge standard retournée par Clock.systemUTC() pendant l’exécution normale de l’application, mais une horloge fixe retournée par Clock.fixed(Instant, ZoneId) dans le cadre de tests unitaires. Comme toujours, se référer à la JavaDoc pour plus de détails. 😉