Skip to content

Latest commit

 

History

History
399 lines (312 loc) · 12.7 KB

05.approve-everything.md

File metadata and controls

399 lines (312 loc) · 12.7 KB

"Approve Everything"

Il y a quelques tests pour lesquels nous avons énormément de lignes d'assertions. Nous allons les retravailler afin de les transformer en Approval Tests.

  • Prendre du temps pour comprendre ce qui se cache derrière cette notion d'Approval Testing
  • Identifier des tests sur lesquels on pourrait utiliser cette technique
  • Refactorer un test existant en utilisant la librairie Verify

Step 5 - Approve Everything

Identification des tests

On pourrait utiliser cette technique pour les tests suivants :

  • DemarrerUnePartieDeChasse.AvecPlusieursChasseurs
    • Limitera les asserts à une seule ligne
    • Moins de maintenance et assertions plus lisibles
  • ConsulterStatus : QuandLaPartieVientDeDémarrer / QuandLaPartieEstTerminée
  • ScenarioTests.DéroulerUnePartie
    • On valide le contenu d'un string
    • Cela évitera de stocker ce string dans le code (sera stocké sous forme de ressource)

Refactorer ScenarioTests.DéroulerUnePartie

  • On commence par ajouter la dépendance sur notre librairie d'Approval Testing
dotnet add package Verify.xUnit
  • On peut ensuite extraire le contenu de l'assertion dans un fichier "Approved" ici "verified"

    • On crée un fichier appelé : ScenarioTests.DéroulerUnePartie.verified.txt ([Nom de la classe de tests].[Nom du test].verified.txt)
    • C'est sur base de ce fichier que l'assertion se fera via Verify Approved Content
  • On transforme le test en Approval Test en

    • Ajoutant l'annotation UsesVerify sur la classe de test
    • Changeant la méthode de test pour que celle-ci renvoie une Task
[UsesVerify]
public class ScenarioTests
{
    [Fact]
    public Task DéroulerUnePartie()
    {
        var time = new DateTime(2024, 4, 25, 9, 0, 0);
        var repository = new PartieDeChasseRepositoryForTests();
        var service = new PartieDeChasseService(repository, () => time);
        var chasseurs = new List<(string, int)>
        {
            ("Dédé", 20),
            ("Bernard", 8),
            ("Robert", 12)
        };
        var terrainDeChasse = ("Pitibon sur Sauldre", 4);
        var id = service.Demarrer(
            terrainDeChasse,
            chasseurs
        );

        time = time.Add(TimeSpan.FromMinutes(10));
        service.Tirer(id, "Dédé");

        time = time.Add(TimeSpan.FromMinutes(30));
        service.TirerSurUneGalinette(id, "Robert");

        time = time.Add(TimeSpan.FromMinutes(20));
        service.PrendreLapéro(id);

        time = time.Add(TimeSpan.FromHours(1));
        service.ReprendreLaPartie(id);

        time = time.Add(TimeSpan.FromMinutes(2));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromMinutes(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromMinutes(1));
        service.TirerSurUneGalinette(id, "Dédé");

        time = time.Add(TimeSpan.FromMinutes(26));
        service.TirerSurUneGalinette(id, "Robert");

        time = time.Add(TimeSpan.FromMinutes(10));
        service.PrendreLapéro(id);

        time = time.Add(TimeSpan.FromMinutes(170));
        service.ReprendreLaPartie(id);

        time = time.Add(TimeSpan.FromMinutes(11));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));
        service.Tirer(id, "Bernard");

        time = time.Add(TimeSpan.FromSeconds(1));

        try
        {
            service.Tirer(id, "Bernard");
        }
        catch (TasPlusDeBallesMonVieuxChasseALaMain)
        {
        }

        time = time.Add(TimeSpan.FromMinutes(19));
        service.TirerSurUneGalinette(id, "Robert");

        time = time.Add(TimeSpan.FromMinutes(30));
        service.TerminerLaPartie(id);

        // retourne le résultat de la méthode `Verify`
        return Verify(service.ConsulterStatus(id));
    }
}

