Il y a 15 ans -
Temps de lecture 9 minutes
Simplifier les assertions JUnit et améliorer vos tests
Nombreux sont ceux d’entre nous qui ont déjà utilisé JUnit pour écrire des tests.
Quel est celui qui n’a pas été déçu par les limitations inhérentes aux différentes méthodes assertXXX()
?
Quel est celui qui n’a pas utilisé des librairies supplémentaires (JUnit-addons, Unitils, …) contenant des méthodes utilitaires du type assertContains
, … ?
Quel est celui qui n’a pas écrit sa propre méthode utilitaire ?
Si vous vous sentez concernés, lisez la suite de cet article qui aborde :
- Les limites et défauts de JUnit
- Les solutions classiques
- Une solution magique
Les limites et défauts de JUnit
1 – JUnit ne contient que 6 méthodes (assertEquals
, assertFalse
/ assertTrue
, assertSame
, assertNull
/ assertNotNull
) venant toutes de la classe Assert. On en vient très vite à utiliser principalement assertTrue
.
2 – Les signatures des méthodes sont curieuses sur deux points :
- L’ordre de lecture est illogique. En effet, pourquoi le premier paramètre est-il la valeur attendue et non la valeur actuelle ? Pourquoi le verbe précède-t-il le complément qui lui même précède le sujet ? Il serait préférable de lire « assert x égale 3 » au lieu de « assert égale 3 x ».
- Chaque méthode est surchargée pour accepter un paramètre supplémentaire (le message) qui est placé en premier et non à la fin ! Il est amusant de voir que Ken Beck, auteur de JUnit, propose un solution différente dans son livre Implementation Patterns paru en novembre dernier « Optional Parameter » pattern) .
3 – En l’absence de message explicite le message d’erreur affiché est très laconique voire inexistant :
assertTrue
renvoie justejunit.framework.AssertionError
assertEquals
affiche la valeur attendue et la valeur actuelle (en utilisant la méthodetoString()
).
Exemple 1 : pour un objet String
junit.framework.ComparisonFailure: expected: "xyzt" but "abc"
Exemple 2 : pour un objet java.util.Date
public void testShowAssertEqualsDefaultMessageForDate() { Date expected = new Date(); Date actual = new Date(expected.getTime() + 1000L*60*60); assertEquals(expected, actual); }
Résultat :
junit.framework.AssertionFailedError: expected: "Thu Mar 20 13:49:50 CET 2008" but was: "Thu Mar 20 14:49:50 CET 2008"
toString()
ne renvoie pas une description suffisante pour expliquer la différence ? Dans le cas précédent, si la différence entre les 2 dates est inférieure à 1 seconde, on ne la verrait pas.4 – Il n’y a aucun mécanisme permettant d’assurer la cohérence entre le message et l’assertion : modifier cette dernière implique de modifier le message.
5 – Le message est une description textuelle de l’assertion d’où une forme de redondance/duplication.
6 – Chaque appel à la méthode assertTrue
ne doit contenir qu’une seule assertion : autrement, on ne sait plus laquelle est fausse.
7 – La seule possibilité de combiner des assertions est implicite et utilise seulement l’opérateur AND : 1 assertion = 1 appel de méthode.
Exemple complet :
import junit.framework.TestCase; public class JUnit3LimitsSample extends TestCase { String s; public void testNotNull() { s = null; assertNotNull(s); } public void testForMultipleEquals() { s = "xyzt"; assertTrue(s.equals("abc") || s.equals("def") || s.equals("xyz")); } }
Résultat
JUnit3LimitsSample samples.JUnit3LimitsSample testNotNull(samples.JUnit3LimitsSample) junit.framework.AssertionFailedError at junit.framework.Assert.fail(Assert.java:47) at junit.framework.Assert.assertTrue(Assert.java:20) at junit.framework.Assert.assertNotNull(Assert.java:217) at junit.framework.Assert.assertNotNull(Assert.java:210) at samples.JUnit3LimitsSample.testNotNull(JUnit3LimitsSample.java:11) ...... at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:196) testForMultipleEquals(samples.JUnit3LimitsSample) junit.framework.AssertionFailedError at junit.framework.Assert.fail(Assert.java:47) at junit.framework.Assert.assertTrue(Assert.java:20) at junit.framework.Assert.assertTrue(Assert.java:27) at samples.JUnit3LimitsSample.testForMultipleEquals(JUnit3LimitsSample.java:15) ...... at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:196)
Le problème 2 ne trouvera pas de solution étant donné la base installée.
Les problèmes 1, 6 et 7 sont partiellement résolus par les solutions classiques et mieux résolus dans la solution magique.
Les problèmes 4 et 5 ne sont résolus que dans la solution magique.
Les solutions classiques
Librairies open-source (ou pas) : JUnit-addons, Unitils, …
JUnit-addons propose plusieurs classes utilitaires contenant chacune des méthode assertXXX
dédié à un type de classe ou d’interface :
ArrayAssert
ComparableAssert
FileAssert
ListAssert
NamingAssert
ObjectAssert
Mais, même avec Java 5 et des imports statiques, le code reste pénible à lire (cf. exemple ci-dessous). En outre, il n’est pas possible d’importer statiquement deux méthodes ayant la même signature !
On mélange donc import classique et import statique !
import java.io.File; import java.util.ArrayList; import junit.framework.TestCase; // JUnit addon 1.4 import junitx.framework.ArrayAssert; import junitx.framework.ComparableAssert. import junitx.framework.FileAssert; import junitx.framework.ListAssert; import junitx.framework.ObjectAssert; import static junitx.framework.ComparableAssert.assertGreater; import static junitx.framework.ComparableAssert.assertLesser; import static junitx.framework.ComparableAssert.assertNotGreater; import static junitx.framework.ComparableAssert.assertNotLesser; import static junitx.framework.ObjectAssert.assertInstanceOf; import static junitx.framework.ObjectAssert.assertNotInstanceOf; public class JUnitAddonSampleTest extends TestCase { public void testSample() { // junitx.framework.ArrayAssert.assertXXX(YYY[],ZZZ[]) ArrayAssert.assertEquals(new String[] {"expected1", "expected2"}, new String[]{"actual1", "actual2"}); ArrayAssert.assertEquivalenceArrays(new String[]{"expected1", "expected2"}, new String[]{"actual1", "actual2"}); // junitx.framework.ComparableAssert.assertXXX(Comparable expected, Comparable actual) ComparableAssert.assertEquals(180, 180); assertGreater(125, 180); assertLesser(125, 18); assertNotGreater(125, 18); assertNotLesser(125, 180); // junitx.framework.FileAssert.assertXXX(java.io.File, java.io.File) FileAssert.assertBinaryEquals(new File("expectedFile"), new File("actualFile")); FileAssert.assertEquals(new File("expectedFile"), new File("actualFile")); // junitx.framework.ListAssert.assertXXX(...) ListAssert.assertContains(new ArrayList(), "abc"); ListAssert.assertEquals(new ArrayList(), new ArrayList()); // junitx.framework.ObjectAssert.assertXXX(...) assertInstanceOf(String.class, "abc"); assertNotInstanceOf(Integer.class, "abc"); ObjectAssert.assertNotSame("expected", "actual"); ObjectAssert.assertSame("abc", "abc"); } }
Unitils offre beaucoup plus de fonctionnalités.
Mais, vous aurez beau chercher une librairie, il manquera toujours la méthode qu’il vous faut. On en vient donc à utiliser une librairie « maison » !
Librairie « maison »
Pour résoudre l’exemple du paragraphe 2, on peut écrire une méthode assertNotNullAndTrue(Object o, boolean condition)
:
assertNotNullAndTrue(s, s.equals("abc") || s.equals("def") || s.equals("xyz"));
où plus précis une méthode assertIn(String s, String[], boolean canBeNull)
:
assertIn(s, new String[] { "abc", "def", "xyz" }, false);
En Java 5, on peut même utiliser vararg mais faut mettre le paramètre à la fin ce qui perturbe la logique de lecture :
// Définition assertIn(boolean canBeNull, String s, String...) // Utilisation assertIn(false, s, "abc", "def", "xyz"); // vararg Java 5 must be last parameter !
Limites des solutions classiques
Au regard de ce qui précède, on voit que la combinaison d’assertions en une seule méthode induit une complexité qui devient très vite exponentielle. Qui dit design complexe dit code smell. Qui dit code smell dit refactoring (et peut être, si besoin, design pattern). Le problème ici est l’apparition d’un langage implicite autour du concept de prédicat. La solution est décrite dans le livre Refactoring to patterns de Joshua Kerievsky. Elle porte le doux nom de Replace implicit language with Interpreter.
La solution magique
Cette solution a été implémentée par Joe Walnes. Dans son article Flexible JUnit assertions with assertThat(), il propose une nouvelle approche d’assertion. Il a eu l’idée d’utiliser la librairie de contraintes de jMock1. Une contrainte jMock permet d’exprimer précisément une attente quant à la valeur d’un objet.
Joe Walnes a donc créé une méthode assertThat(Object value, Constraint constraint)
.
Le premier paramètre est évidemment l’objet à tester. Le second est une instance d’une classe implémentant Constraint
. Cette interface provient initialement de jMock1. Ce projet fourni de nombreuses implémentations : eq(....)
, contains(....)
, …
Il existe une autre version de la méthode qui prend en paramètre le message d’erreur. Elle est peu utilisée car la violation d’une contrainte s’accompagne d’un message suffisamment explicite.
L’interface Constraint
ne contient que deux méthodes et est donc simple à implémenter :
// public boolean eval(Object object) // public StringBuffer describeTo(StringBuffer buffer);
Exemple de code appelant :
protected void assertThat(Object something, Constraint constraint) { if (!constraint.eval(something)) { StringBuffer message = new StringBuffer("\nExpected: "); constraint.describeTo(message); message.append("\nbut got : ").append(something).append('\n'); fail(message.toString()); } }
Cette méthode est suffisamment générale pour répondre à tous les cas !
Nous n’allons pas montrer d’exemple d’implémentation de l’interface Constraint
pour deux raisons. D’une part, la librairie de contraintes jMock a été déplacé dans un nouveau projet (Hamcrest) et refactorée. D’autre part, dans JUnit 4.4, la méthode assertThat
a une signature différente :
assertThat(Object value, org.hamcrest.Matcher matcher)
.
JUnit 4.4 incorpore en standard certains matchers dans la classe org.hamcrest.CoreMatchers
:
allOf(Iterable>) allOf(Matcher...) any(Class ) anyOf(Iterable >) anyOf(Matcher...) anything() anything(String) describedAs(String, Matcher , Object...) equalTo(T) instanceOf(Class) is(Class) is(Matcher ) is(T) not(Matcher ) not(T) notNullValue() notNullValue(Class ) nullValue() nullValue(Class ) sameInstance(T)
Voici un exemple réel qui fonctionne sous JUnit 4.4 dans Eclipse 3.3 :
import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.hamcrest.Matcher; public class CustomMatchers { public staticMatcher in(final T... values) { return new BaseMatcher () { public boolean matches(Object object) { // values can never be null ! // when passing a null value, the JVM creates a 1-length array containing a null if (values.length == 0) { throw new IllegalStateException("in(...) matcher expect a non-empty values argument"); } if (object == null) { for(T value : values) { if (value == null) { return true; } } return false; } for(T value : values) { if (object.equals(value)) { return true; } } return false; } public void describeTo(Description desc) { desc.appendText(" in ").appendValue(values); } }; } }
On peut ainsi réécrire notre exemple initial comme ceci :
import org.junit.Test; import static org.junit.Assert.assertThat; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.notNullValue; public class JUnitAssertThatSampleTest { @Test public void testAssertThat() { assertThat("abc", allOf(notNullValue(), CustomMatchers.in("def", "ghi"))); } }
Résultat :
testAssertThat(JUnitAssertThatSampleTest) java.lang.AssertionError: Expected: (not null and in ["def", "ghi"]) got: "abc" ......
Magnifique, n’est-ce pas !
Conclusion
Ainsi, en utilisant assertThat()
on répond aux problèmes suivants décrits au début de l’article :
- En l’absence de message explicite le message d’erreur affiché est très laconique voire inexistant.
- Il n’y a aucun mécanisme permettant d’assurer la cohérence entre le message et l’assertion : modifier cette dernière implique de modifier le message.
- Le message est une description textuelle de l’assertion d’où une forme de redondance/duplication.
- La seule possibilité de combiner des assertions est implicite et utilise seulement l’opérateur AND : 1 assertion = 1 appel de méthode.
Maintenant, il n’est plus nécessaire de passer un message en paramètre car les matchers construisent et renvoient un message explicite.
En outre, on peut combiner les matchers comme on veut !
Ressources
- JUnit-addons : http://junit-addons.sourceforge.net
- Unitils : http://www.unitils.org
- JUnit 4.4 release note : http://junit.sourceforge.net/doc/ReleaseNotes4.4.html
- Article de Joe Walnes : http://joe.truemesh.com/blog/000511.html
- Hamcrest : http://code.google.com/p/hamcrest/
Commentaire
3 réponses pour " Simplifier les assertions JUnit et améliorer vos tests "
Published by David Andrianavalontsalama , Il y a 15 ans
Excellent. Je ne connaissais pas. Merci.
Published by Julien G , Il y a 15 ans
Bon article et tres utile. Je ne connaissait pas.
Il agreable de revoir du Java5, ca fait tellement longtemps :’-)
Julien G
Published by ahd , Il y a 13 ans
Excellent comme article .Je pense que ‘Oalia’ va le regretter ;=)