Tester BeanValidation, ou pas

J'ai déjà expliqué que je testais les logs. Du coup, franchement, rien d'étonnant à ce que je teste aussi BeanValidation :D.

J'aime bien utiliser BeanValidation pour remonter toutes les erreurs de saisie aux utilisateurs en une fois. Je ne valide pas la cohérence des objets métier avec cet outil puisque j'assure cette cohérence directement dans le domain model.

Avec le temps, j'ai imaginé différents outils pour valider efficacement mon utilisation de BeanValidation, c'est ce que je vais partager ici.

BeanValidation en quelques lignes

BeanValidation permet de spécifier, via des annotations, des règles de validité sur un objet :

class RestBean {
 
  private final String firstname;
  private final int age;
 
  RestBean(String firstname, int age) {
    this.firstname = firstname;
    this.age = age;
  }
 
  @NotBlank(message = ValidationMessage.MANDATORY)
  public String getFirstname() {
    return firstname;
  }
 
  @Min(value = 0, message = ValidationMessage.VALUE_TOO_LOW)
  public int getAge() {
    return age;
  }
}

L'intégration dans SpringBoot se charge ensuite de jouer la validation même s'il est possible de la faire "manuellement" dans le code.

Beaucoup d'annotations sont disponibles et il est possible de définir les vôtres. Je vous conseille cependant de ne pas passer trop de temps sur ce point et d'investir davantage sur des validations Métier (mais c'est un autre sujet :)).

Valider les règles

La première chose à valider lorsqu'on parle de BeanValidation ce sont les règles de validation en elles-mêmes.

Comme pour chaque validation d'outillage bien intégré, le but n'est pas de savoir si le Framework fonctionne : BeanValidation fonctionne ! On cherche ici à savoir si on l'utilise correctement ; si le comportement est celui que l'on attend.

Comme j'aime beaucoup les assertions fluent d'AssertJ j'ai imaginé une API de test de BeanValidation ayant cette forme :

assertThatBean(invalidBean)
    .hasInvalidProperty("firstname")
    .withMessage(ValidationMessage.MANDATORY)
    .and()
    .hasInvalidProperty("age")
    .withMessage(ValidationMessage.VALUE_TOO_LOW)
    .withParameter("value", 0L);

Je ne veux pas utiliser directement assertThat car ces validations doivent se faire sur tous les Object, j'aurais alors un conflit avec les assertions natives de AssertJ (je n'ai donc pas fait un Asserter AssertJ custom). J'ai choisi de nommer ma method assertThatBean parce que j'aime bien la phrase produite par une assertion (c'est le côté fluent de l'API).

Pour permettre ces validations j'ai donc un petit utilitaire :

package com.ippon;
 
import static org.assertj.core.api.Assertions.*;
 
import com.ippon.error.domain.Assert;
import java.util.Set;
import java.util.function.Predicate;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
 
public final class BeanValidationAssertions {
 
  private BeanValidationAssertions() {}
 
  public static <T> BeanAsserter<T> assertThatBean(T bean) {
    return new BeanAsserter<>(bean);
  }
 
  public static class BeanAsserter<T> {
    private static final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
 
    private final Set<ConstraintViolation<T>> violations;
 
    private BeanAsserter(T bean) {
      Assert.notNull("bean", bean);
 
      violations = validator.validate(bean);
    }
 
    public BeanAsserter<T> isValid() {
      assertThat(violations).isEmpty();
 
      return this;
    }
 
    public InvalidPropertyAsserter<T> hasInvalidProperty(String property) {
      Assert.notBlank("property", property);
 
      return violations
        .stream()
        .filter(withProperty(property))
        .findFirst()
        .map(validation -> new InvalidPropertyAsserter<>(this, validation))
        .orElseThrow(() -> new AssertionError("Property " + property + " must be invalid but wasn't"));
    }
 
    private Predicate<ConstraintViolation<T>> withProperty(String property) {
      return validation -> property.equals(validation.getPropertyPath().toString());
    }
  }
 
  public static class InvalidPropertyAsserter<T> {
    private final BeanAsserter<T> beanAsserter;
    private final ConstraintViolation<T> violation;
 
    private InvalidPropertyAsserter(BeanAsserter<T> beanAsserter, ConstraintViolation<T> violation) {
      this.beanAsserter = beanAsserter;
      this.violation = violation;
    }
 
    public InvalidPropertyAsserter<T> withMessage(String message) {
      Assert.notBlank("message", message);
 
      assertThat(violation.getMessage()).isEqualTo(message);
 
      return this;
    }
 
    public InvalidPropertyAsserter<T> withParameter(String parameter, Object value) {
      Assert.notBlank("parameter", parameter);
 
      assertThat(violation.getConstraintDescriptor().getAttributes()).contains(entry(parameter, value));
 
      return this;
    }
 
    public BeanAsserter<T> and() {
      return beanAsserter;
    }
  }
}

J'utilise une class Assert très semblable à celle décrite dans des objets, pas des data classes mais c'est un détail.

Cette class répond au besoin que j'ai sur une base de code précise. Peut-être est-elle trop légère ou trop complète pour vos besoins ! Je ne veux pas fournir un Framework de test pour BeanValidation parce qu'il en existe sûrement en quantités pléthoriques ! En réalité, je veux juste montrer :

  • Comment BeanValidation s'utilise "nativement" ;
  • Qu'il est relativement simple de fabriquer sa propre API fluent et qu'on devrait penser plus souvent à ce type de design ;
  • Qu'on peut fabriquer des outils pour faciliter nos tests et donc notre quotidien ;

