Publié par

Il y a 11 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 juste junit.framework.AssertionError
  • assertEquals affiche la valeur attendue et la valeur actuelle (en utilisant la méthode toString()).
  • 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"
    
  • Quid du cas où la méthode 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 static  Matcher 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

Publié par

Commentaire

3 réponses pour " Simplifier les assertions JUnit et améliorer vos tests "

  1. Publié par , Il y a 11 ans

    Bon article et tres utile. Je ne connaissait pas.
    Il agreable de revoir du Java5, ca fait tellement longtemps :’-)

    Julien G

  2. Publié par , Il y a 10 ans

    Excellent comme article .Je pense que ‘Oalia’ va le regretter ;=)

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.