Tests in C ++ ohne Makros und dynamischen Speicher

Viele beliebte Bibliotheken zum Testen, z. B. Google Test, Catch2, Boost.Test, sind stark an die Verwendung von Makros gebunden. Als Beispiel für Tests dieser Bibliotheken sehen Sie normalerweise ein Bild wie das folgende:


namespace { // Tests the default c'tor. TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length()); } const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string. TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length()); } // Tests the copy c'tor. TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString)); } } // namespace 

Makros in C ++ sind vorsichtig. Warum gedeihen sie in Bibliotheken zum Erstellen von Tests so gut?


Die Unit-Test-Bibliothek sollte ihren Benutzern die Möglichkeit bieten, Tests zu schreiben, damit die Testlaufzeit sie irgendwie finden und ausführen kann. Wenn Sie darüber nachdenken, wie dies zu tun ist, scheint die Verwendung von Makros am einfachsten zu sein. Das TEST () -Makro definiert normalerweise irgendwie eine Funktion (im Fall von Google Test erstellt das Makro auch eine Klasse) und stellt sicher, dass die Adresse dieser Funktion in einen globalen Container gelangt.


Die bekannte Bibliothek, in der der Ansatz ohne ein einziges Makro implementiert ist, ist das tut-Framework . Schauen wir uns ihr Beispiel aus dem Tutorial an:


 #include <tut/tut.hpp> namespace tut { struct basic{}; typedef test_group<basic> factory; typedef factory::object object; } namespace { tut::factory tf("basic test"); } namespace tut { template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } } 

Die Idee, die zugrunde liegt, ist sehr interessant und funktioniert, es ist nicht sehr schwierig. Kurz gesagt, Sie haben eine Basisklasse, die eine Vorlagenfunktion implementiert, die die Parametrisierung mit einer Ganzzahl umfasst:


 template <class Data> class test_object : public Data { /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; } } 

Wenn Sie jetzt einen solchen Test schreiben:


 template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); } 

Sie erstellen tatsächlich eine Testmethoden-Spezialisierung für eine bestimmte Zahl N = 1 (genau dafür steht template<>template<> ). Durch Aufrufen von test<N>() die Testlaufzeit nachvollziehen, ob es sich um einen echten Test oder um einen Stub handelt, der nach Ausführung des Tests den called_method_was_a_dummy_test_ überprüft.


Als nächstes, wenn Sie eine Testgruppe deklarieren:


 tut::factory tf("basic test"); 

Erstens listen Sie alle test<N> bis zu einer bestimmten Konstante auf, die mit der Bibliothek verbunden ist, und zweitens fügen Sie nebenbei dem globalen Container Informationen über die Gruppe hinzu (Gruppenname und Adressen aller Testfunktionen).


Ausnahmen werden in tut als Testbedingungen verwendet, daher tut::ensure_equals() Funktion tut::ensure_equals() einfach eine Ausnahme aus, wenn die beiden übergebenen Werte nicht gleich sind, und die Testlaufumgebung tut::ensure_equals() eine Ausnahme und betrachtet den Test als fehlgeschlagen. Ich mag diesen Ansatz, es wird jedem C ++ - Entwickler sofort klar, wo solche Behauptungen verwendet werden können. Wenn mein Test beispielsweise einen Hilfsthread erstellt hat, ist es sinnlos, dort Aussagen zu platzieren, die niemand abfangen kann. Außerdem ist mir klar, dass mein Test im Falle einer Ausnahme in der Lage sein sollte, Ressourcen freizugeben, als wäre es gewöhnlicher ausnahmesicherer Code.


Im Prinzip sieht die Tut-Framework-Bibliothek ziemlich gut aus, aber ihre Implementierung weist einige Nachteile auf. In meinem Fall möchte ich beispielsweise, dass der Test nicht nur eine Nummer, sondern auch andere Attribute, insbesondere den Namen, sowie die "Größe" des Tests enthält (z. B. handelt es sich um einen Integrationstest oder um einen Komponententest). Dies kann im Rahmen des API-Tuts gelöst werden, und es ist sogar bereits etwas vorhanden, und etwas kann implementiert werden, wenn Sie der Bibliotheks-API eine Methode hinzufügen und sie im Testkörper aufrufen, um einen ihrer Parameter festzulegen:


 template<> template<> void object::test<1>() { set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4); } 

