Aller au contenu
BackJavaTestTest unitaireTesting

Tests par propriété : générateurs custom pour des données réalistes

Après avoir découvert les bases des tests par propriété avec jqwik, il est temps de passer à l'étape suivante : la génération de données réalistes. Emails, dates, objets métier ou utilisateurs complets, découvrez comment créer des générateurs personnalisés.

Tests par propriété : générateurs custom pour des données réalistes

Dans l'article précédent, nous avons découvert les tests par propriété avec jqwik. Nous avons vu comment générer automatiquement des nombres, des chaînes de caractères ou encore des collections afin de vérifier qu'une règle métier reste toujours vraie.

Cette approche est déjà très puissante, mais dans une application réelle nous manipulons rarement des entiers ou des chaînes isolées. Nous travaillons plutôt avec des adresses email, des dates, des utilisateurs ou encore des objets complexes.

Heureusement, jqwik permet de créer ses propres générateurs afin de produire des données beaucoup plus proches de la réalité métier.

Pourquoi créer ses propres générateurs ?

Prenons l'exemple d'un service qui valide une adresse email.

Un premier réflexe pourrait être d'utiliser simplement :

@Propertyvoid emailShouldBeValid(@ForAll String email) {    ...}

Le problème est que jqwik va générer toutes sortes de chaînes :

"abc"
""
"123"
"@"
"#$%^"

Ces valeurs peuvent être intéressantes pour tester la robustesse d'un système, mais elles ne correspondent pas forcément au contexte métier que nous souhaitons valider.
Dans certains cas, il est plus pertinent de produire directement des données respectant les contraintes attendues.

C'est exactement le rôle des générateurs personnalisés.

Générer des données cohérentes avec le métier

L'objectif n'est pas seulement de produire des valeurs aléatoires.

Un bon générateur doit produire :

  • des données réalistes ;
  • des données variées ;
  • des données conformes aux contraintes métier ;
  • suffisamment de cas limites pour détecter les anomalies.

Autrement dit, nous cherchons à reproduire le comportement d'utilisateurs réels tout en conservant la puissance de l'exploration automatique.

Exemple 1 : générer des adresses email valides

Pour créer un générateur custom dans jqwik, nous allons avoir besoin de trois éléments : une annotation, un provider, et un mécanisme d'enregistrement.

Commençons par l'annotation qui viendra décorer nos paramètres de test :

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Email {
    String[] domains() default {};
    int maxLocalLength() default 20;
}

Les deux paramètres optionnels permettent de contraindre la génération selon le contexte : si domains est renseigné, le générateur piochera dans cette liste ; sinon, il construira des domaines aléatoires.

Passons maintenant au provider, qui implémente ArbitraryProvider :

public class EmailGenerator implements ArbitraryProvider {

    @Override
    public boolean canProvideFor(TypeUsage targetType) {
        return targetType.isOfType(String.class)
            && targetType.findAnnotation(Email.class).isPresent();
    }

    @Override
    public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
        Email annotation = targetType.findAnnotation(Email.class).orElseThrow();
        return Set.of(buildEmailArbitrary(annotation));
    }

    private Arbitrary<String> buildEmailArbitrary(Email annotation) {
        Arbitrary<String> localPart = Arbitraries.strings()
            .withChars("abcdefghijklmnopqrstuvwxyz0123456789._")
            .ofMinLength(1)
            .ofMaxLength(annotation.maxLocalLength())
            .filter(s -> !s.startsWith(".") && !s.startsWith("_")
                      && !s.endsWith(".")   && !s.endsWith("_"));

        Arbitrary<String> domain = buildDomainArbitrary(annotation);

        return Combinators.combine(localPart, domain)
            .as((local, dom) -> local + "@" + dom);
    }

    private Arbitrary<String> buildDomainArbitrary(Email annotation) {
        if (annotation.domains().length > 0) {
            return Arbitraries.of(annotation.domains());
        }

        Arbitrary<String> domainName = Arbitraries.strings()
            .withChars("abcdefghijklmnopqrstuvwxyz0123456789-")
            .ofMinLength(2)
            .ofMaxLength(15)
            .filter(s -> !s.startsWith("-") && !s.endsWith("-"));

        Arbitrary<String> tld = Arbitraries.of("com", "org", "net", "io", "fr", "de", "uk");

        return Combinators.combine(domainName, tld)
            .as((name, ext) -> name + "." + ext);
    }

    @Override
    public int priority() {
        return 10;
    }
}