Et puis, si ce petit bout de code vous est utile : tant mieux !

Valider l'intégration

Maintenant que nous pouvons valider les règles sur nos beans il faut s'assurer que ces règles soient bien validées lors des appels.

Comme pour chaque test que l'on écrit, il est essentiel que ces tests aient du sens au vu des erreurs que l'on commet souvent.

Fut un temps, je testais ma configuration de BeanValidation via des tests de composants qui faisaient des appels aux WebServices. Cette approche est coûteuse en temps de développement et en temps d'exécution.

Avec le temps, je me suis rendu compte que l'erreur que je faisais le plus souvent était d'oublier de mettre une annotation @Valid ou @Validated sur les parameters dans les @RestController...

J'ai donc imaginé un test se basant sur la librairie reflections (qui offre de nouvelles possibilités aux API reflect de base) :

<properties>
  <!-- ... -->
  <reflections.version>0.9.12</reflections.version>
</properties>
 
<dependencies>
  <dependency>
    <groupId>org.reflections</groupId>
    <artifactId>reflections</artifactId>
    <version>${reflections.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

L'idée est simple : aller voir tous les parameters de toutes les methods exposées dans tous les @RestController et s'assurer que toutes les classes de l'application soient bien annotées avec @Validated (je n'utilise pas @Valid) :

package com.ippon;
 
import static org.assertj.core.api.Assertions.*;
 
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.reflections.Reflections;
import org.reflections.scanners.SubTypesScanner;
import org.reflections.scanners.TypeAnnotationsScanner;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import org.reflections.util.FilterBuilder;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
 
public class BeanValidationUnitTest {
  private static final String ROOT_PACKAGE = "com.company.application";
 
  private static final Set<String> EXCLUDED_CONTROLLERS = Set.of();
 
  private static final Set<Method> OBJECT_METHODS = Arrays.stream(Object.class.getMethods()).collect(Collectors.toUnmodifiableSet());
 
  private static final Set<Class<?>> controllers = new Reflections(
    new ConfigurationBuilder()
      .setUrls(ClasspathHelper.forPackage(ROOT_PACKAGE))
      .setScanners(new TypeAnnotationsScanner(), new SubTypesScanner())
      .filterInputsBy(new FilterBuilder().includePackage(ROOT_PACKAGE))
  )
  .getTypesAnnotatedWith(RestController.class);
 
  @Test
  void shouldHaveValidatedAnnotationForAllParameters() {
    controllers
      .stream()
      .filter(controller -> !EXCLUDED_CONTROLLERS.contains(controller.getSimpleName()))
      .flatMap(toMethods())
      .filter(visibleMethods())
      .filter(controllerMethods())
      .forEach(checkValidatedAnnotation());
  }
 
  private Function<Class<?>, Stream<Method>> toMethods() {
    return controller -> Arrays.stream(controller.getMethods());
  }
 
  private Predicate<Method> visibleMethods() {
    return method -> !Modifier.isPrivate(method.getModifiers());
  }
 
  private Predicate<Method> controllerMethods() {
    return method -> !OBJECT_METHODS.contains(method);
  }
 
  private Consumer<Method> checkValidatedAnnotation() {
    return method ->
      Arrays
        .stream(method.getParameters())
        .filter(checkedTypes())
        .forEach(
          parameter -> {
            assertThat(Arrays.stream(parameter.getAnnotations()))
              .as(errorMessage(method, parameter))
              .anyMatch(annotation -> annotation.annotationType().equals(Validated.class));
          }
        );
  }
 
  private String errorMessage(Method method, Parameter parameter) {
    return (
      "Missing @Validated annotation in " +
      method.getDeclaringClass().getSimpleName() +
      " on method " +
      method.getName() +
      " parameter of type " +
      parameter.getType().getSimpleName()
    );
  }
 
  private Predicate<Parameter> checkedTypes() {
    return parameter -> {
      Class<?> parameterClass = parameter.getType();
 
      return !parameterClass.isPrimitive() && parameterClass.getName().startsWith(ROOT_PACKAGE);
    };
  }
}

Ce code est complexe mais, en faisant le rapport entre ces apports et son coût il est très rentable pour moi puisqu'il évite une erreur que je fais souvent ! Je systématise ce type d'automatisation et je fais la chasse à celles qui coûtent et qui corrigent des erreurs que personne dans l'équipe ne fait !

Un point de détail : j'ai choisi d'utiliser les noms des classes des controllers à ignorer dans EXCLUDED_CONTROLLERS et non pas les classes en elles-mêmes pour pouvoir garder ces controllers en visibilité package.

Si vous voulez reprendre ce code, pensez bien à changer la valeur de ROOT_PACKAGE pour pointer sur le package de base de votre application.

Euh, du coup, tester BeanValidation ?

Oui, non, des fois, ce n'est pas important ! Ce qui est essentiel c'est d'empêcher, de la manière la plus pragmatique possible, les erreurs faites par une équipe.

Cet exemple de BeanValidation me paraissait intéressant car les outils montrés ici sortent de l'ordinaire. Vous pouvez construire des outils pratiques à utiliser ou réfléchir au problème par un autre angle pour arriver à vos fins !

Laissez-vous le droit d'essayer et, peut-être, d'échouer mais, de temps en temps, prenez le temps de trouver une solution automatique à un problème que vous avez souvent rencontré, vous vous remercierez plus tard !