Property-Based Testing : arrêter de choisir ses exemples à la main
Le problème des tests par l’exemple
Voici un test classique pour une fonction de calcul :
[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_ReturnsCorrectSum(int a, int b, int expected)
{
Assert.Equal(expected, new Calculator().Add(a, b));
}
Ces tests ont de la valeur. Mais ils ont un défaut fondamental : on a choisi nous-mêmes les cas à tester.
A-t-on pensé à int.MaxValue + 1 ? Aux valeurs proches des limites ? Aux combinaisons qu’on n’a pas imaginées ? Si un bug existe pour des entrées qu’on n’a pas essayées, nos tests ne le verront jamais.
L’idée du Property-Based Testing
Au lieu de choisir des exemples, on décrit une propriété — une assertion qui doit être vraie pour toutes les entrées valides.
Puis on laisse le framework générer des centaines (ou des milliers) de cas aléatoires pour essayer de violer cette propriété.
Tests par l'exemple : "Add(2, 3) == 5"
Property-Based : "Pour tout a et b, Add(a, b) == Add(b, a)"
La deuxième formulation teste la commutativité de l’addition. Ce n’est pas un exemple — c’est une loi.
Un exemple avec FsCheck (.NET)
// Installation : dotnet add package FsCheck.Xunit
using FsCheck.Xunit;
public class CalculatorProperties
{
// Propriété : l'addition est commutative
[Property]
public bool Add_IsCommutative(int a, int b)
=> new Calculator().Add(a, b) == new Calculator().Add(b, a);
// Propriété : l'addition est associative
[Property]
public bool Add_IsAssociative(int a, int b, int c)
{
var calc = new Calculator();
return calc.Add(calc.Add(a, b), c) == calc.Add(a, calc.Add(b, c));
}
}
FsCheck génère automatiquement des centaines de paires (a, b) pour vérifier ces propriétés. Si une combinaison les viole, FsCheck la shrink — il trouve le plus petit exemple qui reproduit l’échec.
Les propriétés les plus utiles
Roundtrip (aller-retour) Sérialiser puis désérialiser doit redonner l’objet original.
[Property]
public bool Serialize_Roundtrip(User user)
{
var json = JsonSerializer.Serialize(user);
var deserialized = JsonSerializer.Deserialize<User>(json);
return user.Equals(deserialized);
}
Idempotence Appliquer l’opération deux fois doit donner le même résultat qu’une fois.
[Property]
public bool Normalize_IsIdempotent(string input)
{
var once = TextNormalizer.Normalize(input);
var twice = TextNormalizer.Normalize(once);
return once == twice;
}
Les règles métier comme propriétés
C’est le cas d’usage le plus sous-estimé du Property-Based Testing. Les invariants métier — les règles qui doivent être vraies quoi qu’il arrive — sont des propriétés parfaites.
Une remise ne doit jamais rendre un prix négatif
[Property]
public bool Discount_NeverProducesNegativePrice(
PositiveDecimal originalPrice,
PositiveDecimal discountPercent)
{
var price = originalPrice.Value;
var discount = discountPercent.Value % 100; // discount entre 0 et 100%
var result = PricingService.ApplyDiscount(price, discount);
return result >= 0;
}
FsCheck va générer des centaines de combinaisons de prix et de remises. Si l’implémentation calcule mal pour une remise de 99,99% sur un prix de 0,01€ — ce cas qu’on n’aurait jamais écrit à la main — le test l’attrape.
Deux réservations pour la même ressource ne peuvent pas se chevaucher
Prenons ce modèle simple :
public record Reservation(DateTime Start, DateTime End, string ResourceId);
Et une implémentation naïve du scheduler :
public class ReservationScheduler
{
private readonly List<Reservation> _reservations = new();
public Result TryBook(Reservation newReservation)
{
// ❌ Bug : vérifie seulement que le début est après la fin de l'existante
// ne détecte pas les chevauchements partiels par la gauche
var hasConflict = _reservations.Any(r =>
newReservation.Start >= r.Start && newReservation.Start < r.End);
if (hasConflict) return Result.Failure("Créneau indisponible");
_reservations.Add(newReservation);
return Result.Success();
}
}
Avec des tests par l’exemple, on écrirait probablement :
[Fact] void Book_OverlappingSlot_IsRejected() { /* 14h-15h puis 14h30-15h30 */ }
[Fact] void Book_NonOverlappingSlot_IsAccepted() { /* 14h-15h puis 15h-16h */ }
[Fact] void Book_SameSlot_IsRejected() { /* 14h-15h deux fois */ }
Ces trois tests passent. Le bug n’est pas détecté.
Pourquoi ? Parce qu’on n’a pas pensé au cas où le nouveau créneau commence avant l’existant mais se termine pendant :
Existant : [====14h-15h====]
Nouveau : [==13h30-14h30==] ← commence avant, chevauche par la gauche
newReservation.Start (13h30) < r.Start (14h) — la condition ne se déclenche pas. La réservation est acceptée à tort.
Avec un test de propriété, FsCheck trouve ce cas automatiquement :
[Property]
public bool Reservations_ForSameResource_NeverOverlap(
NonEmptyArray<Reservation> existingReservations,
Reservation newReservation)
{
var scheduler = new ReservationScheduler();
foreach (var r in existingReservations.Get)
scheduler.Book(r);
var result = scheduler.TryBook(newReservation);
return !result.IsSuccess || existingReservations.Get.All(r =>
newReservation.End <= r.Start || newReservation.Start >= r.End);
}
FsCheck génère des centaines de paires de créneaux, y compris les cas qu’on n’aurait pas pensé à écrire : chevauchement par la gauche, par la droite, créneau contenu dans un autre, créneaux qui se touchent exactement à la frontière, durée nulle.
Quand il trouve une combinaison qui viole la propriété, il la shrink — il réduit l’exemple au minimum qui reproduit l’échec. Au lieu de recevoir “créneau du 2025-01-15 13h27 au 14h43 échoue contre le créneau du 2025-01-15 13h55 au 15h12”, on reçoit :
Falsifiable after 23 tests.
Shrunk 7 times.
existingReservations = [Reservation { Start = 01/01 02:00, End = 01/01 03:00 }]
newReservation = Reservation { Start = 01/01 01:00, End = 01/01 02:30 }
Le cas minimal, lisible, reproductible — exactement ce qu’il faut pour corriger le bug.
Le shrinking est une des features les plus précieuses de FsCheck — souvent plus que la génération elle-même. Un test par l’exemple qui échoue te donne le cas exact que tu as écrit. FsCheck qui échoue te donne le plus petit cas possible, ce qui rend le debugging beaucoup plus rapide. C’est une raison supplémentaire d’adopter le PBT au-delà de la couverture.
Un utilisateur bloqué ne peut jamais effectuer une action sensible
[Property]
public bool BlockedUser_CanNeverPerformSensitiveAction(
User user,
SensitiveAction action)
{
var blockedUser = user with { Status = UserStatus.Blocked };
var result = AuthorizationService.CanPerform(blockedUser, action);
return result == false;
}
Ce test ne vérifie pas un cas précis — il vérifie que quel que soit l’utilisateur et quelle que soit l’action, le statut bloqué prime toujours. C’est exactement la classe de bugs qui échappe aux tests par l’exemple : les combinaisons d’états qu’on n’a pas imaginées.
Choisir la bonne force pour une propriété
Un piège courant : écrire des propriétés trop faibles, qui passent même quand le code est cassé.
// ❌ Propriété trop faible : ne vérifie que la dernière réservation
return !result.IsSuccess || newReservation.Start >= existingReservations.Get.Last().End
// ✅ Propriété exacte : vérifie l'absence de chevauchement avec TOUTES les réservations
return !result.IsSuccess || existingReservations.Get.All(r =>
newReservation.End <= r.Start || newReservation.Start >= r.End)
La règle : une propriété doit être aussi forte que possible — tout en restant vraie pour toutes les entrées valides. Une propriété trop faible ne détecte pas les bugs qu’elle était censée attraper. C’est pire qu’une absence de test : elle donne une fausse confiance.
Pourquoi c’est puissant pour le métier
Les tests par l’exemple documentent des scénarios : “Alice réserve la salle A du 14h au 15h”. Les tests de propriétés encodent des lois : “aucune double réservation n’est jamais possible, quelle que soit la combinaison de créneaux”. Ces lois sont souvent directement issues du cahier des charges ou des règles de gestion — et elles sont valides pour toutes les entrées, pas pour quelques exemples soigneusement choisis.
Limites et complémentarité
Le Property-Based Testing ne remplace pas les tests par l’exemple — il les complète.
- Les tests par l’exemple sont excellents pour documenter des cas métier précis et des scénarios concrets
- Les tests de propriétés sont excellents pour vérifier des invariants — mathématiques, techniques, ou métier
La combinaison des deux donne une couverture bien plus solide que l’un ou l’autre seul.
En résumé
- Les tests par l’exemple ne testent que les cas qu’on a imaginés
- Le Property-Based Testing génère automatiquement des centaines de cas pour vérifier des propriétés universelles
- Les propriétés les plus utiles : roundtrip, invariant, idempotence, commutativité, règles métier
- FsCheck est la bibliothèque de référence pour .NET ; QuickCheck pour Haskell/Erlang, Hypothesis pour Python
- Ce n’est pas un remplacement des tests classiques — c’est un complément qui attrape une classe de bugs différente
La prochaine fois que vous écrivez un serializer, un algorithme de tri, ou que vous formalisez une règle de gestion, demandez-vous : quelle propriété doit être vraie pour toutes les entrées ? C’est souvent la question qui révèle les bugs qu’on n’aurait jamais pensé à chercher.
Le Property-Based Testing avec FsCheck est au programme du TP de ma formation Tests & Validation au CNAM Strasbourg.