TDD & Qualité

Property-Based Testing : Trouver les Bugs que vos Tests Classiques Ratent

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Property-Based Testing : Trouver les Bugs que vos Tests Classiques Ratent

Le problème des tests basés sur des exemples

Vos tests ressemblent probablement à ça :

it('additionne deux nombres', () => {
  expect(add(2, 3)).toBe(5);
  expect(add(-1, 1)).toBe(0);
  expect(add(0, 0)).toBe(0);
});

Vous avez choisi ces exemples parce qu'ils vous semblaient représentatifs. Mais comment savoir si vous n'avez pas oublié un cas ? Que se passe-t-il avec Number.MAX_SAFE_INTEGER + 1 ? Avec NaN ? Avec -0 ?

Le property-based testing inverse la logique : plutôt que de spécifier des exemples, vous décrivez des propriétés qui doivent être vraies pour toutes les entrées valides. L'outil génère ensuite des milliers de cas aléatoires pour tenter de violer ces propriétés.

fast-check — le standard JavaScript

fast-check est la bibliothèque de référence pour le property-based testing en JavaScript/TypeScript.

npm install --save-dev fast-check

De l'exemple vers la propriété

Reprenons notre fonction add :

import fc from 'fast-check';

// Test basé sur des exemples
describe('add — exemples', () => {
  it('additionne 2 + 3 = 5', () => {
    expect(add(2, 3)).toBe(5);
  });
});

// Test basé sur des propriétés
describe('add — propriétés', () => {
  it('est commutatif (a + b = b + a)', () => {
    fc.assert(
      fc.property(fc.integer(), fc.integer(), (a, b) => {
        expect(add(a, b)).toBe(add(b, a));
      })
    );
  });

  it('est associatif ((a + b) + c = a + (b + c))', () => {
    fc.assert(
      fc.property(fc.integer(), fc.integer(), fc.integer(), (a, b, c) => {
        expect(add(add(a, b), c)).toBe(add(a, add(b, c)));
      })
    );
  });

  it('a zéro comme élément neutre (a + 0 = a)', () => {
    fc.assert(
      fc.property(fc.integer(), (a) => {
        expect(add(a, 0)).toBe(a);
      })
    );
  });
});

fast-check génère par défaut 100 cas de test par propriété, avec des valeurs qui incluent les cas limites : 0, -1, 1, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, et des valeurs aléatoires intermédiaires.

Le shrinking — la killer feature

Quand fast-check trouve un cas qui viole une propriété, il ne vous retourne pas juste l'exemple aléatoire qui a échoué. Il réduit cet exemple au cas le plus simple possible qui reproduit l'erreur. C'est le shrinking.

Prenons un exemple avec un bug réel :

// Fonction avec un bug subtil
export function sortAndDeduplicate(arr: number[]): number[] {
  // Bug : sort() sans comparateur trie lexicographiquement
  return [...new Set(arr)].sort();
}

// Le bug
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    const result = sortAndDeduplicate(arr);
    
    // Propriété : le résultat doit être trié
    for (let i = 0; i < result.length - 1; i++) {
      expect(result[i]).toBeLessThanOrEqual(result[i + 1]);
    }
  })
);

Sortie de fast-check :

Error: Property failed after 5 tests
{ seed: 42, path: "4:1:2", endOnFailure: true }
Counterexample: [[9, 11]]  ← Cas minimal qui reproduit le bug
Shrunk 8 time(s)
Got error: Expected 11 to be less than or equal to 9

Hint: Enable verbose mode in order to have the list of all failing values encountered during the run

Sans shrinking, fast-check aurait retourné quelque chose comme [42, 103, -7, 9, 11, 200]. Avec shrinking, il a réduit au minimum : [9, 11] — là où "9" > "11" en comparaison lexicographique.

Les arbitraires de fast-check

fast-check fournit une grande variété de générateurs (appelés "arbitraires") :

// Primitifs
fc.integer()                    // Entiers
fc.integer({ min: 0, max: 100 }) // Entiers bornés
fc.nat()                        // Entiers naturels (≥ 0)
fc.float()                      // Flottants
fc.double({ noNaN: true })      // Flottants sans NaN
fc.boolean()                    // Booléens
fc.string()                     // Chaînes quelconques
fc.string({ minLength: 1, maxLength: 100 })
fc.constantFrom('a', 'b', 'c') // Une valeur parmi une liste

// Collections
fc.array(fc.integer())          // Tableau d'entiers
fc.array(fc.integer(), { minLength: 1, maxLength: 10 })
fc.set(fc.string())             // Ensemble sans doublons
fc.tuple(fc.integer(), fc.string(), fc.boolean()) // Tuple typé

// Objets
fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1 }),
  age: fc.integer({ min: 0, max: 120 }),
  email: fc.emailAddress(),
})

// Dates
fc.date({ min: new Date('2000-01-01'), max: new Date('2030-12-31') })

// Cas spéciaux
fc.oneof(fc.constant(null), fc.string()) // String ou null
fc.option(fc.string())                   // Équivalent : string | undefined

Propriétés courantes à tester

Isomorphisme encode/decode

C'est l'une des propriétés les plus puissantes : encoder puis décoder doit donner le même résultat.

import fc from 'fast-check';

describe('JSON encode/decode', () => {
  it('encode puis décode = identité', () => {
    fc.assert(
      fc.property(
        fc.oneof(
          fc.integer(),
          fc.string(),
          fc.boolean(),
          fc.array(fc.integer()),
          fc.record({ x: fc.integer(), y: fc.string() })
        ),
        (value) => {
          expect(JSON.parse(JSON.stringify(value))).toEqual(value);
        }
      )
    );
  });
});

