AssertJ comme un pro

Partie 2

Thomas Fabre
Takima
9 min readApr 15, 2024

--

People illustrations by Storyset

Nous avons vu précédemment des généralités sur AssertJ. Nous savons à quel point cette bibliothèque libère l’expressivité de notre code.

Ici, nous nous attacherons à observer comment elle va nous permettre de garder nos tests propres grâce à ses capacités de réfactorisation.

Un peu de réfactorisation

Problématique

Dans beaucoup de projets, nous avons une couche de validation. Bien souvent, cette couche se décline en classes qui vont analyser un DTO. Si le DTO a un problème, la méthode de validation lancera une exception. Le diagramme suivant permet de bien comprendre les objets que nous allons manipuler :

Diagramme de classe du modèle de ValidationException

Pour tester quelque chose dans ce genre, nous pourrions écrire une assertion de cette forme :

assertThatThrownBy(() -> validator.validate(toValidate))
.isInstanceOf(ValidationException.class)
.extracting("errors", InstanceOfAssertFactories.collection(ErrorDetails.class))
.hasSize(1)
.first()
.isEqualTo(new ErrorDetails("name", ErrorCode.SHOULD_NOT_BE_NULL));

Très concrètement, ce code remplit son objectif. Il y a de petites choses que nous pourrions revoir comme le fait de transformer la paire hasSize(1) et first() en singleElement(). Ceci dit, ici, nous vérifions que l’exception lancée par notre validateur n’a qu’une seule erreur de validation qui porte sur le champ “name” et qui vaut ErrorCode.SHOULD_NOT_BE_NULL.

En l’état, rien de problématique, seulement, il s’avère que dans la pratique, nous allons avoir un petit paquet de tests sur nos validateurs, et que nos 7 lignes de code précédentes vont être copiées encore et encore. En tant que développeurs consciencieux, nous savons à quel point c’est une très mauvaise idée de copier/coller du code, ne serait-ce que pour des problématiques de lisibilité. De plus, ces lignes de code ne sont pas très explicites sur ce que nous cherchons à faire.

Niveau 0

La solution qui doit nous sauter aux yeux, quand nous voyons du code dupliqué c’est de factoriser ce code. C’est-à-dire de littéralement trouver le facteur commun à toutes ces opérations et de le mutualiser. Pour notre cas, cela se traduirait simplement par le fait de créer une méthode qui encapsule ce code. Nous aurions alors :

private static <T> void hasOnlyOneErrorOfType(
Validator<T> validator,
T toValidate,
String field,
ErrorCode expectedErrorCode
) {
assertThatThrownBy(() -> validator.validate(toValidate))
.isInstanceOf(ValidationException.class)
.extracting("errors", InstanceOfAssertFactories.collection(ErrorDetails.class))
.hasSize(1)
.first()
.isEqualTo(new ErrorDetails(field, expectedErrorCode));
}

Cette approche est déjà très bonne car elle permet d’encapsuler le code verbeux et ainsi éviter les duplications. À présent, si jamais nous devons modifier quelque chose dans cette assertion, nous ne le modifierons qu’à un seul endroit.

Cela dit, il reste quelques inconvénients. D’abord, lorsque nous appelons cette méthode, il n’est pas nécessairement très clair de comprendre qu’il y a une assertion derrière. De plus, nous extrayons généralement ce type de méthode dans la classe de test où nous l’utilisons. Il est rare que nous pensions à la mettre dans une classe partagée. Et par conséquent, nous nous retrouvons à dupliquer cette méthode de classe en classe.

Si tout cela est très bien, c’est sans compter les possibilités que nous offrent AssertJ. Voyons ce que nous pouvons faire quand nous nous penchons sur cette bibliothèque.

Les conditions

AssertJ offre un moyen de mettre en commun des prédicats de tests. En plus de limiter la duplication de code, ces prédicats sont typés, ce qui nous offre de la sécurité à la compilation. Notons également que ces prédicats sont composables. Nous pouvons donc faire de la validation sur un ou plusieurs prédicats et tester si notre objet les vérifie tous, ou au moins un !

Dans l’univers d’AssertJ, les prédicats s’appellent des Condition<T> . Nous les construisons comme suit :

Condition<Integer> isTheAnswer = new Condition<>(
i -> i == 42, "The number is the response");

