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
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)
- On valide le contenu d'un
- 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 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
- Ajoutant l'annotation
[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 :
Dès lors nous avons une arborescence de fichiers ressemblant à cela :
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.
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
- Afin de supprimer les
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
- 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();
-
On l'utilise partout en s'assurant que notre test reste vert
- En rendant également
safe
l'appelle à la méthodeact
- En rendant également
[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
}
}
}
- On commence par changer le test
- Ici on va "approuver" la représentation textuelle de la
PartieDeChasse
- Ici on va "approuver" la représentation textuelle de la
[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
etGuid
ici)
- Par défaut,
- 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
Après les refactorings des tests, on peut lancer une analyse codescene
pour vérifier leur impact sur l'état de notre code base :
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)
Il reste 1 refactoring target
: PartieDeChasseService
Rapport disponible ici.
- Que pensez vous de cette technique ?
- Quels autres cas d'utilisation pouvez-vous identifier ?
- Qu'est-ce que le
scrubbing
?