La méthode canProvideFor est la clé du mécanisme : elle s'assure que ce provider ne s'active que lorsque le paramètre est à la fois un String et annoté avec @Email, évitant ainsi tout effet de bord sur les autres générateurs.

Il reste ensuite à déclarer ce provider à jqwik. C'est ici qu'intervient le mécanisme SPI (Service Provider Interface), intégré à Java depuis le JDK 1.3. Plutôt que de câbler en dur les implémentations d'une interface dans le code, Java permet de les déclarer dans un fichier texte que le ServiceLoader découvre et instancie automatiquement au démarrage.

Il faut donc créer le fichier suivant dans src/test/resources :

META-INF/services/net.jqwik.api.providers.ArbitraryProvider

Avec pour contenu le nom complet de notre provider :

com.example.EmailGenerator

Notre générateur est maintenant opérationnel. Nous pouvons l'utiliser dans nos tests :

private static final Pattern EMAIL_PATTERN = Pattern.compile(
        "^[a-zA-Z0-9][a-zA-Z0-9._-]*@[a-zA-Z0-9-]+\\.[a-zA-Z]{2,}$"
);

@Property
void emailShouldBeValid(@ForAll @Email String email) {
    assertTrue(EMAIL_PATTERN.matcher(email).matches());
}

@Property
void emailShouldUseCorporateDomain(@ForAll @Email(domains = {"company.com", "corp.io"}) String email) {
    assertTrue(email.endsWith(".com") || email.endsWith(".io"));
}

À noter que la propriété testée reste indépendante de l'implémentation du générateur : la regex de validation et la logique de construction dans EmailGenerator sont deux choses distinctes.
Si le générateur produisait un email incorrect, le test le détecterait immédiatement.

Exemple 2 : générer des dates dans un intervalle

Les dates sont un autre cas très fréquent en contexte métier. Une date de naissance ne peut pas être dans le futur, une date de contrat doit se situer dans une plage cohérente, une échéance ne peut pas précéder sa date de création. Autant de contraintes que les générateurs par défaut de jqwik ne connaissent pas.

Plutôt que d'implémenter un ArbitraryProvider complet comme pour les emails, jqwik offre une approche plus légère pour ce type de cas : les méthodes de génération nommées. Il suffit de créer une méthode annotée @Provide dans la classe de test, puis de la référencer par son nom dans @ForAll.

Commençons par extraire la logique de génération dans une classe utilitaire réutilisable :

public class DateGenerators {  
    public static Arbitrary<LocalDate> datesBetween(LocalDate start, LocalDate end) {  
        return Arbitraries.longs()  
                .between(start.toEpochDay(), end.toEpochDay())  
                .map(LocalDate::ofEpochDay);  
    }  
}

L'astuce repose sur toEpochDay() : plutôt que de manipuler des dates directement, on génère un long représentant le nombre de jours écoulés depuis le 1er janvier 1970, puis on le reconvertit en LocalDate via ofEpochDay.
Cela permet de bénéficier de toute la puissance des générateurs numériques de jqwik tout en obtenant des dates en sortie.

Dans la classe de test, on expose ensuite ce générateur via @Provide :


@Property
void birthdayShouldBeInThePast(@ForAll("pastDates") LocalDate birthday) {
	assertTrue(birthday.isBefore(LocalDate.now()));
}

@Provide
Arbitrary<LocalDate> pastDates() {
	return DateGenerators.datesBetween(
		LocalDate.of(1900, 1, 1),
		LocalDate.of(2026, 1, 1)
	);
}