Ein weiteres Problem besteht darin, dass die Tut-Testlaufumgebung nichts über ein solches Ereignis wie den Beginn eines Tests weiß. Die Umgebung führt object::test<N>() und weiß nicht im Voraus, ob der Test für ein bestimmtes N implementiert ist oder nur ein Stub. Sie called_method_was_a_dummy_test_ nur called_method_was_a_dummy_test_ wann der Test beendet ist, indem sie den Wert called_method_was_a_dummy_test_ analysiert. Diese Funktion zeigt sich in CI-Systemen nicht sehr gut, die in der Lage sind, die Ausgabe zu gruppieren, die das Programm zwischen dem Beginn und dem Ende des Tests erstellt hat.


Meiner Meinung nach ist die Hauptsache, die verbessert werden kann (ein "schwerwiegender Fehler"), das Vorhandensein von zusätzlichem Hilfscode, der zum Schreiben von Tests erforderlich ist. Das Tutorial-Tutorial-Framework enthält eine Menge Dinge: Es wird vorgeschlagen, zuerst eine bestimmte Klassenstruktur struct basic{} zu erstellen und die Tests als damit verbundene Objektmethoden zu beschreiben. In dieser Klasse können Sie die Methoden und Daten definieren, die Sie in der Testgruppe verwenden möchten, und der Konstruktor und der Destruktor rahmen die Ausführung des Tests ein und erstellen so etwas wie ein Fixture aus jUnit. In meiner Praxis mit tut ist dieses Objekt fast immer leer, aber es zieht sich entlang einer bestimmten Anzahl von Codezeilen.


Also gehen wir in die Fahrradwerkstatt und versuchen, die Idee in Form einer kleinen Bibliothek zu arrangieren.


So sieht die minimale Testdatei in der getesteten Bibliothek aus:


 // Test group for std::vector (illustrative purposes) #include "tested.h" #include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default"); } template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime) { runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty()); } void LinkVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Zusätzlich zum Fehlen von Makros ist der Bonus das Fehlen der Verwendung von dynamischem Speicher innerhalb der Bibliothek.


Definition von Testfällen


Für die Registrierung von Tests wird die elementare Magie des Einstiegs nach dem gleichen Prinzip wie tut verwendet. Irgendwo in getestet.h gibt es eine Boilerplate-Funktion dieser Art:


 template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); } 

Von Bibliotheksbenutzern geschriebene Testfälle sind lediglich Spezialisierungen dieser Methode. Die Funktion wird als statisch deklariert, d.h. In jeder Übersetzungseinheit erstellen wir Spezialisierungen, die sich beim Verknüpfen nicht namentlich überschneiden.


Es gibt eine solche Regel, dass Sie zuerst StartCase() aufrufen StartCase() , an die Sie Dinge wie den Namen des Tests und möglicherweise einige andere Dinge übergeben können, die sich noch in der Entwicklung befinden.


Wenn ein Test runtime->StartTest() , können interessante Dinge passieren. Erstens, wenn sich die Tests jetzt im Ausführungsmodus befinden, können Sie irgendwo feststellen, dass der Test mit der Ausführung begonnen hat. Zweitens, wenn es einen Modus zum Sammeln von Informationen über verfügbare Tests gibt, StartTest() eine spezielle Art von Ausnahme aus, die bedeutet, dass der Test echt ist und kein Stub.


Registrierung


Irgendwann müssen Sie die Adressen aller Testfälle sammeln und irgendwo ablegen. Im Test erfolgt dies mit Gruppen. Der Konstruktor der getesteten :: Group-Klasse tut dies als Nebeneffekt:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); 

