Craftsman Recipes – Refactorez vos tests avec GenericAssert de FEST-Assert

Écrire des tests unitaires, c’est bien. Écrire des tests unitaires maintenables et lisibles, c’est encore mieux. Mais quand ils échouent, combien de temps vous faut-il pour trouver la source du problème ?

Pour pallier à quelques manques de JUnit, FEST-Assert est une bibliothèque Java puissante. Elle permet de chaîner les assertions sur un même objet ou encore de définir des messages d’erreur très personnalisés pour les cas d’échec. Elle propose des assertions spécifiques pour de nombreux types Java, et même bientôt pour Guava et Joda Time !
Mais si vous avez des classes de votre projet qui reviennent de manière récurrente dans vos tests, il est alors temps de créer vos propres assertions FEST !

Voyons cela en pratique avec ce nouvel article de la série Craftsman Recipes. À noter que l’intégralité du code présenté se trouve sur ce repository Github avec un tag pour chaque étape.

Classes métiers

J’utiliserai un cas métier simple. J’ai une classe Amount qui contient deux attributs, currency et value.

public class Amount {

    private final Currency currency;
    private final BigDecimal value;

    public Amount(BigDecimal value, Currency currency) {
        this.currency = currency;
        this.value = value;
    }

    public Amount add(BigDecimal value) {
        return new Amount(this.value.add(value), this.currency);
    }

    public Currency getCurrency() {
        return currency;
    }

    public BigDecimal getValue() {
        return value;
    }
}

J’ai ensuite une classe AmountAccumulator qui me permet d’agréger des objets de type Amount. Les règles métiers sont simples : 

  • Si l’accumulateur est vide, alors il n’a pas de valeur.
  • Si on ajoute des montants de devises différentes, il n’y a pas de valeur.
  • Dans les autres cas, le montant est égal à la somme des montants accumulés.

J’utiliserai ici la classe Optional de Guava pour signifier l’absence de valeur.

Un premier test simple

Bien sûr, nous écrivons les tests d’abord ! Ce test permet de vérifier la première règle métier.

public class AmountAccumulatorTest {

    @Test
    public void should_get_absent_for_empty_accumulator() {
        Optional<Amount> actualAmount = new AmountAccumulator().getAmount();

        assertThat(actualAmount.isPresent()).isFalse();
    }
}

Pour ceux qui ont l’habitude d’utiliser JUnit, vous remarquerez que l’ordre de comparaison est inversé. On commence par l’objet obtenu pour le comparer à l’objet attendu (false dans le cas présent). Pour l’instant, il n’y a rien de folichon ici.

Le code de production qui permet de faire passer ce test est alors le suivant :

public class AmountAccumulator {
    public Optional<Amount> getAmount(){
        return absent();
    }
}

 

Un second test pour la seconde règle

    public static final BigDecimal _5 = new BigDecimal("5");
    public static final BigDecimal _10 = new BigDecimal("10");
    public static final Currency USD = Currency.getInstance("USD");

 @Test
    public void should_get_10_USD_when_accumulate_5_USD_2_times() {
        Amount _5_USD = new Amount(_5, USD);

        Optional<Amount> actualAmount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_5_USD)
                .getAmount();

        assertThat(actualAmount.isPresent()).isTrue();
        assertThat(actualAmount.get().getCurrency()).isEqualTo(USD);
        assertThat(actualAmount.get().getValue()).isEqualTo(_10);
    }

Ce test permet de tester la présence d’un montant et ses valeurs. Comme j’ai ajouté un nouveau test et que nous sommes en TDD, le test doit maintenant échouer. Le message d’erreur généré par l’API est alors le suivant :

org.junit.ComparisonFailure: 

Expected :false
Actual   :true