Le test passe du premier coup 👌

On va faire en sorte de le faire passer au rouge : ne jamais croire un test qu'on a pas vu échouer...

Pour cela le plus simple est de changer le fichier verified.

Notre Approval Test échoue, notre outil de comparaison de fichier va s'ouvrir : File compare

Dès lors nous avons une arborescence de fichiers ressemblant à cela : Files

Un élément important quand on utilise une librairie de ce genre, ajouter les fichiers received dans le fichier .gitignore :

# Verify
*.received.txt

Félicitations, notre premier test passe et on peut se fier à lui.

En revanche, le test n'est pas très lisible / maintenable :

  • Beaucoup de duplication
  • try / catch vide
  • Méthode de plus de 80 loc

On va y appliquer la fameuse règle du boyscout. Boy scout rule

Boy Scout Rule

  • On commence par extraire des champs à partir du test via notre IDE Introduce Field

  • Puis on configure l'extraction Introduce Field details

  • Le résultat est :

public class ScenarioTests
{
    private DateTime _time = new(2024, 4, 25, 9, 0, 0);
    private readonly PartieDeChasseService _service;

    public ScenarioTests()
    {
        _service = new PartieDeChasseService(
            new PartieDeChasseRepositoryForTests(),
            () => _time
        );
    }
    ....
}
  • On va utiliser le CommandBuilder également
    • Afin de supprimer les string hardcodés
var command = DémarrerUnePartieDeChasse()
    .Avec(("Dédé", 20), ("Bernard", 8), ("Robert", 12))
    .SurUnTerrainRicheEnGalinettes(4);

var id = _service.Demarrer(
    command.Terrain,
    command.Chasseurs
);
  • On va ensuite supprimer la duplication en faisant une extraction des constantes : Bernard, Robert, Dédé, ChasseurInconnu
    • Pour pouvoir les utiliser dans cette classe de test également
    • On les place dans un fichier Data

Move to type

  • Notre test ressemble désormais à cela
[Fact]
public Task DéroulerUnePartie()
{
    var command = DémarrerUnePartieDeChasse()
        .Avec((Data.Dédé, 20), (Data.Bernard, 8), (Data.Robert, 12))
        .SurUnTerrainRicheEnGalinettes(4);

    var id = _service.Demarrer(
        command.Terrain,
        command.Chasseurs
    );

    _time = _time.Add(TimeSpan.FromMinutes(10));
    _service.Tirer(id, Data.Dédé);

    _time = _time.Add(TimeSpan.FromMinutes(30));
    _service.TirerSurUneGalinette(id, Data.Robert);
    
    ....
}
  • On va extraire une méthode à partir de cela en identifiant les similitudes et différences
// Ajoute du temps à _time
_time = _time.Add(TimeSpan.FromMinutes(30));
// Appelle d'une méthode sur le service
_service.TirerSurUneGalinette(id, Data.Robert);

// Ajoute du temps à _time
_time = _time.Add(TimeSpan.FromMinutes(20));
// Appelle d'une méthode sur le service
_service.PrendreLapéro(id);
  • On prépare notre extraction en décomposant le code ci-dessus en :
// Extract variable
var timeToAdd = TimeSpan.FromMinutes(10);
// Refactor l'appelle en Action
var act = () => _service.Tirer(id, Data.Dédé);

_time = _time.Add(timeToAdd);
act();
  • Extraction de la méthode Extract method

  • Puis on configure la méthode Configure Extract Method

  • On l'utilise partout en s'assurant que notre test reste vert

    • En rendant également safe l'appelle à la méthode act
[UsesVerify]
public class ScenarioTests
{
    private DateTime _time = new(2024, 4, 25, 9, 0, 0);
    private readonly PartieDeChasseService _service;