La valeur passée à @ForAll("pastDates") est simplement le nom de la méthode annotée @Provide, jqwik se charge de faire le lien automatiquement.
Cette approche est particulièrement adaptée quand la contrainte est ponctuelle et contextuelle : on n'a pas besoin d'un provider global enregistré via SPI, juste d'une méthode utilitaire réutilisable selon les besoins du test.


Pour les cas simples de génération de dates, jqwik propose le module jqwik-time qui évite d'écrire tout ce code. Après l'ajout de la dépendance, l'exemple précédent se résume à :

@Property
void birthdayShouldBeInThePast(@ForAll @DateRange(min = "1900-01-01", max = "2026-01-01") LocalDate birthday) {
    assertTrue(birthday.isBefore(LocalDate.now()));
}

Le module couvre une large gamme de types : LocalDate, LocalTime, LocalDateTime, Instant, ZonedDateTime, YearMonth... avec des annotations dédiées pour chacun.
L'exemple datesBetween reste néanmoins pertinent dès que les contraintes deviennent plus complexes qu'un simple intervalle : par exemple, combiner une plage de dates avec d'autres règles métier.


Exemple 3 : générer des objets métier complets

Jusqu'ici, nous avons généré des types simples : des String et des LocalDate. Dans une application réelle, les propriétés que l'on souhaite vérifier portent le plus souvent sur des objets métier complets. jqwik permet de les générer tout aussi facilement grâce à Combinators.combine().

Prenons l'exemple d'un objet User :

public class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}

Le générateur associé combine plusieurs arbitraires indépendants pour construire une instance cohérente :

public class UserGenerator {

    public static Arbitrary<User> users() {
        return Combinators.combine(
            Arbitraries.strings().alpha().ofMinLength(2).ofMaxLength(20),
            Arbitraries.integers().between(18, 100)
        ).as((name, age) -> new User(name, age));
    }
}

Combinators.combine() prend en entrée plusieurs Arbitrary et les assemble via .as() pour produire un objet final. Chaque champ est généré indépendamment, ce qui maximise la diversité des combinaisons explorées.

Dans la classe de test, on expose ce générateur via @Provide :

class UserPropertyTest {

    @Property
    void userAgeShouldBePositive(@ForAll("users") User user) {
        assertTrue(user.getAge() > 0);
    }

    @Provide
    Arbitrary<User> users() {
        return UserGenerator.users();
    }
}

Cette approche se compose très bien : si User contient lui-même des objets imbriqués (une adresse, un rôle, une liste de permissions) il suffit d'ajouter des arbitraires supplémentaires dans le combine(). Le générateur reste lisible et les responsabilités sont clairement séparées entre la logique de génération dans UserGenerator et son exposition dans la classe de test via @Provide.

Conclusion

Les générateurs personnalisés sont ce qui fait passer les tests par propriété d'un outil expérimental à un véritable allié du quotidien. En produisant des données réalistes et cohérentes avec le métier, ils permettent d'explorer automatiquement des milliers de scénarios que l'on n'aurait jamais pensé à écrire à la main.

Nous avons vu trois approches complémentaires selon le niveau de réutilisabilité souhaité :

  • ArbitraryProvider + SPI pour un générateur global, activé automatiquement via une annotation.
  • @Provide + méthode statique pour un générateur ponctuel, déclaré directement dans la classe de test.
  • Combinators.combine() pour assembler des objets complexes à partir de générateurs simples.

Ces patterns se composent et s'imbriquent : un UserGenerator peut très bien s'appuyer sur un EmailGenerator pour produire des utilisateurs avec des adresses email valides. C'est là toute la puissance de l'approche.

plateforme de données - sfeir.dev - Le média incontournable pour les passionnés de tech et d’intelligence artificielle
L’actualité des dernières avancées et tendances en technologies et en intelligence artificielle, décryptée avec acuité par des experts IT.

Dernier