Le message reflète effectivement ce que j’ai modifié, ce qui est bien mais pas top. En lisant un rapport de tests depuis Jenkins par exemple, est-on capable de comprendre rapidement la source du problème… sans regarder le code ? Là, c’est un peu plus dur. Il n’y a rien de plus frustrant que de devoir comprendre des tests écrits par quelqu’un d’autre lors de l’échec d’une intégration continue. Pensons à notre prochain et améliorons un peu le test, il passera certainement une meilleure journée et vous remerciera avec un café la prochaine fois (voire même un croissant !).

Je complète le code de production :

public class AmountAccumulator {
    
 private final Optional<Amount> amount;
    
 public AmountAccumulator() {
        this.amount = absent();
    }
    
 public AmountAccumulator(Amount accumulate) {
        this.amount = of(accumulate);
    }
    
 public AmountAccumulator accumulate(final Amount amount) {
        final AmountAccumulator nextState;
        
  if (!this.amount.isPresent()) {
            nextState = new AmountAccumulator(amount);
        } else {
            Amount currentState = this.amount.get();
            nextState = new AmountAccumulator(currentState.add(amount.getValue()));
        }

        return nextState;
    }

    public Optional<Amount> getAmount() {
        return amount;
    }
}

 

Les messages d’erreur personnalisés

L’API de FEST-assert permet de créer des messages d’erreur personnalisés lorsqu’un test échoue. Voyons comment cela fonctionne :

    public static final BigDecimal _5 = new BigDecimal("5");
    public static final BigDecimal _10 = new BigDecimal("10");
    public static final Currency USD = Currency.getInstance("USD");

    private static final String ABSENCE_ERROR = "The amount shouldn't exist";
    private static final String PRESENCE_ERROR = "The amount should exist";
    private static final String CURRENCY_DESC = "Currency of the amount";
    private static final String VALUE_DESC = "Value of the amount";

    @Test
    public void should_get_absent_for_empty_accumulator() {
        Optional<Amount> actualAmount = new AmountAccumulator().getAmount();

        assertThat(actualAmount.isPresent()).overridingErrorMessage(ABSENCE_ERROR).isFalse();
    }

    @Test
    public void should_get_10_USD_when_accumulate_5_USD_2_times() {
        Amount _5_USD = new Amount(_5, USD);

        Optional<Amount> actualAmount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_5_USD)
                .getAmount();

        //PRESENCE OF AMOUNT
        assertThat(actualAmount.isPresent()).overridingErrorMessage(PRESENCE_ERROR).isTrue();

        //CURRENCY
        Currency actualCurrency = actualAmount.get().getCurrency();
        Currency expectedCurrency = USD;
        assertThat(actualCurrency).describedAs(CURRENCY_DESC).isEqualTo(expectedCurrency);

        //VALUE
        BigDecimal actualValue = actualAmount.get().getValue();
        BigDecimal expectedAmount = _10;
        assertThat(actualValue).describedAs(VALUE_DESC).isEqualTo(expectedAmount);
    }

Tout se fait grâce à l’appel des méthodes describedAs ou overridingErrorMessage juste après l’appel à assertThat(xx). Cela permet d’avoir un message d’erreur propre.

Si j’introduis la même erreur dans le test, voici dorénavant le message d’erreur :

// message d'erreur avec overridingErrorMessage
java.lang.AssertionError: The amount shouldn't exists
 
// ou describedAs
org.junit.ComparisonFailure: [Value of the amount] 
Expected :10
Actual   :15

Avec ce message plus le nom du tests should_get_absent_for_empty_accumulator, il est déjà plus facile de comprendre la régression. Mais le test devient de plus en plus verbeux, il est temps de refactorer tout cela.

GenericAssert