    public ScenarioTests()
    {
        _service = new PartieDeChasseService(
            new PartieDeChasseRepositoryForTests(),
            () => _time
        );
    }

    [Fact]
    public Task DéroulerUnePartie()
    {
        var command = DémarrerUnePartieDeChasse()
            .Avec((Data.Dédé, 20), (Data.Bernard, 8), (Data.Robert, 12))
            .SurUnTerrainRicheEnGalinettes(4);

        var id = _service.Demarrer(
            command.Terrain,
            command.Chasseurs
        );

        After(10.Minutes(), () => _service.Tirer(id, Data.Dédé));
        After(30.Minutes(), () => _service.TirerSurUneGalinette(id, Data.Robert));
        After(20.Minutes(), () => _service.PrendreLapéro(id));
        After(1.Hours(), () => _service.ReprendreLaPartie(id));
        After(2.Minutes(), () => _service.Tirer(id, Data.Bernard));
        After(1.Minutes(), () => _service.Tirer(id, Data.Bernard));
        After(1.Minutes(), () => _service.TirerSurUneGalinette(id, Data.Dédé));
        After(26.Minutes(), () => _service.TirerSurUneGalinette(id, Data.Robert));
        After(10.Minutes(), () => _service.PrendreLapéro(id));
        After(170.Minutes(), () => _service.ReprendreLaPartie(id));
        After(11.Minutes(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(1.Seconds(), () => _service.Tirer(id, Data.Bernard));
        After(19.Minutes(), () => _service.TirerSurUneGalinette(id, Data.Robert));
        After(30.Minutes(), () => _service.TerminerLaPartie(id));

        return Verify(_service.ConsulterStatus(id));
    }

    private void After(TimeSpan time, Action act)
    {
        _time = _time.Add(time);
        try
        {
            act();
        }
        catch
        {
            // ignored
        }
    }
}

Refactorer DemarrerUnePartieDeChasse.AvecPlusieursChasseurs

  • On commence par changer le test
    • Ici on va "approuver" la représentation textuelle de la PartieDeChasse
[Fact]
public Task AvecPlusieursChasseurs()
{
    var command = DémarrerUnePartieDeChasse()
        .Avec((Data.Dédé, 20), (Data.Bernard, 8), (Data.Robert, 12))
        .SurUnTerrainRicheEnGalinettes();

    PartieDeChasseService.Demarrer(
        command.Terrain,
        command.Chasseurs
    );

    return Verify(Repository.SavedPartieDeChasse());
}
  • Voici le résultat
    • Par défaut, Verify va scrubber les donées non déterministes (DateTime et Guid ici)

Verify scrubbers

  • Concernant la date, on perd 1 assertion faites dans le test avant refactoring
    • On change la configuration pour ce test
[Fact]
public Task AvecPlusieursChasseurs()
{
    var command = DémarrerUnePartieDeChasse()
        .Avec((Data.Dédé, 20), (Data.Bernard, 8), (Data.Robert, 12))
        .SurUnTerrainRicheEnGalinettes();

    PartieDeChasseService.Demarrer(
        command.Terrain,
        command.Chasseurs
    );

    return Verify(Repository.SavedPartieDeChasse())
        // On précise qu'on ne veut pas "scubber" les DateTime
        .DontScrubDateTimes();
}
  • On peut maintenant approver le résultat du test qui ressemble à cela

Verify scrubbers

Impact du refactoring des tests

Codescene

Après les refactorings des tests, on peut lancer une analyse codescene pour vérifier leur impact sur l'état de notre code base : code scene health

Nous sommes passé d'une Code Health de 8,4 à 9,8 👌

Les hotspots ont changé de taille (rouges car les commits sont très récents) code scene hotspots

Il reste 1 refactoring target : PartieDeChasseService code scene target

SonarCloud

Rapport disponible ici.

Reflect

  • Que pensez vous de cette technique ?
    • Quels autres cas d'utilisation pouvez-vous identifier ?
  • Qu'est-ce que le scrubbing ?

Approve everything