Le constructeur prend un Predicat<T> et une description. Nous pouvons ensuite l’utiliser de la façon suivante :

assertThat(answer).satisfies(isTheAnswer);

Ici, rien de bien compliqué. Nous pourrions du coup adapter le code de validation précédent comme suit :

var hasSingleNotNullError = new Condition<ValidationException>(
v -> v.getErrors().size() == 1 &&
v.getErrors()
.stream()
.findFirst()
.orElseThrow()
.errorCode()
.equals(ErrorCode.SHOULD_NOT_BE_NULL),
"Has single error which is SHOULD_NOT_BE_NULL");

Nous permettant ensuite de l’utiliser de cette manière :

assertThatThrownBy(() -> validator.validate(toValidate))
.asInstanceOf(InstanceOfAssertFactories.type(ValidationException.class))
.satisfies(hasSingleNotNullError);

L’inconvénient avec l’exemple précédent c’est qu’il fige les arguments que l’on veut tester. Si nous utilisons la condition telle qu’elle, nous vérifions que le code d’erreur est bien ErrorCode.SHOULD_NOT_BE_NULL et seulement celui-ci. De plus, nous ne vérifions pas le nom du champ qui porte l’erreur.

Alors nous pourrions certes, dupliquer le code pour toutes les variantes et combinaisons de code d’erreur et de champ. Mais en tant que développeurs, nous sommes un peu plus malin que ça, et nous avons à disposition de meilleurs moyens pour factoriser les choses. Voici donc une version plus complète :

private static Condition<ValidationException> hasOnlyOneErrorOfType(
String field,
ErrorCode expectedErrorCode
) {
return new Condition<>(
v -> {
ErrorDetails errorDetails = v.getErrors()
.stream()
.findFirst()
.orElseThrow();
return v.getErrors().size() == 1 &&
errorDetails.field().equals(field) &&
errorDetails.errorCode().equals(expectedErrorCode);
}, "Has single error which is SHOULD_NOT_BE_NULL");
}

Ici c’est beaucoup mieux, nous pouvons maintenant paramétriser nos conditions à l’aide des closures de Java. Grâce aux closures, nous pouvons capturer les paramètres de méthode à l’intérieur du prédicat pour les réutiliser lors de l’appel. Nous l’utilisons ainsi :

assertThatThrownBy(() -> validator.validate(toValidate))
.asInstanceOf(InstanceOfAssertFactories.type(ValidationException.class))
.satisfies(hasOnlyOneErrorOfType("name", ErrorCode.SHOULD_NOT_BE_NULL));

Nous avons ici beaucoup plus de modularité. De plus, nous voyons explicitement un appel d’assertion, ce qui aura tendance à clarifier le code.

Néanmoins, il subsiste encore un problème avec cette approche. L’attrait d’AssertJ réside autour de son API sémantique. Elle permet aux développeurs de trouver la bonne méthode d’assertion en fonction du type qu’elle va fournir au assertThat. Avec les conditions, c’est à nous de savoir celles que nous avons à disposition avant de pouvoir les utiliser. Ici, on n’aura aucune aide de la part de notre IDE, ce qui est un peu dommage.

Nos propres assertions ?

Si les méthodes précédentes sont intéressantes, et relèvent de la bonne pratique, il est possible d’aller encore un peu plus loin, en créant nos propres assertions. Bien souvent, pour une classe ou une typologie de tests données, nous avons un ensemble de choses diverses à vérifier. Chacun a ses petites particularités, et il semblerait logique de les regrouper ensemble. Nous pourrions nous employer à appliquer la même philosophie qu’AssertJ sur nos propres types. Ce faisant, nous gagnerions en temps de développement, en maintenabilité et en lisibilité !

Pour créer notre propre assertion avec AssertJ, il suffit de créer une classe et de lui faire étendre au moins le type AbstractAssert.

Pourquoi “au moins” ?

Parce que nous avons tout un ensemble de classes qui étendent AbstractAssert et qui le spécialise en fonction d’un type donné. Typiquement, pour notre cas, nous voulons faire une assertion sur une exception. Si nous nous limitons à AbstractAssert, nous aurons un ensemble de méthodes intéressantes qui hériteront de cette classe, mais nous pourrions avoir un sur-ensemble plus grand et plus spécifique si nous utilisions AbstractThrowableAssert .