Der Konstruktor erstellt eine Gruppe mit dem angegebenen Namen und fügt alle Fälle Case<N> , die er in der aktuellen Übersetzungseinheit findet. Es stellt sich heraus, dass Sie in einer Übersetzungseinheit nicht zwei Gruppen haben können. Dies bedeutet auch, dass Sie eine Gruppe nicht in mehrere Übersetzungseinheiten aufteilen können.


Der Parameter der Vorlage gibt an, nach wie vielen Testfällen in der aktuellen Übersetzungseinheit für die erstellte Gruppe gesucht werden soll.


Link


Im obigen Beispiel erfolgt die Erstellung des getesteten :: Group () -Objekts innerhalb der Funktion, die wir von unserer Anwendung aus aufrufen müssen, um die Tests zu registrieren:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

Eine Funktion ist nicht immer erforderlich. Manchmal können Sie einfach ein Objekt der tested::Group Klasse in einer Datei deklarieren. Ich habe jedoch die Erfahrung gemacht, dass der Linker manchmal die gesamte Datei "optimiert", wenn sie in der Bibliothek zusammengestellt ist, und keine der Hauptanwendungen Zeichen aus dieser CPP-Datei verwendet:


 calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | | app.exe run_test.exe 

Wenn calc_test.cpp nicht mit der Quelle run_test.exe verknüpft ist, entfernt der Linker diese Datei einfach vollständig aus der Betrachtung, zusammen mit der Erstellung eines statischen Objekts, obwohl sie die von uns benötigten Nebenwirkungen hat.


Wenn welche Kette aus run_test.exe resultiert, wird das statische Objekt in der ausführbaren Datei angezeigt. Und es spielt keine Rolle, wie dies genau gemacht wird, wie im Beispiel:


 void LinkStdVectorTests() { static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); } 

oder so:


 static tested::Group<CASE_COUNTER> x("std.vector", __FILE__); void LinkStdVectorTests() { } 

Die erste Option ist meiner Meinung nach besser, da der Konstruktor nach dem Start von main () aufgerufen wird und die Anwendung eine gewisse Kontrolle über diesen Prozess hat.


Ich denke, diese Einstellung von Krücken ist für jede Unit-Test-Bibliothek erforderlich, die globale Variablen und Nebenwirkungen des Konstruktors verwendet, um eine Testdatenbank zu erstellen. Dies kann jedoch wahrscheinlich vermieden werden, indem die Testbibliothek mit dem Schlüssel --whole-archive verknüpft wird (ein Analogon in MSVC wurde nur in Visual Studio 2015.3 angezeigt).


Makros


Ich habe versprochen, dass es keine Makros geben wird, aber es ist - CASE_COUNTER . Die Arbeitsoption besteht darin, dass es von __COUNTER__ verwendet wird, einem Makro, das der Compiler bei jeder Verwendung in der Übersetzungseinheit um eins erhöht.
Unterstützt von GCC, CLANG, MSVC, aber nicht vom Standard. Wenn dies frustrierend ist, sind hier einige Alternativen:


  • Verwenden Sie die Nummern 0, 1, 2
  • Verwenden Sie Standard __LINE__ .
  • Verwende die Constexpr-Magie der Stufe 80. Sie können nach "constexpr counter" suchen und versuchen, den Compiler zu finden, auf dem er funktioniert.

Das Problem mit __LINE__ besteht darin, dass durch die Verwendung großer Zahlen in den Vorlagenoptionen eine große Größe der ausführbaren Datei erstellt wird. Aus diesem Grund habe ich den Typ des signierten Zeichenmusters auf 128 als maximale Anzahl von Tests in der Gruppe beschränkt.


Ausfall des dynamischen Speichers


