Wir haben unserem bestehenden Automatisierungs-Test-Framework eine neue Art von Automatisierungstest hinzugefügt. Diese neue Art wird als Spec bezeichnet. „Spec“ ist ein Begriff für einen Test, der nach der Behavior Driven Design (BDD)-Methodik erstellt wird. Es ist eine sehr verbreitete Methodik im Web-Development-Testing, die wir für unser C++-Framework adaptiert haben.
Es gibt mehrere Gründe, warum Sie mit dem Schreiben von Specs beginnen sollten, unter anderem:
- Sie sind selbstdokumentierend
- Sind fließend und oft DRYer
DRY (Don't Repeat Yourself) - Sie sind deutlich einfacher beim Schreiben von Thread- oder Latenz-Testcode
- Sie isolieren Erwartungen (Tests)
- Sie können für nahezu alle Testarten verwendet werden (Funktional, Integration und Unit)
Einrichtung einer Spec
Es gibt zwei Methoden zur Definition des Headers für Ihre Spec, und beide ähneln der bestehenden Methode, die wir zur Definition von Testtypen verwenden.
Die einfachste Methode ist die Verwendung des DEFINE_SPEC-Makros, das exakt die gleichen Parameter wie alle anderen Testdefinitionsmakros verwendet.
DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
//@todo Erwartungen hier formulieren
}
Die einzige Alternative ist die Verwendung der Makros BEGIN_DEFINE_SPEC und END_DEFINE_SPEC. Diese Makros ermöglichen es Ihnen, Ihre eigenen Komponenten als Teil des Tests zu definieren. Wie Sie im nächsten Abschnitt sehen werden, ist es wertvoll, Elemente relativ zum this-Pointer zu haben.
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
//@todo Erwartungen hier formulieren
}
Der einzige weitere Hinweis ist, dass Sie die Implementierung für das Define()-Mitglied Ihrer Spec-Klasse schreiben müssen, anstelle des RunTests()-Mitglieds – wie Sie es für jeden anderen Testtyp tun würden.
Specs sollten in einer Datei mit der Erweiterung .spec.cpp definiert werden und nicht das Wort „Test“ im Namen tragen. Ein Beispiel: Die Klasse FItemCatalogService könnte die Dateien ItemCatalogService.h, ItemCatalogService.cpp und ItemCatalogService.spec.cpp enthalten.
Dies ist eine Empfehlung und keine technische Einschränkung.
Wie Sie Ihre Erwartungen definieren
Ein wichtiger Aspekt von BDD ist, dass Sie anstelle einer spezifischen Implementierung die Erwartungen einer öffentlichen API testen. Dies macht Ihren Test wesentlich robuster und somit wartungsfreundlicher, und erhöht die Wahrscheinlichkeit, dass er auch bei verschiedenen Implementierungen derselben API funktioniert.
In einer Spec definieren Sie Ihre Erwartungen durch die Verwendung von zwei verschiedenen Hauptfunktionen, Describe() und It().
Beschreiben
Describe() wird verwendet, um komplexe Erwartungen zu strukturieren, sodass sie lesbarer und DRYer sind. Die Verwendung von Describe() macht Ihren Code DRYer durch das Zusammenspiel mit anderen unterstützenden Funktionen wie BeforeEach() und AfterEach(), was im Folgenden behandelt wird:
void Describe(const FString& Description, TFunction<void()> DoWork)
Describe() nimmt einen String entgegen, der den Umfang der darin enthaltenen Erwartungen beschreibt, und ein Lambda, das diese Erwartungen definiert.
Sie können Describe() kaskadieren, indem Sie ein Describe() in ein anderes Describe() einfügen.
Beachten Sie, dass Describe() kein Test ist und während eines tatsächlichen Testlaufs nicht ausgeführt wird. Sie werden nur einmal ausgeführt, wenn die Erwartungen (oder Tests) innerhalb der Spec zum ersten Mal definiert werden.
It
It() ist der Codeabschnitt, der eine tatsächliche Erwartung für die Spec definiert. Sie können It() von der Stamm-Define()-Methode oder innerhalb jedes Describe()-Lambdas aufrufen. It() sollte idealerweise nur zur Überprüfung der Erwartung verwendet werden, kann aber auch für die letzten Einrichtungsschritte des zu testenden Szenarios genutzt werden.
Generell gilt als Best Practice, einen It()-Aufrufbeschreibungs-String mit dem Wort „sollte“ zu beginnen, was „es sollte“ impliziert.
Definition einer einfachen Erwartung
Hier ist ein Beispiel, das alle Elemente zusammenführt, um eine sehr einfache Erwartung zu definieren:
BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter | EAutomationTestFlags::ApplicationContextMask)
TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
Describe("Execute()", [this]()
{
It("should return true when successful", [this]()
{
TestTrue("Execute", CustomClass->Execute());
});
It("should return false when unsuccessful", [this]()
{
TestFalse("Execute", CustomClass->Execute());
});
});
}
Wie Sie sehen, führt dies zu selbstdokumentierenden Tests, besonders wenn der Programmierer sich die Zeit nimmt, die Erwartung korrekt zu beschreiben, ohne verschiedene Erwartungen zu kombinieren. Es ist beabsichtigt, dass die Kombination aller Describe()- und It()-Aufrufe einen weitgehend lesbaren Satz ergeben soll, zum Beispiel:
Execute() sollte bei Erfolg True zurückgeben
Execute() sollte bei Misserfolg False zurückgeben
Das folgende Beispiel zeigt, wie eine ausgereifte Spec derzeit in der Automatisierungstest-UI aussieht:
In diesem Beispiel sind Driver, Element und Click jeweils Describe()-Aufrufe, wobei die verschiedenen „should...“-Nachrichten durch It()-Aufrufe definiert werden.
Jeder dieser It()-Aufrufe wird zu einem einzelnen auszuführenden Test, der somit isoliert ausgeführt werden kann, wenn einer fehlschlägt, während andere erfolgreich sind. Dies erleichtert die Wartung der Tests, da die Fehlerbehebung weniger aufwendig ist. Da die Tests selbstdokumentierend und isoliert sind, hat die Person, die den Testbericht liest, ein viel genaueres Verständnis davon, was nicht funktioniert – anstatt nur zu wissen, dass ein sehr großer Bereich namens Core fehlgeschlagen ist. Das bedeutet, dass Probleme schneller an die richtigen Personen weitergeleitet werden und weniger Zeit für die Untersuchung von Problemen aufgewendet wird.
Durch Klicken auf einen der oben genannten Tests gelangen Sie direkt zur It()-Anweisung, die ihn definiert hat.
Wie eine Spec-Erwartung in einen Test übersetzt wird
Im Folgenden finden Sie eine detaillierte Erklärung; das Verständnis des zugrunde liegenden Verhaltens des Spec-Testtyps wird jedoch das Verständnis einiger der folgenden komplexen Funktionen erleichtern.
Der Spec-Testtyp führt die Stamm-Define()-Funktion einmal aus, aber erst wenn sie benötigt wird. Während des Durchlaufs erfasst er jeden nicht-Describe Lambda-Ausdruck. Nach Abschluss von Define() durchläuft er erneut alle gesammelten Lambda-Ausdrücke oder Codeblöcke und generiert für jeden It()-Aufruf ein Array latenter Befehle.
Dadurch werden alle BeforeEach()-, It()- und AfterEach()-Lambda-Codeblöcke in einer Ausführungskette für einen einzelnen Test zusammengefügt. Wenn ein bestimmter Test ausgeführt werden soll, reiht der Spec-Testtyp alle Befehle für diesen speziellen Test zur Ausführung ein. Dabei wird jeder Block erst dann fortgesetzt, wenn der vorherige Block seine Ausführung abgeschlossen hat.
Zusätzliche Funktionen
Der Spec-Testtyp bietet mehrere weitere Funktionen, die das Schreiben komplexer Tests erleichtern. Insbesondere macht er die direkte Verwendung des latenten Befehlssystems des Automatisierungstest-Frameworks, das mächtig, aber auch umständlich ist, weitgehend überflüssig.
Hier ist eine Liste der vom Spec-Testtyp unterstützten Funktionen, die bei komplexeren Szenarien hilfreich sein können:
- BeforeEach und AfterEach
- AsyncExecution
- Latent Abgeschlossen
- Parametrisierte Tests
- Redefine
- Tests deaktivieren
BeforeEach und AfterEach
BeforeEach() und AfterEach() sind Kernfunktionen für das Erstellen von Specs, die über triviale Varianten hinausgehen. BeforeEach() ermöglicht es Ihnen, Code auszuführen, bevor der nachfolgende It()-Code ausgeführt wird. AfterEach() funktioniert ähnlich, führt den Code jedoch nach der Ausführung des It()-Codes aus.
Denke daran, dass jeder "Test" nur aus einem einzigen It()-Aufruf besteht.
Zum Beispiel:
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("A spec using BeforeEach and AfterEach", [this]()
{
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("A"));
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("AZ"));
});
});
}
In unserem Beispiel werden die Codeblöcke von oben nach unten ausgeführt, da zuerst BeforeEach(), dann It() und schließlich AfterEach() definiert wird. Auch wenn es keine Pflicht ist, empfehlen wir diese logische Reihenfolge der Aufrufe beizubehalten. Die Reihenfolge der drei oben genannten Aufrufe könnte jedoch beliebig variiert werden und das Testergebnis wäre stets identisch.
Im obigen Beispiel wird zudem eine Erwartung in AfterEach() überprüft, was höchst ungewöhnlich ist und nur als Nebeneffekt beim Testen des Spec-Testtyps selbst auftritt. Daher empfehlen wir, AfterEach() ausschließlich für Bereinigungszwecke zu verwenden.
Sie können auch mehrere BeforeEach()- und AfterEach()-Aufrufe tätigen, die in der definierten Reihenfolge ausgeführt werden. Wie beim ersten BeforeEach()-Aufruf, der vor dem zweiten BeforeEach()-Aufruf ausgeführt wird, verhält sich AfterEach() ähnlich – der erste Aufruf wird vor dem nachfolgenden ausgeführt.
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
BeforeEach([this]()
{
RunOrder += TEXT("B");
});
It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("AB"));
});
AfterEach([this]()
{
RunOrder += TEXT("Y");
TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});
Zusätzlich werden BeforeEach() und AfterEach() vom Describe()-Geltungsbereich beeinflusst, in dem sie aufgerufen werden. Beide werden nur für It()-Aufrufe ausgeführt, die sich im gleichen Geltungsbereich befinden, in dem sie aufgerufen werden.
Hier ist ein komplexes Beispiel mit nicht korrekt geordneten Aufrufen, die dennoch alle richtig funktionieren.
BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter | EAutomationTestFlags::ApplicationContextMask)
FString RunOrder;
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
Describe("A spec using BeforeEach and AfterEach", [this]()
{
BeforeEach([this]()
{
RunOrder = TEXT("A");
});
AfterEach([this]()
{
RunOrder += TEXT("Z");
// Kann resultieren in
// TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));
// oder dies, abhängig davon, welches It() ausgeführt wird
// TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
});
BeforeEach([this]()
{
RunOrder += TEXT("B");
});
Describe("while nested inside another Describe", [this]()
{
AfterEach([this]()
{
RunOrder += TEXT("Y");
});
It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABC"));
});
BeforeEach([this]()
{
RunOrder += TEXT("C");
});
Describe("while nested inside yet another Describe", [this]()
{
It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
{
TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
});
AfterEach([this]()
{
RunOrder += TEXT("X");
});
BeforeEach([this]()
{
RunOrder += TEXT("D");
});
});
});
});
}
AsyncExecution
Der Spec-Testtyp ermöglicht es Ihnen auch, die Ausführungsweise eines einzelnen Codeblocks einfach zu definieren. Dies erfolgt einfach durch Übergabe des entsprechenden EAsyncExecution-Typs an die überladene Version von BeforeEach(), It() und/oder AfterEach().
Zum Beispiel:
BeforeEach(EAsyncExecution::TaskGraph, [this]()
{
// Einrichtung vornehmen
));
It("should do something awesome", EAsyncExecution::ThreadPool, [this]()
{
// Aktionen ausführen
});
AfterEach(EAsyncExecution::Thread, [this]()
{
// Aufräumarbeiten durchführen
));
Jeder der obigen Codeblöcke wird unterschiedlich, aber in einer garantiert sequentiellen Reihenfolge ausgeführt. Der BeforeEach()-Block wird als Aufgabe im TaskGraph ausgeführt, das It() läuft auf einem freien Thread im Thread-Pool und das AfterEach() startet einen eigenen dedizierten Thread nur für die Ausführung eines Codeblocks.
Diese Optionen sind äußerst praktisch, wenn Thread-empfindliche Szenarien simuliert werden müssen, wie beispielsweise beim Automation Driver.
Die AsyncExecution-Funktion kann mit der Latent Completion-Funktion kombiniert werden.
Latente Ausführung
Manchmal müssen Sie einen Test schreiben, der eine Aktion über mehrere Frames ausführt, beispielsweise bei der Durchführung einer Abfrage. In solchen Szenarien können Sie die überladenen Funktionen LatentBeforeEach(), LatentIt() und LatentAfterEach() verwenden. Diese Funktionen sind mit den nicht-latenten Varianten identisch, mit dem Unterschied, dass ihre Lambdas einen einfachen Delegate namens Done verwenden.
Bei Verwendung der latenten Varianten wird die Spec-Testausführung erst dann mit dem nächsten Codeblock in der Testsequenz fortfahren, wenn der aktiv laufende latente Codeblock den Done-Delegate aufruft.
LatentIt("should return available items", [this](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();
}
Wie Sie im Beispiel sehen können, können Sie den Done-Delegate als Nutzlast an andere Callbacks übergeben, um ihn für den latenten Code zugänglich zu machen. Wenn der oben genannte Test ausgeführt wird, werden keine AfterEach()-Codeblöcke für das It() weiter ausgeführt, bis der Done-Delegate ausgeführt wird, auch wenn der It()-Codeblock bereits abgeschlossen ist.
Die Funktion Latent Completion kann mit der Funktion AsyncExecution kombiniert werden.
Parametrisierte Tests
In manchen Fällen müssen Tests datengesteuert erstellt werden. Manchmal bedeutet dies, Inputs aus einer Datei zu lesen und daraus Tests zu generieren. In anderen Fällen kann es einfach ideal sein, Codeduplikationen zu reduzieren. Der Spec-Testtyp ermöglicht auf natürliche Weise parametrisierte Tests.
Describe("Basic Math", [this]()
{
for (int32 Index = 0; Index < 5; Index++)
{
It(FString::Printf(TEXT("should resolve %d + %d = %d"), Index, 2, Index + 2), [this, Index]()
{
TestEqual(FString::Printf(TEXT("%d + %d = %d"), Index, 2, Index + 2), Index + 2, Index + 2);
});
}
});
Wie Sie im obigen Beispiel sehen können, müssen Sie für parametrisierte Tests lediglich dynamisch die anderen Spec-Funktionen aufrufen und dabei die parametrisierten Daten als Teil der Lambda-Nutzlast übergeben, während Sie eine eindeutige Beschreibung generieren.
In einigen Fällen könnten parametrisierte Tests zu einer unnötigen Aufblähung der Tests führen. Es kann sinnvoll sein, alle Szenarien aus dem Input als Teil eines einzelnen Tests auszuführen. Sie sollten die Anzahl der Inputs und die daraus resultierenden Tests berücksichtigen. Der größte Vorteil bei der Erstellung von datengesteuerten Tests in parametrisierter Form besteht darin, dass jeder Test isoliert ausgeführt werden kann, was die Reproduzierbarkeit erleichtert.
Neudefinition
Bei der Arbeit mit parametrisierten Tests kann es manchmal praktisch sein, zur Laufzeit eine Änderung an einer externen Datei vorzunehmen, die die Inputs
steuert, und die Tests automatisch zu aktualisieren. Redefine() ist ein Mitglied
des Spec-Testtyps, der bei Aufruf den Define()-Prozess erneut durchführt. Dadurch werden alle Codeblöcke für die Tests neu erfasst und zusammengestellt.
Die praktischste Methode hierfür wäre die Erstellung eines Codes, der auf Änderungen der Input-Datei achtet und bei Bedarf Redefine() für den Test aufruft.
Tests deaktivieren
Jedes Describe()-, BeforeEach()-, It()- und AfterEach()-Mitglied
des Spec-Testtyps hat eine Variation mit einem vorangestellten „x“. Zum Beispiel xDescribe(), xBeforeEach(), xIt() und xAfterEach(). Diese Varianten sind eine einfachere Möglichkeit, einen Codeblock oder Describe() zu deaktivieren. Wenn xDescribe() verwendet wird, wird auch der gesamte Code innerhalb von xDescribe() deaktiviert.
Dies kann einfacher sein, als Erwartungen zu kommentieren, die noch überarbeitet werden müssen.
Ausgereifte Beispiele
Ausgereifte Beispiele des Spec-Testtyps finden Sie unter Engine/Plugins/Tests/AutomationDriverTests/Source/AutomationDriverTests/Private/AutomationDriver.spec.cpp. Diese Spezifikation enthält derzeit über 120 Erwartungen und nutzt die meisten erweiterten Funktionen an verschiedenen Stellen.
Unser Launcher-Team verfügt ebenfalls über mehrere ausgereifte Anwendungen des Spec-Frameworks, wobei die Specs für BuildPatchServices zu den ausgereiftesten gehören.