Nous allons regrouper tout ce code de test dans une classe qui étend "org.fest.assertions.GenericAssert". Voici ce que cela donne dans notre cas:

    private static final String NULL_ERROR = "The amount should not be null";
    private static final String ABSENCE_ERROR = "The amount should exist";
    private static final String PRESENCE_ERROR = "The amount should exist";
    private static final String CURRENCY_DESC = "Currency of the amount";
    private static final String VALUE_DESC = "Value of the amount";

    OptionalAmountAsserter(Optional<Amount> actual) {
        super(OptionalAmountAsserter.class, actual);
    }

    public static OptionalAmountAsserter assertThat(Optional<Amount> actual) {
        return new OptionalAmountAsserter(actual);
    }

    public OptionalAmountAsserter isAbsent() {
        Assertions.assertThat(actual).overridingErrorMessage(NULL_ERROR).isNotNull();
        Assertions.assertThat(actual.isPresent()).overridingErrorMessage(ABSENCE_ERROR).isFalse();

        return this;
    }

    public OptionalAmountAsserter isPresent() {
        Assertions.assertThat(actual).overridingErrorMessage(NULL_ERROR).isNotNull();
        Assertions.assertThat(actual.isPresent()).overridingErrorMessage(PRESENCE_ERROR).isTrue();

        return this;
    }

    public OptionalAmountAsserter hasCurrency(Currency expected) {
        Assertions.assertThat(actual).overridingErrorMessage(NULL_ERROR).isNotNull();
        isPresent();
        Currency actualCurrency = actual.get().getCurrency();

        Assertions.assertThat(actualCurrency).describedAs(CURRENCY_DESC).isEqualTo(expected);

        return this;
    }

    public OptionalAmountAsserter hasValue(BigDecimal expected) {
        Assertions.assertThat(actual).overridingErrorMessage(NULL_ERROR).isNotNull();
        isPresent();
        BigDecimal actualValue = actual.get().getValue();

        Assertions.assertThat(actualValue).describedAs(VALUE_DESC).isEqualTo(expected);

        return this;
    }

Pour respecter la convention de l’API, le constructeur n’est pas public. Il faut passer par une static factory-method nommée par convention assertThat().

Le code des tests devient plus léger et plus lisible :

    public static final BigDecimal _5 = new BigDecimal("5");
    public static final BigDecimal _10 = new BigDecimal("10");
    public static final Currency USD = Currency.getInstance("USD");

    @Test
    public void should_get_absent_for_empty_accumulator() {
        Optional<Amount> actualAmount = new AmountAccumulator().getAmount();
        OptionalAmountAsserter.assertThat(actualAmount).isAbsent();
    }

    @Test
    public void should_get_10_USD_when_accumulate_5_USD_2_times() {
        Amount _5_USD = new Amount(_5, USD);
        Optional<Amount> actualAmount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_5_USD)
                .getAmount();

        //PRESENCE OF AMOUNT
        OptionalAmountAsserter.assertThat(actualAmount)
                .hasCurrency(USD)
                .hasValue(_10);
    }

Pour utiliser l’asserter, il suffit d’utiliser la factory méthode avec le code : OptionalAmountAsserter.assertThat(actualAmount). Ensuite sur cet objet, je peux appeler les méthodes suivantes : 

  • isPresent()
  • isAbsent()
  • hasCurrency(Currency expected)
  • hasValue(BigDecimal expected)

L’asserter se charge ensuite de faire les tests nécessaires et de gérer les messages d’erreur pour vous. L’air de rien, nous venons de créer une libraire de test spécifique à notre domaine, c’est à dire un DSL. Les avantages sont d’avoir des tests lisibles, maintenables avec des composants réutilisables. Si vous ne souhaitez pas coder en anglais, libre à vous de choisir la langue de vos méthodes de tests.

Si nous souhaitons ajouter un test, cela devient trivial :

    @Test
    public void should_get_absent_for_accumulation_of_different_currencies() {
        Amount _5_USD = new Amount(_5, USD);
        Amount _10_EUR = new Amount(_10, EUR);

        Optional<Amount> amount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_10_EUR)
                .getAmount();

        OptionalAmountAsserter.assertThat(amount).isAbsent();
    }

