6) Définir des propriétés

Avant de nous lancer dans notre refactoring, on peut encore aller plus loin sur nos tests afin d'améliorer encore notre confiance vis-à-vis de notre code base.

Pour ce faire on va écrire des tests de propriétés :

  • Prendre du temps pour comprendre ce qu'est le Property-Based Testing

  • Quelles propriétés peut-on identifier à partir de notre Example Mapping ?

  • Ecrire des tests de propriétés en utilisant la librairie FsCheck

Démarrer une partie

On peut identifier des propriétés telles que :

forall(terrain contenant des galinettes, groupe de chasseurs avec chacun-e a minima 1 balle)
La partie de chasse doit démarrer avec succès

Ou encore :

forall(terrain sans galinettes, groupe de chasseurs avec au moins 1 chasseur sans balle)
La partie de chasse ne démarre pas car "pas de galinettes sur le terrain"

Cas passant

  • On commence par ajouter la dépendance sur FsCheck

dotnet add package FsCheck.xUnit
  • On ajoute 1 test dans la classe DemarrerUnePartieDeChasse

[Property] // Annotation pour xUnit
public Property Sur1TerrainAvecGalinettesEtAuMoins1ChasseurAvecTousDesBalles() =>
    Prop.ForAll(
        null,
        (terrain, chasseurs) => true
    );
  • On doit travailler sur la génération d'un terrain valide

private static Arbitrary<(string nom, int nbGalinettes)> terrainGenerator()
    => (from nom in Arb.Generate<string>()
        // A minima 1 galinette sur le terrain
        from nbGalinette in Gen.Choose(1, int.MaxValue)
        select (nom, nbGalinette)).ToArbitrary();
  • Ensuite, on travaille sur la manière de générer des chasseurs valides

private static Arbitrary<(string nom, int nbBalles)> chasseurGenerator()
    => (from nom in Arb.Generate<string>()
        // A minima 1 balle
        from nbBalles in Gen.Choose(1, int.MaxValue)
        select (nom, nbBalles)).ToArbitrary();
  • On définit comment construire 1 groupe de chasseurs :

private static Arbitrary<FSharpList<(string nom, int nbBalles)>> groupeDeChasseursGenerator()
    => // On définit le nombre de chasseurs dans le groupe [1; 1000]
        (from nbChasseurs in Gen.Choose(1, 1_000)
        // On utilise le nombre de chasseurs pour générer le bon nombre de chasseurs
        select chasseurGenerator().Generator.Sample(1, nbChasseurs)).ToArbitrary();
  • On utilise les générateurs dans la propriété

[Property]
public Property Sur1TerrainAvecGalinettesEtAuMoins1ChasseurAvecTousDesBalles() =>
    Prop.ForAll(
        terrainGenerator(),
        groupeDeChasseursGenerator(),
        (terrain, chasseurs) =>
        {
            var savedId = PartieDeChasseService.Demarrer(
                terrain,
                chasseurs.ToList()
            );
            return Repository.SavedPartieDeChasse()!.Id == savedId;
        }
    );
  • On lance le test et vérifie les inputs générés

  • On a alors 1 seul test qui va en fait valoir l'écriture de 100 tests

Pour ce test on a alors :

private static Arbitrary<(string nom, int nbGalinettes)> terrainGenerator()
    => (from nom in Arb.Generate<string>()
        from nbGalinette in Gen.Choose(1, int.MaxValue)
        select (nom, nbGalinette)).ToArbitrary();

private static Arbitrary<(string nom, int nbBalles)> chasseurGenerator()
    => (from nom in Arb.Generate<string>()
        from nbBalles in Gen.Choose(1, int.MaxValue)
        select (nom, nbBalles)).ToArbitrary();

private static Arbitrary<FSharpList<(string nom, int nbBalles)>> groupeDeChasseursGenerator()
    => (from nbChasseurs in Gen.Choose(1, 1_000)
        select chasseurGenerator().Generator.Sample(1, nbChasseurs)).ToArbitrary();

[Property]
public Property Sur1TerrainAvecGalinettesEtAuMoins1ChasseurAvecTousDesBalles() =>
    Prop.ForAll(
        terrainGenerator(),
        groupeDeChasseursGenerator(),
        (terrain, chasseurs) => DémarreLaPartieAvecSuccès(terrain, chasseurs));

private bool DémarreLaPartieAvecSuccès((string nom, int nbGalinettes) terrain,
    IEnumerable<(string nom, int nbBalles)> chasseurs)
    => PartieDeChasseService.Demarrer(
        terrain,
        chasseurs.ToList()) == Repository.SavedPartieDeChasse()!.Id;

Cette propriété est complémentaire au test DemarrerUnePartieDeChasse.AvecPlusieursChasseurs qui valide la bonne instantiation de l'objet PartieDeChasse à partir d'un exemple.

Dans la propriété, on valide que la partie démarre sans se soucier de l'instantiation de la partie au sens du Domain.

Cas non passant

Concernant les cas non-passants, nous allons les remplacer par des tests de propriétés.

  • On commence par ce test :

[Fact]
public void SansChasseurs()
    => ExecuteAndAssertThrow<ImpossibleDeDémarrerUnePartieSansChasseur>(
        s => s.Demarrer(("Pitibon sur Sauldre", 3), new List<(string, int)>()),
        p => p.Should().BeNull()
    );
  • On le change en Property

[Property]
public Property SansChasseursSurNimporteQuelTerrainRicheEnGalinette()
    => Prop.ForAll(
        // On réutilise le générateur de terrains riches en galinettes
        terrainGenerator(),
        terrain =>
        {
            // On va refactorer pour réutiliser cette logique
            try
            {
                PartieDeChasseService.Demarrer(
                    terrain,
                    new List<(string, int)>());

                return false;
            }
            catch (ImpossibleDeDémarrerUnePartieSansChasseur)
            {
                return Repository.SavedPartieDeChasse() == null;
            }
        });
  • On crée une méthode qui va lancer l'action de manière safe et valider la lancement de l'exception

protected bool MustFailWith<TException>(Action action, Func<PartieDeChasse?, bool>? assert = null)
    where TException : Exception
{
    try
    {
        action();
        return false;
    }
    catch (TException)
    {
        return assert?.Invoke(SavedPartieDeChasse()) ?? true;
    }
}

private bool EchoueAvec<TException>(
                (string nom, int nbGalinettes) terrain,
                IEnumerable<(string nom, int nbBalles)> chasseurs,
                Func<PartieDeChasse?, bool>? assert = null) where TException : Exception
                => MustFailWith<TException>(() => PartieDeChasseService.Demarrer(terrain, chasseurs.ToList()), assert);

[Property]
public Property SansChasseursSurNimporteQuelTerrainRicheEnGalinette()
    => ForAll(
        terrainRicheEnGalinettesGenerator(),
        terrain =>
            EchoueAvec<ImpossibleDeDémarrerUnePartieSansChasseur>(
                terrain,
                PasDeChasseurs,
                savedPartieDeChasse => savedPartieDeChasse == null)

Autres propriétés

D'autres propriétés pourraient être définies sur d'autres classes de tests.

Nouveau rapport SonarCloud disponible ici.

Reflect

  • Que pensez vous de cette technique ?

  • Quelles sont ses avantages ?

  • Comment vous pourriez l'utiliser ?

Last updated