describe('Base64 encode/decode', () => {
  it('encode puis décode = identité', () => {
    fc.assert(
      fc.property(fc.string(), (str) => {
        const encoded = Buffer.from(str).toString('base64');
        const decoded = Buffer.from(encoded, 'base64').toString();
        expect(decoded).toBe(str);
      })
    );
  });
});

Idempotence

Appliquer une fonction deux fois doit donner le même résultat que l'appliquer une fois :

describe('normalizeWhitespace', () => {
  it('est idempotente', () => {
    fc.assert(
      fc.property(fc.string(), (str) => {
        const once = normalizeWhitespace(str);
        const twice = normalizeWhitespace(once);
        expect(twice).toBe(once);
      })
    );
  });
});

describe('sortAndDeduplicate', () => {
  it('est idempotente', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const once = sortAndDeduplicate(arr);
        const twice = sortAndDeduplicate(once);
        expect(twice).toEqual(once);
      })
    );
  });
});

Conservation de propriétés après transformation

describe('filterEven', () => {
  it('préserve les éléments pairs', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const result = arr.filter((n) => n % 2 === 0);
        // Tous les éléments du résultat sont pairs
        expect(result.every((n) => n % 2 === 0)).toBe(true);
        // Le résultat est un sous-ensemble de l'entrée
        expect(result.every((n) => arr.includes(n))).toBe(true);
        // La taille du résultat est inférieure ou égale à l'entrée
        expect(result.length).toBeLessThanOrEqual(arr.length);
      })
    );
  });
});

Modélisation d'états — tester des systèmes complexes

fast-check permet de modéliser des séquences d'actions sur un système :

import fc from 'fast-check';
import { ModelRunnerContext } from 'fast-check/lib/esm/check/model/ModelRunner';

// Système à tester : une pile (stack)
class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

// Modèle de référence simple
interface StackModel {
  items: number[];
}

// Commandes
class PushCommand implements fc.Command<StackModel, Stack<number>> {
  constructor(readonly value: number) {}

  check() { return true; }

  run(model: StackModel, real: Stack<number>): void {
    model.items.push(this.value);
    real.push(this.value);

    expect(real.size).toBe(model.items.length);
    expect(real.peek()).toBe(model.items[model.items.length - 1]);
  }

  toString() { return `push(${this.value})`; }
}

class PopCommand implements fc.Command<StackModel, Stack<number>> {
  check(model: StackModel) {
    return model.items.length > 0; // Ne pop que si non vide
  }

  run(model: StackModel, real: Stack<number>): void {
    const expectedValue = model.items.pop();
    const actualValue = real.pop();

    expect(actualValue).toBe(expectedValue);
    expect(real.size).toBe(model.items.length);
  }

  toString() { return 'pop()'; }
}

describe('Stack — model-based testing', () => {
  it('respecte le modèle pour toute séquence d\'opérations', () => {
    const allCommands = [
      fc.integer({ min: -100, max: 100 }).map((v) => new PushCommand(v)),
      fc.constant(new PopCommand()),
    ];

    fc.assert(
      fc.property(fc.commands(allCommands, { maxCommands: 20 }), (cmds) => {
        const model: StackModel = { items: [] };
        const real = new Stack<number>();

        fc.modelRun(() => ({ model, real }), cmds);
      })
    );
  });
});

Intégration avec Jest et configuration avancée

// Configurer le nombre de runs et la graine aléatoire
fc.assert(
  fc.property(fc.string(), (s) => {
    expect(s.length).toBeGreaterThanOrEqual(0);
  }),
  {
    numRuns: 1000,      // Augmenter pour plus de rigueur
    seed: 42,           // Graine fixe pour la reproductibilité
    verbose: true,      // Afficher tous les cas testés en cas d'échec
    endOnFailure: true, // Arrêter au premier échec
  }
);

// Reproduire un échec spécifique avec sa graine
fc.assert(
  fc.property(fc.integer(), (n) => { /* ... */ }),
  { seed: 1234567890, path: '3:2:1' } // Copié depuis le message d'erreur
);

Quand utiliser le property-based testing ?

Le PBT n'est pas un remplacement des tests basés sur des exemples — c'est un complément. Utilisez-le pour :

  • Fonctions pures avec des propriétés mathématiques : encode/decode, sort, filter, map
  • Parseurs et sérialiseurs : JSON, CSV, Markdown, formats binaires
  • Algorithmes : tri, recherche, chiffrement, compression
  • Systèmes à états : state machines, queues, caches
  • Validation de données : règles métier complexes avec de nombreuses combinaisons

Gardez les tests exemples pour :

  • Les comportements documentés spécifiques
  • Les bugs régressifs (ajouter l'exemple exact qui a causé le bug)
  • La lisibilité et la documentation du comportement attendu

Conclusion

Le property-based testing trouve les bugs que votre intuition humaine ne trouve pas, parce qu'il explore systématiquement l'espace des entrées plutôt que de se limiter aux exemples que vous avez imaginés. Le shrinking automatique transforme des cas d'échec complexes en exemples minimaux compréhensibles.

Commencez par les fonctions pures de votre codebase : chaque paire encode/decode, chaque algorithme de tri ou de filtrage est un candidat idéal. En quelques heures, vous aurez probablement trouvé des bugs qui dormaient depuis des mois.