Cela nous permet de coder le dernier cas d’utilisation de la classe AmountAccumulator :

public class AmountAccumulator {
    
 private final Optional<Amount> amount;
    
 public AmountAccumulator() {
        this.amount = absent();
    }
    
 public AmountAccumulator(Amount accumulate) {
        this.amount = of(accumulate);
    }
    
 public AmountAccumulator accumulate(final Amount amount) {
        final AmountAccumulator nextState;
    
     if (!this.amount.isPresent()) {
            nextState = new AmountAccumulator(amount);
        } else {
    
         final Amount currentState = this.amount.get();
            if (currentState.getCurrency().equals(amount.getCurrency())) {
                nextState = new AmountAccumulator(currentState.add(amount.getValue()));
            } else {
                nextState = new AmountAccumulator();
            }
        }
    
     return nextState;
    }
    public Optional<Amount> getAmount() {
        return amount;
    }
}

Un dernier refactoring

Notre code de test est encore pollué par le nom des asserters à utiliser. La solution ? Créer sa propre classe Assertions qui étend celle de FEST avec nos asserters.

Voici le code de cette classe : 

package fr.xebia.blog;

import com.google.common.base.Optional;

public class Assertions extends org.fest.assertions.Assertions{
    public static OptionalAmountAsserter assertThat(Optional<Amount> actual) {
        return new OptionalAmountAsserter(actual);
    }
}

Ce refactoring permet une utilisation transparente de tous les asserters, ceux de base de FEST et les notres.

Avec les import static, notre code de test ressemble à cela :

 @Test
    public void should_get_absent_for_empty_accumulator() {
        Optional<Amount> actualAmount = new AmountAccumulator().getAmount();
        
  assertThat(actualAmount).isAbsent();
    }

    @Test
    public void should_get_10_USD_when_accumulate_5_USD_2_times() {
        Amount _5_USD = new Amount(_5, USD);
        
  Optional<Amount> actualAmount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_5_USD)
                .getAmount();

        assertThat(actualAmount)
                .hasCurrency(USD)
                .hasValue(_10);
    }

    @Test
    public void should_get_absent_for_accumulation_of_different_currencies() {
        Amount _5_USD = new Amount(_5, USD);
        Amount _10_EUR = new Amount(_10, EUR);
        
  Optional<Amount> actualAmount = new AmountAccumulator()
                .accumulate(_5_USD)
                .accumulate(_10_EUR)
                .getAmount();
    
     assertThat(actualAmount).isAbsent();
    }


Conclusion

L’écriture des GenericAssert peut sembler fastidieuse :

  • Le code devient vite peu lisible à cause des successions de .describedAs(…) (il existe aussi un raccourci : as(…)).
  • La génération "à la main" des messages d’erreur de comparaison est longue.

Mais je pense que la réutilisabilité des composants est un réel avantage. L’ensemble des tests est cohérent et utilise les mêmes API. La construction d’un DSL (Domain Specific Language) permet même de faire du BDD (Behavior Driven Development) avec des fonctionnels en pair-programming. J’ai déjà testé ceci chez mon client actuel, c’est une très bonne expérience. 

De plus, on n’est plus obligé de reposer sur les méthodes hashCode et equals pour comparer deux objets. Cerise sur la gâteau, FEST-Assert fournit une très bonne API pour tester des collections.

J’ai utilisé pour cet article la version 1.4 de l’API. Il faut savoir que la version 2.0 de l’API est en cours de finalisation et disponible sur Github. C’est une version majeure car elle n’assure pas de compatibilité. Une raison de plus de bien factoriser son code, et même pour les tests. 


Billets sur le même thème :