Sans plus d’atermoiements, nous vous proposons l’implémentation de notre assertion pas à pas :

public class ValidationExceptionAssert
extends AbstractThrowableAssert<ValidationExceptionAssert, ValidationException> {
}

Comme dit précédemment, nous étendons le type AbstractThrowableAssert pour pouvoir bénéficier de toutes les méthodes d’assertions sur les exceptions dont nous disposons dans AsserJ. Les plus curieux d’entre vous auront remarqué que la classe AbstractThrowableAssert est générique. Pour être plus précis, c’est même la classe AbstractAssert qui est générique. Elle a 2 paramètres dont voici la définition :

class AbstractAssert<SELF extends AbstractAssert<SELF, ACTUAL>, ACTUAL>

Le premier paramètre SELF sert à avoir une référence vers le type qui implémente effectivement cette classe. En utilisant ce type générique plutôt que le nom de la classe elle-même dans les signatures des méthodes, nous nous assurons que le type renvoyé est bien l’objet qui étend la classe mère et non la classe mère elle-même. Ce faisant, nous pouvons enchaîner les appels qui viennent de la classe abstraite avec les appels de la classe concrète.

Le second paramètre ACTUAL permet de garder une trace du type que nous allons soumettre à nos assertions. Il va s’en dire qu’il faut garder une trace de ces 2 paramètres au niveau du code et pas seulement au niveau du type. Nous devons donc renseigner ces informations au niveau du constructeur :

public ValidationExceptionAssert(ValidationException actual) {
super(actual, ValidationExceptionAssert.class);
}

Maintenant que nous avons mis en place la base de notre assertion, nous pouvons nous attarder sur le test que nous voulons faire. Nous proposons donc l’implémentation suivante :

public ValidationExceptionAssert hasOnlyThisErrorDetails(
String field,
ErrorCode errorCode
) {
var actualErrors = Set.copyOf(actual.getErrors());
var expectedErrors = Set.of(new ErrorDetails(field, errorCode));
if (!actualErrors.equals(expectedErrors)) {
throw failureWithActualExpected(actualErrors, expectedErrors, "%s should have one errors", actual);
}
return myself;
}

Nous créons donc la méthode qui va vérifier que nous n’avons qu’une seule erreur pour un champ et un code donné. Outre les copies défensives ici qui ne sont pas strictement nécessaires, nous pouvons noter l’appel à la méthode failureWithActualExpected qui créée une exception pour nous avec un message d’erreur explicite et clair. Nous avons la main pour lancer l’exception nous-même. Le fait que ce soit nous qui écrivions l’instruction throw permet au compilateur de se rendre compte que si nous atteignons ce point nous sortons de la méthode. Cette astuce peut aider dans certains cas.

Ici nous renvoyons myself plutôt que this pour des raisons de convention. this aurait été adapté dans notre cas, mais utiliser une référence à myself permet de rester plus proche de la philosophie du framework. Vous l’aurez deviné, myself est une référence vers this mais qui a le type générique SELF permettant de faire comprendre au compilateur que nous manipulons les bons types.

Bien que nous soyons déjà bien avancés dans la rédaction de notre assertion, nous allons ajouter quelques méthodes qui vont nous simplifier la tâche par la suite. Parmis celles-ci, introduisons factory() :

public static InstanceOfAssertFactory<ValidationException, ValidationExceptionAssert> factory() {
return new InstanceOfAssertFactory<>(ValidationException.class, ValidationExceptionAssert::new);
}

Cette méthode va nous permettre de transformer l’assertion que nous sommes en train de manipuler, donc issue du framework AssertJ, en l’assertion que nous sommes en train d’écrire. Le système de type de Java étant ce qu’il est, il a besoin d’un petit coup de pouce de temps en temps pour comprendre nos intentions.

Avec le code que nous avons déjà écrit, nous pouvons déjà utiliser notre assertion comme suit :

assertThatThrownBy(() -> validator.validate(toValidate))
.asInstanceOf(ValidationExceptionAssert.factory())
.hasOnlyThisErrorDetails("name", ErrorCode.SHOULD_NOT_BE_NULL);

