Nous avons ajouté un nouveau type de test d’automatisation à notre framework de tests d’automatisation existant. Ce nouveau type est connu sous le nom de Spec. "Spec" est un terme désignant un test construit selon la méthodologie de programmation pilotée par le comportement (BDD). Il s'agit d'une méthodologie très courante utilisée dans les tests de développement Web, que nous avons adaptée à notre framework C++.
Il y existe plusieurs raisons de commencer à rédiger des Specs, notamment parce qu’elles :
- Sont auto-documentées
- Sont fluides et souvent plus DRY
DRY (Don't Repeat Yourself/Ne vous répétez pas) - Sont beaucoup plus pratiques lors de l'écriture de code de test à thread ou latent
- Isoler les attentes (tests)
- Peuvent être utilisées pour presque tous les types de tests (fonctionnels, d'intégration et d’unités)
Comment configurer une spécification
Il existe deux méthodes pour définir l'en-tête de vos spécifications, et toutes deux sont similaires à la méthode existante que nous utilisons pour définir les types de test.
La méthode la plus simple est d'utiliser la macro DEFINE_SPEC, qui prend exactement les mêmes paramètres que toutes les autres macros définies par le test.
DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
//@todo écrire mes attentes ici
}
La seule autre alternative est d'utiliser les macros BEGIN_DEFINE_SPEC et END_DEFINE_SPEC. Ces macros vous permettent de définir vos propres membres dans le cadre du test. Comme vous le verrez dans la section suivante, il est utile d'avoir des éléments relatifs à ce pointeur.
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
//@todo écrire mes attentes ici
}
La seule autre remarque est que vous devez écrire l'implémentation du membre Define() de votre classe Spec, au lieu du membre RunTests() - comme vous le feriez pour n'importe quel autre type de test.
Les spécifications doivent être définies dans un fichier avec l’extension .spec.cpp et ne doivent pas porter la mention "Test" dans le nom. Par exemple, la classe FItemCatalogService peut comporter les fichiers ItemCatalogService.h, ItemCatalogService.cpp et ItemCatalogService.spec.cpp.
Il s’agit d’une directive suggérée et non d’une restriction technique.
Comment définir vos attentes
Une grande partie de la BDD consiste à tester les attentes d’une API publique au lieu de tester une implémentation spécifique. Cela rend votre test beaucoup moins fragile et donc plus facile à entretenir, et plus susceptible de fonctionner en présence de plusieurs implémentations différentes de la même API.
Dans une Spec, vous définissez vos attentes via l'utilisation de deux fonctions principales différentes : Describe() et It().
Describe
la fonction Describe() est utilisée comme un moyen d'encadrer des attentes compliquées, de façon à ce qu'elles soient plus lisibles et plus DRY. L'utilisation de Describe() rend votre code plus DRY en fonction de ses interactions avec d'autres fonctions de support telles que BeforeEach() et AfterEach(), qui sont abordées ci-dessous :
void Describe(const FString& Description, TFunction<void()> DoWork)
Describe() prend une chaîne qui décrit l'étendue des attentes qu'elle contient, et un lambda qui définit ces attentes.
Il est possible d'imbriquer des fonctions Describe() en plaçant une fonction Describe() à l'intérieur d’une autre.
Gardez à l’esprit que la fonction Describe() n’est pas un test et qu’elle n’est pas exécutée lors d’une exécution de test réelle. Elle n’est exécutée qu'une seule fois lors de la première définition des attentes (ou des tests) dans la Spec.
Il
L’instruction It() est le morceau de code qui définit une attente réelle de la Spec. Vous pouvez appeler l’instruction It() depuis la méthode racine Define() ou depuis n'importe quel lambda de fonction Describe(). De préférence, l’instruction It() ne doit être utilisée que pour affirmer l'attente, mais elle peut également être utilisée pour effectuer la dernière partie de la configuration du scénario testé.
En général, il est recommandé de commencer une chaîne de description d'appel de l’instruction It() par le mot "should" (doit), qui implique "it should" (elle doit).
Définition d'une attente de base
Vous trouverez ci-après un exemple d’application de tout cela pour définir une attente très simple :
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
Describe("Execute()", [ceci]()
{
It("doit renvoyer true en cas de réussite", [ceci]()
{
TestTrue("Execute", CustomClass->Execute());
});
It("doit renvoyer false en cas d’échec", [ceci]()
{
TestFalse("Execute", CustomClass->Execute());
});
});
}
Comme vous pouvez le constater, cela permet aux tests d’être auto-documentés, en particulier si le programmeur prend le temps de décrire correctement l’attente sans combiner des attentes disparates. L'objectif est que la combinaison de tous les appels de Describe() et It() crée une phrase globalement lisible, par exemple :
Execute() doit renvoyer true en cas de succès
Execute() doit renvoyer false en cas d’échec
Voici un exemple plus complexe de ce à quoi ressemble actuellement une Spec avancée dans l'interface utilisateur du test d'automatisation :
Dans cet exemple, Driver, Element et Click sont chacun un appel de Describe(), les différents messages "doit…" étant définis par un appel de It().
Chacun de ces appels de It() devient un test individuel à exécuter, et peut donc être exécuté de manière isolée si l'un échoue contrairement aux autres. Cela facilite la maintenance des tests, car il est plus facile de les déboguer. De plus, étant donné que les tests sont auto-documentés et isolés, lorsqu'un test échoue, la personne qui lit le rapport de test a une compréhension beaucoup plus précise de l’erreur - plutôt que de simplement savoir qu'un très grand compartiment nommé Core a échoué. Cela signifie que les problèmes sont transmis plus rapidement à la personne adéquate et que moins de temps est consacré à l'enquête.
Enfin, en cliquant sur l'un des tests ci-dessus, vous serez directement redirigé vers l'instruction It() qui le définit.
Comment convertir une attente de Spec en un test
Ce qui suit est une explication détaillée ; cependant, la compréhension du comportement sous-jacent du type de test Spec rendra certaines des fonctionnalités complexes suivantes plus faciles à comprendre.
Le type de test Spec exécute la fonction racine Define() une fois, mais pas avant qu'elle ne soit nécessaire. Au fur et à mesure de son exécution, il collecte tous les lambda non liés à la fonction Describe. Une fois l’exécution de Define() terminée, elle revient sur tous les lambdas ou blocs de code qu’elle a collectés et génère une matrice de commandes latentes pour chaque instruction It().
Par conséquent, chaque bloc de code lambda BeforeEach(), It() et AfterEach() est rassemblé dans une chaîne d'exécution pour un seul test. Lorsqu'il est demandé d'exécuter un test spécifique, le type de test Spec met en file d'attente toutes les commandes de ce test particulier pour exécution. Lorsque cela se produit, chaque bloc ne continue pas jusqu'à ce que le bloc précédent ait signalé qu'il a terminé son exécution.
Fonctionnalités supplémentaires
Le type de test Spec offre plusieurs autres fonctionnalités qui facilitent l'écriture de tests complexes. En particulier, il supprime généralement la nécessité d'utiliser directement le système de commande latente du framework de test d’automatisation, qui est à la fois performant et encombrant.
Vous trouverez ci-après une liste des fonctionnalités prises en charge par le type de test Spec qui peuvent être utiles dans des scénarios plus complexes :
- BeforeEach et AfterEach
- AsyncExecution
- Achèvement latent
- Tests paramétrisés
- Redéfinir
- Désactivation de tests
BeforeEach et AfterEach
BeforeEach() et AfterEach() sont des fonctions essentielles pour écrire des tests allant au-delà des Spec les plus basiques. BeforeEach() permet d'exécuter du code avant l’exécution du code It() suivant. AfterEach() fait la même chose, mais exécute le code après l'exécution du code It().
N'oubliez pas que chaque "test" n'est composé que d'un seul appel de It().
Exemple :
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
FIN_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("Une Spec utilisant BeforeEach et AfterEach", [ceci]()
{
BeforeEach([ceci]()
{
RunOrder = TEXT("A");
});
It("exécutera le code avant chaque Spec dans la fonction Describe et après chaque Spec de la fonction Describe", [ceci]()
{
TestEqual("RunOrder", RunOrder, TEXT("A"));
});
AfterEach([ceci]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("AZ"));
});
});
}
Dans notre exemple, les blocs de code sont exécutés de haut en bas, en définissant BeforeEach(), puis It(), puis AfterEach(). Bien que cela ne soit pas une obligation, nous vous suggérons de conserver cet ordre logique des appels. Mais vous pourriez mélanger l'ordre des trois appels ci-dessus et le résultat produirait toujours le même test.
Dans l'exemple ci-dessus, il s'agit également de vérifier une attente dans AfterEach(), ce qui est très anormal et constitue un effet secondaire du type de test Spec lui-même. Par conséquent, nous ne recommandons pas d'utiliser AfterEach() pour autre chose que le nettoyage.
Vous pouvez également faire plusieurs appels à BeforeEach() et AfterEach(), et elles seront appelées dans l'ordre dans lequel elles sont définies. Comme avec le premier appel de BeforeEach() exécuté avant le deuxième appel de BeforeEach(), AfterEach() se comporte à peu près de la même manière — le premier appel étant exécuté avant l'appel suivant.
BeforeEach([ceci]()
{
RunOrder = TEXT("A");
});
BeforeEach([ceci]()
{
RunOrder += TEXT("B");
});
It("exécutera le code avant chaque Spec dans la fonction Describe et après chaque Spec de la fonction Describe", [ceci]()
{
TestEqual("RunOrder", RunOrder, TEXT("AB"));
});
AfterEach([ceci]()
{
RunOrder += TEXT("Y");
TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});
AfterEach([ceci]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});
De plus, BeforeEach() et AfterEach() sont affectés par l’étendue de Describe() dans laquelle elles sont appelées. Les deux ne s'exécuteront que pour les appels de It() qui sont dans l’étendue dans laquelle elles sont également appelées.
Voici un exemple compliqué, avec des appels mal ordonnés, qui fonctionnent tous correctement.
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
FIN_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("Une Spec utilisant BeforeEach et AfterEach", [ceci]()
{
BeforeEach([ceci]()
{
RunOrder = TEXT("A");
});
AfterEach([ceci]()
{
RunOrder += TEXT("Z");
// Peut entraîner
// TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));
// ou ceci, en fonction de l’instruction It() en cours d’exécution
// TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
});
BeforeEach([ceci]()
{
RunOrder += TEXT("B");
});
Describe("tout en étant imbriquée dans une autre fonction Describe", [ceci]()
{
AfterEach([ceci]()
{
RunOrder += TEXT("Y");
});
Il("exécutera tous les blocs BeforeEach et tous les blocs AfterEach", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABC"));
});
BeforeEach([ceci]()
{
RunOrder += TEXT("C");
});
Describe("tout en étant imbriquée dans une fonction Describe supplémentaire", [ceci]()
{
It("exécute tous les blocs BeforeEach et tous les blocs AfterEach", [ceci]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
});
AfterEach([ceci]()
{
RunOrder += TEXT("X");
});
BeforeEach([ceci]()
{
RunOrder += TEXT("D");
});
});
});
});
}
Exécution asynchrone
Le type de test Spec vous permet également de définir facilement comment un seul bloc de code doit être exécuté. Cela se fait simplement en passant le type EAsyncExecution approprié dans la version surchargée de BeforeEach(), It() et/ou AfterEach().
Exemple :
BeforeEach(EAsyncExecution::TaskGraph, [ceci]()
{
// configurer quelque chose
));
Il("devrait faire quelque chose de génial", EAsyncExecution::ThreadPool, [this]()
{
// faire des choses
});
AfterEach(EAsyncExecution::Thread, [ceci]()
{
// détruire quelque chose
));
Chacun des blocs de code ci-dessus s'exécute différemment, mais dans un ordre séquentiel garanti. Le bloc BeforeEach() s'exécute comme une tâche du TaskGraph, le bloc It() s'exécute sur un thread ouvert dans le pool de threads, et le bloc AfterEach() lance son propre thread dédié juste pour exécuter un bloc de code.
Ces options sont extrêmement pratiques lorsqu’il faut simuler des scénarios sensibles aux threads, comme avec le pilote d'automatisation.
La fonctionnalité AsyncExecution peut être combinée avec la fonctionnalité Latent Completion.
Achèvement latent
Il faut parfois écrire un test qui doit effectuer une action qui nécessite plusieurs images, comme lors de l'exécution d'une requête. Dans ces scénarios, vous pouvez utiliser les membres surchargés LatentBeforeEach(), LatentIt() et LatentAfterEach(). Chacun de ces membres est identique aux versions non latentes, sauf que leurs lambdas prennent un délégué simple appelé Done.
Lors de l'utilisation des versions latentes, le type de test Spec ne poursuit pas l'exécution vers le bloc de code suivant de la séquence de test jusqu'à ce que le bloc de code latent en cours d'exécution appelle le délégué Done.
LatentIt("doit renvoyer les éléments disponibles", [ceci](const FDoneDelegate& Done)
{
BackendService->QueryItems(this, &FMyCustomSpec::HandleQueryItemComplete, Done);
});
void FMyCustomSpec::HandleQueryItemsComplete(const TArray<FItem>& Items, FDoneDelegate Done)
{
TestEqual("Items.Num() == 5", Items.Num(), 5);
Done.Execute();
}
Comme vous pouvez le voir dans l'exemple, vous pouvez transmettre le délégué Done sous forme de données utiles à d'autres rappels pour le rendre accessible au code latent. Ainsi, lorsque le test ci-dessus est exécuté, il ne continuera pas à exécuter des blocs de code AfterEach() pour le bloc It() jusqu'à ce que le délégué Done soit exécuté, même si le bloc de code It() a déjà terminé son exécution.
La fonctionnalité Latent Completion peut être combinée avec la fonctionnalité AsyncExecution.
Tests paramétrisés
Il peut parfois s’avérer nécessaire de créer des tests basés sur des données. Et parfois, cela implique de lire des entrées à partir d'un fichier et de générer des tests à partir de ces entrées. D'autres fois, il s'agit simplement de réduire la duplication du code. Quoi qu'il en soit, le type de test Spec permet d'effectuer des tests paramétrisés de manière très naturelle.
Décrire("Mathématiques de base", [this]()
{
for (int32 Index = 0; Index < 5; Index++)
{
It(FString::Printf(TEXT("doit résoudre %d + %d = %d"), Index, 2, Index + 2), [ceci, Index]()
{
TestEqual(FString::Printf(TEXT("%d + %d = %d"), Index, 2, Index + 2), Index + 2, Index + 2);
});
}
});
Comme vous pouvez le voir dans l'exemple ci-dessus, tout ce que vous devez faire pour créer des tests paramétrisés est d'appeler de manière dynamique les autres fonctions Spec en transmettant les données paramétrisées dans le cadre des données utiles lambda tout en générant une description unique.
Dans certains cas, l'utilisation de tests paramétrisés peut simplement créer un foisonnement de tests. Il peut être raisonnable d’exécuter simplement tous les scénarios de l'entrée dans le cadre d’un seul test. Vous devez prendre en compte le nombre d'entrées et les tests résultants qui sont produits. Le principal avantage de la création de vos tests basés sur des données de manière paramétrisée est que chaque test peut être exécuté de manière isolée, ce qui facilite la reproduction.
Redéfinir
Lorsque l'on travaille avec des tests paramétrés, il peut parfois être pratique, au moment de l'exécution, d'apporter une modification à un fichier externe pilotant les entrées et d'actualiser automatiquement les tests. Redefine() est un membre du type de test Spec, qui, lorsqu'il est appelé, réexécute le processus Define(). Cela entraîne le regroupement et l’assemblage de tous les blocs de code des tests.
La méthode la plus pratique pour faire ce qui précède serait de créer un bout de code qui surveille les changements du fichier d’entrée et appelle Redefine() sur le test, le cas échéant.
Désactivation des tests
Chaque membre Describe(), BeforeEach(), It() et AfterEach() du type de test Spec a une variante avec un préfixe 'x'. Par exemple, xDescribe(), xBeforeEach(), xIt() et xAfterEach(). Ces variantes sont un moyen plus simple de désactiver un bloc de code ou Describe(). Si xDescribe() est utilisé, alors tout le code dans xDescribe() est également désactivé.
Cela peut être plus simple que de commenter les attentes nécessitant une itération.
Exemples avancés
Vous pouvez trouver des exemples avancés du type de test Spec dans le répertoire Engine/Plugins/Tests/AutomationDriverTests/Source/AutomationDriverTests/Private/AutomationDriver.spec.cpp. Cette Spec comprend actuellement plus de cent vingt attentes et utilise la plupart des fonctionnalités avancées à un moment donné.
Notre équipe Launcher utilise également le framework Spec de manière avancée, notamment avec les Specs développées autour de BuildPatchServices.