Partie 1
À force de relire du code, nous finissons par tomber sur un certain nombre de patterns qui sont absolument terribles lorsque nous écrivons des tests et faisons des assertions.
Dans ce papier, nous nous attacherons à en décrire un certain nombre et surtout essayer de vous montrer des façons d’éviter de se prendre les pieds dedans et ce grâce à la bibliothèque AssertJ. Il se divisera en deux parties, la première mettra la focale sur des généralités autour d’AssertJ, là où la seconde s’attachera à décrire comment tirer profit des facilités de la bibliothèque pour re-factoriser ses tests.
AssertJ
AssertJ est une bibliothèque open source focalisée sur les assertions. Sa première release remonte à 2013 ce qui dénote d’une certaine maturité. Contrairement aux bibliothèques telles que JUnit ou TestNG, AssertJ n’est pas un environnement d’exécution pour les tests. Elle vient en complément de ces dernières afin de proposer des assertions qui mettent l’accent sur l’aspect sémantique de ce que nous souhaitons vérifier. À travers son API fluent, AssertJ nous permet d’expliciter nos intentions lors de la rédaction des tests.
Nous noterons qu’AssertJ offre également le support de certaines bibliothèques populaires dans l’écosystème Java, tel que Guava, Neo4J, JDBC. Ce support est mis à disposition via des extensions à AssertJ. Ce sont des dépendances supplémentaires à ajouter à notre projet.
Pourquoi utiliser AssertJ ?
JUnit
Lorsque nous développons avec seulement l’aide de JUnit, nous sommes souvent amenés à écrire ce type d’assertion :
assertFalse(collection.isEmpty());
Certes, à force de voir ce pattern, nous sommes habitués à comprendre ce que nous souhaitons faire. Ici, nous voulons nous assurer que la collection n’est pas vide. Pour ce faire, nous écrivons une formule qui en français ressemblerait à :
Assure nous que c’est faux que la collection est vide.
Niveau style, nous avons vu mieux. Nous nous permettons également de revenir sur le message d’erreur au cas où l’assertion ne serait pas vérifiée :
org.opentest4j.AssertionFailedError:
Expected :false
Actual :true
Si l’on tombe sur ce message d’erreur dans une pipeline, nous sommes bien avancés…
AssertJ
La même assertion écrite cette fois-ci en AssertJ aurait cette tête là :
assertThat(collection).isNotEmpty();
En lisant le code, nous comprenons immédiatement :
Assure nous que la collection n’est pas vide
Il n’y a pas d’effort de logique supplémentaire à fournir ici. Nous comprenons tout de suite l’idée.
De même, sur l’aspect des messages d’erreurs, nous nous retrouvons à avoir quelque chose de cette forme :
java.lang.AssertionError:
Expecting actual not to be empty
Encore une fois, en lisant ça, nous sommes un peu plus au fait des problèmes que le test soulève.
Assertions sur les collections
Puisque nous en sommes à parler d’assertions sur les collections, nous pouvons observer des paterns qui reviennent souvent. Voici un exemple typique :
assertFalse(collection.isEmpty());
assertEquals(1, collection.size());
var element = collection.get(0);
assertEquals("test", element);
De prime abord, cette suite d’assertion semble anodine, cependant elle montre des lourdeurs qui ne sont pas nécessaires. Quelle est l’essence de ce que nous souhaitons vérifier ? Nous voulons nous assurer que notre collection ne contient qu’un seul élément et que ce dernier vaut “test”.
Dans l’exemple ci-dessus, nous brouillons les pistes quant à nos intentions. En effet, à l’exécution, il ne faut pas oublier que lorsqu’une assertion n’est pas vérifiée, elle lance une exception qui court-circuite l’exécution du programme. Ainsi si la deuxième ligne échoue, nous n’avons aucune idée des valeurs qui se trouvent dans notre collection.
Enfin, nous pouvons observer une implication logique entre les 2 premières lignes. Nous pourrons toujours déduire qu’une collection n’est pas vide si sa taille est supérieure ou égale à 1. Nous pourrions donc facilement retirer la première ligne et ne perdre aucune vérification.
Néanmoins, si nous utilisons AssertJ, nous n’avons plus aucune excuse. Le code précédent se traduit dans le suivant :
assertThat(collection)
.singleElement()
.isEqualTo("test");
Ici, le singleElement permet d’une part de s’assurer que notre collection n’a qu’un seul élément, et d’autre part de l’extraire pour le mettre à disposition d’autres assertions.
AssertJ offre plusieurs méthodes bien pratiques lorsque nous interagissons avec des collections. Celles qui reviennent le plus souvent sont first() et last(). Elles permettent respectivement de récupérer le premier et le dernier élément d’une collection pour pouvoir appliquer des assertions dessus.
Pour information, ces méthodes renvoient souvent des assertions un peu basiques qui ne permettent pas de proposer des méthodes qui seraient adaptées au type de la collection. Par exemple, dans une collection de nombre, on pourrait vouloir vérifier que le premier élément est supérieur à 10. Si AssertJ peut le permettre, avec la forme suivante, ce n’est pas possible :
assertThat(List.of(11, 12, 13))
.first()
.isGreaterThan(10);
À cause du système de type et de la gestion des génériques de Java, le compilateur n’est pas en mesure de déterminer quel est le type des éléments de la collection. Ainsi le code précédent ne compilera pas.
Si nous souhaitons écrire ce code, il faut donner un petit coup de main à notre compilateur pour qu’il puisse s’en sortir :
assertThat(List.of(11, 12, 13))
.first(InstanceOfAssertFactories.INTEGER)
.isGreaterThan(10);
Ici, nous avons ajouté le InstanceOfAssertFactories.INTEGER qui permet de préciser le type d’assertion que nous souhaitons obtenir.
AssertJ offre beaucoup de ces factories qui vont permettre d’indiquer au compilateur Java quel type nous souhaitons effectivement traiter. Nous retrouvons tous les types de base dans la classe InstanceOfAssertFactories.
Assertion sur plusieurs éléments
Dans la théorie, nous insistons sur le fait qu’un test ne doit vérifier qu’une seule et unique chose. Certains disent qu’il faut avoir par conséquent qu’une seule assertion dans les tests, d’autres que lorsque les assertions vérifient toujours ce même concept, alors il est acceptable d’avoir plusieurs assertions.
Dans la pratique, nous sommes quand même amenés à écrire plusieurs assertions les unes à la suite des autres. Nous pouvons nous retrouver avec ce type de pattern:
assertEquals(List.of(1, 2, 3), list);
assertEquals("ok", message);
assertEquals("NaN", Float.toString(Float.NaN));
Mais que se passe-t-il lorsque la première assertion échoue ? Et bien les autres ne sont pas évalués.
Il y a peut-être certains cas pour lesquels cela reste pertinent. Mais, la plupart du temps, nous aimerions pouvoir tester toutes nos assertions d’un coup avant de voir notre test échouer. Dans ce cas, on peut utiliser assertAll de Junit 5 !
assertAll(
() -> assertEquals(List.of(1, 2, 3), list),
() -> assertEquals("ok", message),
() -> assertEquals("NaN", Float.toString(Float.NaN))
);
Ici, chaque lambda est évaluée indépendamment, si bien que si l’une échoue, nous avons quand même le résultat pour les autres. C’est un comportement très intéressant, puisque dans le cas où un test est un peu long à exécuter, pour un test d’intégration par exemple, nous avons toutes les informations d’un seul coup. Nous minimisons alors les allers-retours entre le développement et l’exécution des tests.
Néanmoins, l’objectif de ce papier était de vous montrer l’utilisation d’AssertJ. Avec AssertJ, nous pouvons écrire une chose de ce type en passant par les assertions d’AsserJ:
assertAll(
() -> assertThat(list).containsExactly(1, 2, 3),
() -> assertThat(message).isEqualTo("ok"),
() -> assertThat(Float.NaN).hasToString("NaN")
);
Ou bien, nous pouvons rester dans l’écosystème AssertJ. C’est d’ailleurs l’une de nos recommandations : il est préférable de rester fidèle à une seule bibliothèque d’assertions à la fois. Dans la pratique, les bibliothèques d’assertions ne communiquent pas très bien les unes avec les autres. En utilisant conjointement les assertions de JUnit et celles d’AssertJ, nous nous retrouvons à avoir des messages d’erreur assez peu clairs, avec des stack traces assez balèzes, agrémentées de suppressed exceptions, ou des logs dédoublés. Si nous restons dans un écosystème, nous devons nous assurer de la cohérence globale du système dans lequel nous sommes.
Donc qu’est que ça donne avec AssertJ ?
SoftAssertions softly = new SoftAssertions();
softly.assertThat(list).containsExactly(1, 2, 3);
softly.assertThat(message).isEqualTo("ok");
softly.assertThat(Float.NaN).hasToString("NaN");
softly.assertAll();
Le problème ici, c’est de penser à bien appeler softly.assertAll() à la fin de notre méthode de test. Sinon, nous ne vérifions rien du tout. Ce serait un peu dommage de l’oublier.
C’est une charge mentale que nous n’avons pas envie de garder, donc bien sûr, il existe de multiples solutions pour palier ce problème :
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(list).containsExactly(1, 2, 3);
softly.assertThat(message).isEqualTo("ok");
softly.assertThat(Float.NaN).hasToString("NaN");
});
Ici nous reprenons plus ou moins l’approche de Junit avec l’utilisation de lambda. C’est la méthode qui se charge de faire l’assertion finale pour nous !
Si nous souhaitons sortir l’artillerie lourde, c’est possible avec les extensions JUnit. Ces dernières permettent à des bibliothèques externes de s’interfacer avec le framework. Ainsi, grâce aux extensions, nous pouvons injecter des composants en attribut de la classe de test et laisser l’extension gérer le cycle de vie de ces objets pour nous. Ce faisant, nous économisons des lignes de codes techniques qui ne sont pas nécessairement intéressantes vis-à-vis de l’aspect métier.
Ainsi, nous pourrions utiliser l’extension SoftAssertionsExtension afin d’automatiser l’appel à assertAll() :
@ExtendWith(SoftAssertionsExtension.class)
class AppTest {
@InjectSoftAssertions
SoftAssertions softly;
@Test
void shouldSoftlyAssert() {
var list = List.of();
var message = "ok";
softly.assertThat(list).containsExactly(1, 2, 3);
softly.assertThat(message).isEqualTo("ok");
softly.assertThat(Float.NaN).hasToString("NaN");
}
}
Si nous avons une petite préférence pour la version “lambda”, cette version à le mérite d’être un peu plus courte en termes de ligne de code et surtout, elle n’ajoute pas un nouveau niveau d’indentation qui pourrait être perturbant pour certain. Libre à chacun d’adopter l’approche qui lui plaît le plus.
Assertion sur des données temporelles
Entendons-nous bien, la meilleure façon de tester tout ce qui a trait aux dates est de pouvoir les mocker. Pour se faire, il suffit de d’injecter l’objet Clock de Java dans nos services. Ainsi, nous pouvons mocker notre clock avec un FixedClock , et donc avoir une maîtrise totale sur l’environnement temporel de nos tests. Nous parlons ici d’un monde idéal. Cependant, il arrive que nous n’ayons pas toujours cette chance (relicats des développeurs avant nous qui n’y ont pas pensé, tester des services qui viennent de package sur lesquels nous n’avons pas la main, …). À ce moment-là, nous pouvons compter sur AssertJ pour voler à notre rescousse.
Depuis java 8, l’API date a évoluée et fournit un ensemble de classes essentielles pour manipuler les dates. S’il vous plaît, utilisez les et arrêtez avec java.util.Date 🙏
En revanche, la précision de ces classes est fixée à la milli, voir à la nanoseconde ! Nous avons besoin de cette précision dans notre code métier, mais dans le cas des tests, ça peut s’avérer un peu trop.
Vous vous en doutez peut-être, AssertJ a prévu le coup en offrant des assertions sur les données temporelles. La bibliothèque offre un ensemble de méthodes qui permet de situer notre point dans le temps par rapport à d’autres. Ainsi, nous pouvons vérifier si notre date est avant, après ou entre d’autres dates :
var actual = Instant.now();
var start = actual.minus(10, ChronoUnit.MINUTES);
var end = actual.plus(10, ChronoUnit.MINUTES);
assertThat(actual)
.isAfter(start)
.isBefore(end)
.isBetween(start, end);
Ils ont même prévu le coup avec une autre méthode bien utile qui permet de s’assurer qu’une date est proche d’une autre sur une fenêtre donnée. Admettons que nous ayons une date qui marque le début de notre méthode de test, nous pourrions vouloir nous assurer que toutes les données temporelles produites dans cette méthode sont proches de cette date de référence dans un intervalle de 10 secondes. Nous écririons alors quelque chose comme :
assertThat(actual).isCloseTo(reference, within(10, ChronoUnit.SECONDS));
Ici, nous cherchons à savoir si la date actual est proche de la reference dans un intervalle de 10 secondes.
Résumé
Nous voyons ici qu’AssertJ est une bibliothèque d’assertion extrêmement polyvalente. Elle est d’une grande expressivité, ce qui permet de clarifier nos intentions lorsque nous écrivons des tests. De ce fait, les messages d’erreur que la bibliothèque met en lumière sont beaucoup plus parlants. Nous pouvons véritablement attribuer à AssertJ le titre de couteau-suisse de l’assertion.
Bien sûr, nous pouvons aller plus loin avec, et c’est ce que nous verrons dans la prochaine partie.