C’est déjà un peu plus clair qu’avec les conditions ! En plus de cela, nous avons l’autocomplétion et nous pouvons enchaîner avec toutes les méthodes sur les exceptions !!

Mais peut-être que nous pourrions faire encore un peu mieux en ajoutant à notre classe cette dernière méthode :

public static ValidationExceptionAssert assertThatThrowsValidationException(
ThrowableAssert.ThrowingCallable shouldRaiseThrowable
) {
return new ValidationExceptionAssert(
AssertionsForClassTypes.catchThrowableOfType(
shouldRaiseThrowable, ValidationException.class));
}

Cette méthode est une “static factory method” qui permet d’instancier notre classe d’exception de façon explicite. Ce faisant, nous explicitons notre intention lors de la rédaction des tests. Ainsi, nous pouvons écrire dans nos tests l’assertion suivante :

assertThatThrowsValidationException(() -> validator.validate("test"))
.hasOnlyThisErrorDetails("name", ErrorCode.SHOULD_NOT_BE_NULL);

Si nous reprenons toute la classe, voici ce que ça donne :

public class ValidationExceptionAssert
extends AbstractThrowableAssert<ValidationExceptionAssert, ValidationException> {

public ValidationExceptionAssert(ValidationException actual) {
super(actual, ValidationExceptionAssert.class);
}

public ValidationExceptionAssert hasOnlyThisErrorDetails(
String field,
ErrorCode errorCode
) {
var actualErrors = Set.copyOf(actual.getErrors());
var expectedErrors = Set.of(new ErrorDetails(field, errorCode));
if (!actualErrors.equals(expectedErrors)) {
throw failureWithActualExpected(actualErrors, expectedErrors, "%s should have one errors", actual);
}
return myself;
}

public static InstanceOfAssertFactory<ValidationException, ValidationExceptionAssert> factory() {
return new InstanceOfAssertFactory<>(
ValidationException.class, ValidationExceptionAssert::new);
}

public static ValidationExceptionAssert assertThatThrowsValidationException(
ThrowableAssert.ThrowingCallable shouldRaiseThrowable
) {
return new ValidationExceptionAssert(AssertionsForClassTypes
.catchThrowableOfType(shouldRaiseThrowable, ValidationException.class));
}
}

En une trentaine de lignes de code, nous avons créé une assertion réutilisable dans tous nos tests. Cette classe permet d’encapsuler un bon nombre de lignes de code et en explicite l’intention en termes de sémantique. À partir du moment où cette assertion est importée, notre IDE pourra nous guider dans le choix des méthodes grâce à l’autocomplétion. Pour un petit effort initial, nous obtenons beaucoup de flexibilité en termes de refactoring et de lisibilité dans la rédaction des tests. Pourquoi s’en priver ?

Le mot de la fin

AssertJ est une bibliothèque d’assertion formidable qui mérite notre attention à tous. Elle offre énormément de fonctionnalités bien utiles lors de la rédaction de vos tests. D’une part, l’API sémantique qu’elle offre permet de la découvrir pas à pas grâce à l’autocomplétion. D’autre part, l’utiliser permet d’expliciter vos intentions lors de la rédaction des tests. Il en résulte des tests plus précis et plus lisibles.

De plus, cette bibliothèque offre un large support d’assertions précises sur les objets de la bibliothèque standard de Java. Que ce soit les collections, les chaînes de caractères ou les données temporelles, nous trouverons toujours chaussure à notre pied.

Enfin, lorsqu’il s’agit d’obtenir le support pour nos propres types, nous avons vu qu’AssertJ en a encore sous la pédale en nous permettant d’utiliser les conditions ou bien encore de créer nos propres assertions. Bien que créer ces propres assertions n’est pas toujours nécessaire, nous pensons qu’il est intéressant de savoir le faire. Dans certaines situations, avoir ses propres assertions retire beaucoup de complexité à la base de test.

Aujourd’hui, la rédaction des tests est une dimension essentielle du métier de développeur. Nous nous devons d’y apporter autant de soin qu’à notre base de code fonctionnelle. Dans cette aventure, AssertJ se révélera être votre meilleur allié ! Prêtez-lui l’attention qu’il mérite.

Originally published at https://blog.takima.fr on April 16, 2024 by Thomas Fabre

--

--