Es stellte sich heraus, dass Sie bei der Registrierung von Tests keinen dynamischen Speicher verwenden können, den ich verwendet habe. Es ist möglich, dass Ihre Umgebung keinen dynamischen Speicher hat oder Sie in Testfällen die Suche nach Speicherlecks verwenden, sodass das Eingreifen in die Testausführungsumgebung nicht das ist, was Sie benötigen. Google Test hat damit zu kämpfen. Hier ist ein Ausschnitt von dort:


 // Use the RAII idiom to flag mem allocs that are intentionally never // deallocated. The motivation is to silence the false positive mem leaks // that are reported by the debug version of MS's CRT which can only detect // if an alloc is missing a matching deallocation. // Example: // MemoryIsNotDeallocated memory_is_not_deallocated; // critical_section_ = new CRITICAL_SECTION; class MemoryIsNotDeallocated 

Und wir können einfach keine Schwierigkeiten schaffen.


Wie bekommen wir dann eine Liste von Tests? Dies sind eher technische Interna, die im Quellcode leichter zu erkennen sind, aber ich werde es Ihnen trotzdem sagen.


Beim Erstellen einer Gruppe erhält ihre Klasse einen Zeiger auf die Funktion tested::CaseCollector<CASE_COUNTER>::collect , mit der alle Tests von Übersetzungseinheiten in einer Liste zusammengefasst werden. So funktioniert es:


 // Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; } 

Es stellt sich heraus, dass in jeder Übersetzungseinheit viele statische Variablen vom Typ CaseListEntry CaseCollector \ :: s_caseListEntry erstellt werden, die Elemente der Testliste sind, und die Methode collect () sammelt diese Elemente in einer einzeln verbundenen Liste. In etwa der gleichen Weise bildet die Liste Gruppen von Tests, jedoch ohne Muster und Rekursion.


Struktur


Tests benötigen eine andere Bindung, z. B. Ausgabe in roten Buchstaben an die Konsole. Fehlgeschlagen. Erstellen von Testberichten in einem für CI oder GUI verständlichen Format, in dem Sie die Liste der Tests anzeigen und die ausgewählten Tests ausführen können - im Allgemeinen viele Dinge. Ich habe eine Vision davon, wie dies getan werden kann, die sich von der unterscheidet, die ich zuvor in der Testbibliothek gesehen habe. Der Anspruch bezieht sich hauptsächlich auf Bibliotheken, die sich "nur Header" nennen und gleichzeitig eine große Menge Code enthalten, was im Wesentlichen nicht für Header-Dateien gilt.


Ich gehe davon aus, dass wir die Bibliothek in Front-End-Bibliotheken aufteilen - dies wird getestet. H- und Back-End-Bibliotheken selbst. Um Tests zu schreiben, benötigen Sie nur getestete.h, die jetzt C ++ 17 ist (aufgrund der std :: std :: string_view), aber es wird angenommen, dass es C ++ 98 geben wird. Tested.h führt tatsächlich die Registrierung und Suche nach Tests durch, eine minimal bequeme Startoption sowie die Möglichkeit, Tests (Gruppen, Adressen von Testfallfunktionen) zu exportieren. Noch nicht vorhandene Back-End-Bibliotheken können alles tun, was sie für die Ausgabe von Ergebnissen und den Start mithilfe der Exportfunktion benötigen. Auf die gleiche Weise können Sie den Start an die Anforderungen Ihres Projekts anpassen.


Zusammenfassung


Die getestete Bibliothek ( Github-Code ) muss noch stabilisiert werden. Fügen Sie in naher Zukunft die Möglichkeit hinzu, asynchrone Tests auszuführen (die für Integrationstests in WebAssembly erforderlich sind), und geben Sie die Größe der Tests an. Meiner Meinung nach ist die Bibliothek noch nicht ganz produktionsbereit, aber ich habe plötzlich viel Zeit verbracht und die Bühne ist stehen geblieben, um Luft zu holen und um Feedback von der Community zu bitten. Möchten Sie diese Art von Bibliothek nutzen? Vielleicht gibt es noch andere Ideen im C ++ - Arsenal, da es möglich wäre, eine Bibliothek ohne Makros zu erstellen? Ist eine solche Erklärung des Problems überhaupt interessant?

Source: https://habr.com/ru/post/de434906/


All Articles