Rédiger des tests d'assertions paraît simple : tout ce qu'on doit faire est de comparer les résultats avec ce qu'on attend. Cela se fait habituellement à l'aide des méthodes d'assertions - par exemple assertTrue() ou assertEquals() - fournies par des frameworks de tests. Néanmoins, dans le cas de scénarios de tests plus complexes, il peut être plus difficile de vérifier le résultat d'un test à l'aide d'assertions basiques.
Le principal problème est que, en les utilisant, on rend nos tests plus abstraits avec des détails de bas niveau. Cela n'est pas bon. À mon avis, on devrait se concentrer sur l'aspect métier pour nos tests.
Dans cet article, je vais vous montrer comment on pourrait utiliser ce qu'on appelle "matcher libraries" et mettre en œuvre des assertions personnalisées pour rendre nos tests plus lisible et maintenable.
Pour la démonstration, nous allons considérer la tâche suivante : imaginons que nous devons développer une classe pour le module de reporting de notre application qui, étant donné deux dates ("begin" et "end"), fournit toutes les heures ces dates. Les intervalles sont ensuite utilisés pour extraire les données nécessaires à partir de la base de données et pour les présenter à l'utilisateur final sous forme de beaux graphiques.
Approche standard
On va commencer par une approche "standard" pour écrire nos assertions. On utilise JUnit pour cet exemple, mais on pourrait aussi utiliser, par exemple, TestNG. On utilisera des méthodes d'assertions comme assertTrue(), assertNotNull() ou assertSame().
Ci-dessous, un des nombreux tests appartenant à la classe HourRangeTest est présenté. C'est relativement simple. Tout d'abord, il appelle la méthode getRanges() pour retourner toutes les plages d'une heure entre deux dates, le même jour. Ensuite ils vérifent que les plages retournées sont exactement comme elles devraient être.
private final static SimpleDateFormat SDF
= new SimpleDateFormat("yyyy-MM-dd HH:mm");
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(3, ranges.size());
assertEquals(SDF.parse("2012-07-23 12:00").getTime(), ranges.get(0).getStart());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(0).getEnd());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(1).getStart());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(1).getEnd());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(2).getStart());
assertEquals(SDF.parse("2012-07-23 15:00").getTime(), ranges.get(2).getEnd());
}
C'est certainement un test valide, mais il met en avant un sérieux inconvénient. Il y a beaucoup de redondance dans la partie //Then. Il paraît évident que ces fragments de code ont été créés par copier-coller - ce qui, d'après mon expérience - conduit inévitablement à des erreurs. De plus, si on devait écrire plus de tests de ce genre (et on devra sûrement écrire plus de tests pour vérifier la classe HourlyRange !), les mêmes déclarations d'assertions seraient répétées maintes et maintes fois dans chacun d'eux.
La lisibilité du test est diminuée par le nombre excessif d'assertions, mais aussi leur complexité. Il y a beaucoup de contraintes de bas niveau, ce qui n'aide pas à saisir le scénario de base des tests. Comme on le sait tous, le code est beaucoup plus souvent lu qu'écrit (je crois que cela vaut également pour les tests), aussi la lisibilité est quelque chose qu'on doit chercher à améliorer.
Avant de réécrire le test, je tiens également à souligner une autre faiblesse, cette fois liée au message d'erreur qu'on obtient en cas d'échec. Par exemple, si l'un des intervalles retournés par la méthode getRanges() s'avérait avoir un temps différent de celui attendu, tout ce qu'on recevrait comme message serait ceci :
org.junit.ComparisonFailure:
Expected :1343044800000
Actual :1343041200000
Ce message n'est pas vraiment clair est pourrait être amélioré.
Méthodes privées
Que peut-on faire alors exactement ? Eh bien, la chose la plus élémentaire serait d'extraire l'assertion dans une méthode privée :
private void assertThatRangeExists(List<Range> ranges, int rangeNb,
String start, String stop) throws ParseException {
assertEquals(ranges.get(rangeNb).getStart(), SDF.parse(start).getTime());
assertEquals(ranges.get(rangeNb).getEnd(), SDF.parse(stop).getTime());
}
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(ranges.size(), 3);
assertThatRangeExists(ranges, 0, "2012-07-23 12:00", "2012-07-23 13:00");
assertThatRangeExists(ranges, 1, "2012-07-23 13:00", "2012-07-23 14:00");
assertThatRangeExists(ranges, 2, "2012-07-23 14:00", "2012-07-23 15:00");
}
Est-ce mieux maintenant ? Je dirais que oui. La redondance du code a été réduite et la lisibilité améliorée. C'est vraiment bon.
Cette approche présente un autre avantage, on est maintenant dans une position bien plus confortable pour améliorer le message d'erreur à afficher en cas d'erreur. Le code d'assertion est extrait d'une méthode, afin que nous puissions améliorer nos assertions avec des messages d'erreur plus facilement lisible.
La réutilisation de ces méthodes d'assertions pourrait être facilitée en les mettant dans une classe de base, que nos classes de tests pourraient étendre.
Pourtant, je pense qu'on pourrait faire encore mieux que cela : utiliser des méthodes privées présente quelques inconvénients, qui deviennent de plus en plus évidents au fur et à mesure que le code évolue et que ces méthodes privées sont appelées dans de nombreuses méthodes de tests :
- il est difficile de trouver des noms de méthodes d'assertions qui indiquent clairement ce qu'elles testent ;
- les besoins augmentent, ces méthodes ont tendance à recevoir des paramètres supplémentaires, nécessaires pour des contrôles plus sophistiqués (assertThatRangeExists() prend déjà 4 paramètres, ce qui est beaucoup trop!) ;
- il arrive parfois que, pour être réutilisées dans de nombreux tests, certaines logiques plus complexes doivent être introduites dans ces méthodes (généralement sous la forme booléens qui les font vérifier - ou ignorer - certains cas particuliers).
Tout cela signifie que, sur long terme, on est susceptible de rencontrer des problèmes de lisibilité et de maintenabilité des tests avec des méthodes d'assertions privées. Penchons-nous sur une autre solution qui ne présenterait pas ces inconvénients.
Les librairies Matcher
Avant de poursuivre, nous allons découvrir de nouveaux outils. Comme décrit précédemment, les assertions fournies par JUnit ou TestNG ne sont pas assez souples. Dans le monde Java, il y a au moins deux librairies open-source qui répondent à nos exigences: AssertJ (un fork du projet FEST Fluent Assertions) et Hamcrest. Je préfère la première, mais c'est une question de goût. Les deux sont très puissantes, et elles permettent de réaliser des choses similaires. La raison principale pour laquelle je préfère AssertJ à Hamcrest est que l'API d'AssertJ - basée sur des interfaces fluides - est parfaitement soutenue par les IDEs.
L'intégration d'AssertJ à JUnit ou TestNG est simple. Tout ce dont on a besoin de faire est d'ajouter les dépendances nécessaires, cesser d'utiliser les assertions fournies par défaut par le framework de test, et de commencer à utiliser celles fournies par AssertJ.
AssertJ offre de nombreuses assertions utiles "out-of-the-box". Elles partagent toutes le même "pattern" : elles débutent par la méthode assertThat(), qui est une méthode statique de la classe Assertions. Cette méthode prend l'objet testé comme argument, et "ouvre la voie" pour les vérifications ultérieures. Ensuite viennent les méthodes d'assertions réelles, chacun d'entre elles vérifie différentes propriétés de l'objet testé. Examinons quelques exemples :
assertThat(myDouble).isLessThanOrEqualTo(2.0d);
assertThat(myListOfStrings).contains("a");
assertThat("some text")
.isNotEmpty()
.startsWith("some")
.hasLength(9);
Comme on peut le voir, AssertJ fournit un ensemble beaucoup plus riche d'assertions que JUnit ou TestNG. Qui plus est, on peut les enchaîner - comme le dernier assertThat("some text") de l'exemple. Une chose très pratique, est que l'IDE propose les méthodes disponibles en fonction du type d'objet testé, et propose seulement celles qui sont concernées.
Ainsi, dans le cas d'une variable double par exemple, après avoir tapé assertThat (MyDouble) et avoir pressé CTRL + ESPACE (ou quel que soit le raccourci de l'IDE ), il nous est présenté la liste des méthodes comme isEqualTo (expectedDouble), isNegative () ou isGreaterThan (otherDouble) - toutes ces méthodes étant destinées à la vérification d'une valeur d'un double. Ce qui est en réalité assez cool.
Assertions personnalisées
Avoir un ensemble d'assertions plus puissant fourni par AssertJ ou Hamcrest est bien, mais ce n'est pas vraiment ce qu'on souhaitait dans le cas de notre classe HourRange.
Une autre caractéristique des librairies de matcher, est qu'elles permettent d'écrire nos propres assertions. Ces assertions personnalisées se comportent exactement comme le font les assertions par défaut d'AssertJ - c'est à dire qu'on est en mesure de les enchaîner. Et c'est exactement ce que nous allons faire pour améliorer notre test par la suite.
Nous allons voir comment mettre en œuvre une stratégie d'assertion personnalisée dans quelques instants, mais pour le moment, nous allons examiner le résultat final de ce que nous allons réaliser. Cette fois nous allons utiliser la méthode assertThat() de notre classe RangeAssert.
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
RangeAssert.assertThat(ranges)
.hasSize(3)
.isSortedAscending()
.hasRange("2012-07-23 12:00", "2012-07-23 13:00")
.hasRange("2012-07-23 13:00", "2012-07-23 14:00")
.hasRange("2012-07-23 14:00", "2012-07-23 15:00");
}
Certains des avantages des assertions personnalisées peuvent être observés même dans un petit exemple comme celui ci-dessus. La première chose à noter à propos de ce test est que la partie //then s'est significativement réduite. Il est également parfaitement lisible maintenant.
D'autres avantages apparaissent lorsque l'on est dans le cas d'un code plus conséquent. Lorsque l'on continue d'utiliser nos assertions personnalisées, on remarque que:
- Il est très facile de les réutiliser. Nous ne sommes pas obligés d'utiliser toutes les assertions, mais on peut seulement sélectionner celles qui sont importantes pour un test spécifique ;
- Le DSL (Domain Specific Language) est à notre main, ce qui signifie que pour des scénarios de test spécifiques, on peut l'adapter à notre guise (par exemple, passer des objets Date au lieu de String) facilement. Ce qui est plus important est que ce type de changement n'affecterait pas les autres tests ;
- Une haute lisibilité - il n'y a pas de problème pour trouver le bon nom pour une méthode de vérification, car l'assertion se compose de nombreuses petites assertions, chacune d'entre elles étant concentrées seulement sur un petit échantillon de la vérification.
Par rapport aux méthodes d'assertions privées, le seul inconvénient de l'assertion personnalisée est qu'on doit fournir un travail plus conséquent pour les créer. Regardons le code de notre assertion sur mesure pour juger si cela est réellement une tâche difficile.
Pour créer une assertion personnalisée, on doit étendre la classe AbstractAssert d'AssertJ ou l'une de ses nombreuses sous-classes. Comme indiqué ci-dessous, notre RangeAssert étend la classe ListAssert d'AssertJ. Ce qui a du sens, car on veut que notre assertion personnalisée vérifie le contenu d'une liste de chaînes (liste de Range).
Chaque assertion personnalisée développée avec AssertJ contient du code responsable de la création d'un objet d'assertion et de l'injection de l'objet testé, donc d'autres méthodes peuvent fonctionner avec elles. Comme la liste le montre, le constructeur et la méthode statique assertThat() prennent une liste de Range comme paramètre.
public class RangeAssert extends ListAssert<Range> {
protected RangeAssert(List<Range> ranges) {
super(ranges);
}
public static RangeAssert assertThat(List<Range> ranges) {
return new RangeAssert(ranges);
}
Voyons maintenant le reste de la classe RangeAssert. Les méthodes hasRange() et isSortedAscending() (présentées dans la prochaine liste) sont des exemples typiques de méthodes d'assertions personnalisées. Elles partagent les propriétés suivantes :
- Les deux commencent par un appel à IsNotNull() qui vérifie si l'objet testé n'est pas nul. Cela garantit que la vérification n'échouera pas en renvoyant le message NullPointerException (cette étape n'est pas nécessaire mais recommandée) ;
- Elles retournent "this" (qui est un objet de la classe de l'assertion personnalisée - la classe RangeAssert, dans notre cas). Ceci permet aux méthodes d'être liées l'une à l'autre ;
- La vérification est effectuée en utilisant des assertions fournies par la classe Assertions d'AssertJ (partie du framework AssertJ) ;
-
Les deux méthodes utilisent un objet «réel» (fourni par la superclasse ListAssert), qui tient une liste des Ranges (liste de Range) en cours de vérification.
private final static SimpleDateFormat SDF
= new SimpleDateFormat("yyyy-MM-dd HH:mm");
public RangeAssert isSortedAscending() {
isNotNull();
long start = 0;
for (int i = 0; i < actual.size(); i++) {
Assertions.assertThat(start)
.isLessThan(actual.get(i).getStart());
start = actual.get(i).getStart();
}
return this;
}
public RangeAssert hasRange(String from, String to) throws ParseException {
isNotNull();
Long dateFrom = SDF.parse(from).getTime();
Long dateTo = SDF.parse(to).getTime();
boolean found = false;
for (Range range : actual) {
if (range.getStart() == dateFrom && range.getEnd() == dateTo) {
found = true;
}
}
Assertions
.assertThat(found)
.isTrue();
return this;
}}
Et que dire du message d'erreur ? AssertJ nous permet de l'ajouter assez facilement. Dans les cas simples, comme une comparaison des valeurs, il suffit souvent d'utiliser la méthode de as(), comme ceci :
Assertions
.assertThat(actual.size())
.as("number of ranges")
.isEqualTo(expectedSize);
Comme on peut le voir, as() est juste une autre méthode fournie par le framework AssertJ. Maintenant, quand le test échoue, il affiche le message suivant afin qu'on sache immédiatement ce qui est faux :
org.junit.ComparisonFailure: [number of ranges]
Expected :4
Actual :3
Parfois, nous avons besoin de plus que le nom de l'objet testé pour comprendre ce qui se passe. Prenons la méthode hasRange(). Ce serait vraiment bien si on pouvait afficher tout les ranges en cas d'échec. Cela peut être fait en utilisant la méthode overridingErrorMessage(), comme ceci :
public RangeAssert hasRange(String from, String to) throws ParseException {
...
String errMsg = String.format("ranges\n%s\ndo not contain %s-%s",
actual ,from, to);
...
Assertions.assertThat(found)
.overridingErrorMessage(errMsg)
.isTrue();
...
}
Maintenant, dans le cas d'un échec, on aurait un message d'erreur très détaillé. Son contenu dépendra de la méthode toString() de la classe Range. Par exemple, il pourrait ressembler à ceci :
HourlyRange{Mon Jul 23 12:00:00 CEST 2012 to Mon Jul 23 13:00:00 CEST 2012},
HourlyRange{Mon Jul 23 13:00:00 CEST 2012 to Mon Jul 23 14:00:00 CEST 2012},
HourlyRange{Mon Jul 23 14:00:00 CEST 2012 to Mon Jul 23 15:00:00 CEST 2012}]
do not contain 2012-07-23 16:00-2012-07-23 14:00
Conclusions
Dans cet article, on a parlé d'un certain nombre de manières d'écrire des assertions. On a commencé avec la manière «traditionnelle», basée sur les assertions fournies par des frameworks de test. Cela est suffisant dans de nombreux cas, mais comme nous l'avons vu, il manque parfois la souplesse nécessaire pour exprimer le scénario du test. Puis on a un peu amélioré les choses par l'introduction des méthodes d'assertions privées, mais cela s'est également révélé ne pas être une solution idéale. Dans notre dernière tentative, on a introduit des assertions personnalisées écrites avec AssertJ, et obtenu un code de test beaucoup plus lisible et maintenable.
Si je devais vous donner quelques conseils au sujet des assertions, je vous suggérerais ce qui suit : vous pourrez beaucoup améliorer votre code de test si vous arrêtez d'utiliser des assertions fournies par des frameworks de test (par exemple, JUnit ou TestNG) et passer à celles fournies par les librairies de matcher (par exemple AssertJ ou Hamcrest). Cela vous permettra d'utiliser une vaste gamme d'assertions très lisibles et d'éliminer la nécessité d'utiliser des instructions compliquées (par exemple en boucle sur les collections) dans les parties //then de vos tests.
Même si le coût de mise en place des assertions personnalisées est très faible, il n'est pas nécessaire de les introduire simplement parce que vous le pouvez. Utilisez-les lorsque la lisibilité et/ou la maintenance de votre code de test en dépendent. D'après mon expérience, je vous encourage à utiliser les assertions personnalisées dans les cas suivants :
-
quand vous trouvez que le scénario de test est complexe à mettre en place en utilisant les assertions fournies par les librairies de matcher ;
-
pour remplacer les méthodes d'assertions privées.
D'après mon expérience, vous aurez rarement besoin d'assertions personnalisées avec les tests unitaires. Cependant, je suis sûr que vous les trouverez indispensables dans le cas d'intégration et des tests de bout-à-bout(fonctionnels). Elles permettent à nos tests d'utiliser le langage du domaine (plutôt que celui de l'implémentation), et elles encapsulent également les détails techniques, ce qui rend les mises à jour de nos tests beaucoup plus simples.
À propos de l'auteur
Tomek Kaczanowski travaille comme Développeur Java pour CodeWise (Krakow, Pologne). Il est spécialisé dans la qualité du code, les tests et l'automatisation et est passionné par les tests du TDD (Test Driven Development), partisan de l'open-source et adorateur de l'agilité. Très impliqué dans le partage de ses connaissances, il est aussi Auteur de livre, blogger et conférencier.