4 commentaires

  • Hello,

    Merci pour l’article, je n’avais jamais vu generic assert. Il faut dire que pour tout connaitre de fest assert, il faut en lire des choses surtout avec la version 2.0 .

    Par ailleurs, j’avais publié un article qui traitais de la lisibilité des tests unitaires. Celui ci utilisait jUnit mais on peut à l’occasion utiliser festAssert pour simplifier le code testé.

    http://java-bien.blogspot.ch/2012/10/le-test-builder.html

    Ce qui me déplait avant tout, dans les tests (des autres :p ), ce n’est pas de ne pas réussir à lire le resultat des tests mais de ne pas réussir a lire son scénario d’éxécution.
    L’approche que j’expose permet de faire les deux. Fest assert lui permet de clarifier – tres bien par ailleurs – les résultats de test.

    Bonne journée.

    @Benjamin_Leroux

  • Salut,

    Bon article ! :)

    Je suis contributeur sur Fest Assert et on partage la même idée : avoir un DSL d’assertions métier pour rendre les tests plus lisible et facile à écrire.

    Fest fournit des assertions pour les types de bases, guava et joda, par contre, comme tu le montres, il faut écrire ses propres classes d’assertions si on veut avoir des assertions métier. Or sur un projet c’est pas de mal boulot au vu du nombre de classes métiers.

    Pour remédier à cela, il existe un générateur d’assertions (avec le plugin maven qui va bien) auquel il suffit d’indiquer les packages où se trouvent les classes métiers et il va générer les classes d’assertions correspondantes.

    Dans ton example, il va générer une class AmountAssert avec les assertions hasCurrency et hasValue (dans le répertoire ./target/generated-test-sources/fest-assertions).

    Le générateur d’assertions se base sur les property des classes métiers par le biais de l’introspection, le projet (et sa doc) est ici :
    https://github.com/joel-costigliola/maven-fest-assertion-generator-plugin

    Pour l’exemple, j’ai forké ton projet mettre en place le générateur d’assertions, comme il nécessite Fest Assert 2.0 j’en ai profité pour l’adapter en conséquence (je t’ai envoyé une pull request si tu veux intégrer cela).

    Mon fork est ici: https://github.com/joel-costigliola/genericassert

    A propos, https://github.com/joel-costigliola/fest-guava-assert et https://github.com/joel-costigliola/fest-joda-time-assert sont dispos en version 1.0 !
    Bon ok c’est un peu light pour l’instant mais c’est un début :)

    Dernier point, même si la version 2.0 n’est pas encore sortie, Fest 2.0M8 est tout à fait utilisable et bien plus riche que Fest 1.4 (ex : assertions sur les Date).

    Cdlt

    Joel Costigliola

    ps : Il existe aussi un plugin eclipse pour générer des classes d’assertions mais il n’est pas encore dispo (à ma grande honte).

  • Merci Joel,

    Je ne connaissais pas ce plugin Maven! C’est une bonne idée de pouvoir créer les squelettes des assertions métiers. Les templates de code dans l’IDE sont aussi un bon moyen de générer rapidement le code répétitif.
    -> C’est d’ailleurs l’un de nos futurs sujet dans cette rubrique Craftsman Recipes!

    Je viens d’accepter ta pull-request. Sur github donc il y a maintenant la version avec FEST 2.0M8. Cool! J’ai pris la mauvaise habitude de rester sur la version 1.4, je vais tenter d’y remédier.

  • Les templates de code … c’est une bonne idée ça ! Je vais créer un ticket pour en fournir dans Fest, si tu en as sous la main, je suis preneur ;-)

    pour faciliter la migration de fest 1.4 à 2.0, on a une page de doc sur le wiki github :
    https://github.com/alexruiz/fest-assert-2.x/wiki/Migrating-from-FEST-Assert-1.4

    On y a documenté des tips & tricks:
    https://github.com/alexruiz/fest-assert-2.x/wiki/Tips-and-tricks

    Et il existe un projet d’exemples d’utilisation d’assertions :
    https://github.com/joel-costigliola/fest-examples/tree/master/src/main/java/org/fest/assertions/examples

    J’espère que ça pourra aider

Laisser un commentaire