Kürzlich schrieb zerocost einen interessanten Artikel mit dem Titel „Tests in C ++ ohne Makros und dynamischer Speicher“ , in dem ein minimalistisches Framework zum Testen von C ++ - Code erörtert wird. Der Autor hat es (fast) geschafft, die Verwendung von Makros zum Registrieren von Tests zu vermeiden, aber stattdessen erschienen im Code „magische“ Vorlagen, die mir persönlich, sorry, unvorstellbar hässlich erscheinen. Nachdem ich den Artikel gelesen hatte, hatte ich ein vages Gefühl der Unzufriedenheit, da ich wusste, was besser gemacht werden könnte. Ich konnte mich nicht sofort erinnern, wo, aber ich sah definitiv den Testcode, der kein einziges zusätzliches Zeichen enthält, um sie zu registrieren:
void test_object_addition() { ensure_equals("2 + 2 = ?", 2 + 2, 4); }
Schließlich erinnerte ich mich daran, dass dieses Framework Cutter heißt und auf geniale Weise Testfunktionen auf seine eigene Weise identifiziert.
(KDPV von der Cutter-Website unter CC BY-SA.)
Was ist der Trick?
Der Testcode wird in einer separaten gemeinsam genutzten Bibliothek zusammengestellt. Testfunktionen werden aus exportierten Bibliothekssymbolen extrahiert und durch Namen identifiziert. Tests werden von einem speziellen externen Dienstprogramm durchgeführt. Sapienti saß.
$ cat test_addition.c #include <cutter.h> void test_addition() { cut_assert_equal_int(2 + 2, 5); }
$ cc -shared -o test_addition.so \ -I/usr/include/cutter -lcutter \ test_addition.c
$ cutter . F ========================================================================= Failure: test_addition <2 + 2 == 5> expected: <4> actual: <5> test_addition.c:5: void test_addition(): cut_assert_equal_int(2 + 2, 5, ) ========================================================================= Finished in 0.000943 seconds (total: 0.000615 seconds) 1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s) 0% passed
Hier ist ein Beispiel aus der Cutter-Dokumentation . Sie können sicher durch alles blättern, was mit Autotools zu tun hat, und nur den Code anzeigen. Das Framework ist ein bisschen seltsam, ja, wie alles Japanische.
Ich werde nicht zu sehr auf die Implementierungsfunktionen eingehen. Ich habe auch keinen vollwertigen Code (und zumindest keinen Entwurf), da ich ihn persönlich nicht wirklich brauche (in Rust ist alles sofort einsatzbereit). Für Interessierte kann dies jedoch eine gute Übung sein.
Details und Implementierungsoptionen
Berücksichtigen Sie einige der Aufgaben, die Sie beim Schreiben eines Frameworks zum Testen mit dem Cutter-Ansatz lösen müssen.
Exportierte Funktionen abrufen
Zuerst müssen Sie irgendwie zu den Testfunktionen gelangen. Der C ++ - Standard beschreibt natürlich überhaupt keine gemeinsam genutzten Bibliotheken. Windows hat kürzlich ein Linux-Subsystem erworben, mit dem alle drei Hauptbetriebssysteme auf POSIX reduziert werden können. Wie Sie wissen, bieten POSIX-Systeme die Funktionen dlopen()
, dlsym()
, dlclose()
, mit denen Sie die Adresse der Funktion dlclose()
können, den Namen ihres Symbols kennen und ... das ist alles. Die Liste der in der geladenen Bibliothek enthaltenen Funktionen wird von POSIX nicht veröffentlicht.
Leider (obwohl zum Glück) gibt es keine tragbare Standardmethode, um alle aus der Bibliothek exportierten Funktionen zu ermitteln. Vielleicht ist die Tatsache, dass das Konzept einer Bibliothek nicht auf allen Plattformen existiert (sprich: eingebettet), hier irgendwie beteiligt. Aber das ist nicht der Punkt. Die Hauptsache ist, dass Sie plattformspezifische Funktionen verwenden müssen.
In erster Näherung können Sie einfach das Dienstprogramm nm aufrufen:
$ cat test.cpp void test_object_addition() { }
$ clang -shared test.cpp
$ nm -gj ./a.out __Z20test_object_additionv dyld_stub_binder
Analysieren Sie die Ausgabe und verwenden Sie dlsym()
.
Für eine tiefere Selbstbeobachtung sind Bibliotheken wie libelf , libMachO und pe- parse hilfreich, mit denen Sie ausführbare Dateien und Bibliotheken von Plattformen, die für Sie von Interesse sind , programmgesteuert analysieren können. Tatsächlich verwenden nm und das Unternehmen sie nur.
Testfunktionsfilterung
Wie Sie vielleicht bemerkt haben, enthalten die Bibliotheken einige seltsame Zeichen:
__Z20test_object_additionv dyld_stub_binder
Dies ist __Z20test_object_additionv
, wenn wir die Funktion nur test_object_addition
? Und was ist das übrig dyld_stub_binder
?
Die " __Z20...
" Zeichen __Z20...
sind die sogenannte Namensdekoration (Name Mangling). C ++ Kompilierungsfunktion, nichts kann getan werden, leben Sie damit. Dies ist, was Funktionen aus Sicht des Systems (und dlsym()
) dlsym()
. Um sie einer Person in ihrer normalen Form zu zeigen, können Sie Bibliotheken wie libdemangle verwenden . Natürlich hängt die Bibliothek, die Sie benötigen, vom verwendeten Compiler ab, aber das Dekorationsformat ist im Rahmen der Plattform normalerweise dasselbe.
Seltsame Funktionen wie dyld_stub_binder
sind ebenfalls Plattformfunktionen, die berücksichtigt werden müssen. Sie müssen zu Beginn der Tests keine Funktionen aufrufen, da dort keine Fische vorhanden sind.
Eine logische Fortsetzung dieser Idee besteht darin, die Funktion nach Namen zu filtern. Beispielsweise können Sie Funktionen nur mit test
im Namen ausführen. Oder funktioniert einfach aus dem tests
Namespace. Verwenden Sie auch verschachtelte Namespaces, um Tests zu gruppieren. Ihrer Fantasie sind keine Grenzen gesetzt.
Übergeben des Kontexts eines ausführbaren Tests
Objektdateien mit Tests werden in einer gemeinsam genutzten Bibliothek gesammelt, deren Ausführung vollständig von einem externen Dienstprogramm-Treiber gesteuert wird - cutter
for Cutter. Dementsprechend können interne Testfunktionen dies nutzen.
Beispielsweise kann der Kontext eines ausführbaren Tests ( IRuntime
im Originalartikel) sicher durch eine globale ( IRuntime
) Variable übergeben werden. Der Fahrer ist für die Verwaltung und Weitergabe des Kontexts verantwortlich.
In diesem Fall erfordern die Testfunktionen keine Argumente, behalten jedoch alle erweiterten Funktionen bei, z. B. die willkürliche Benennung der getesteten Fälle:
void test_vector_add_element() { testing::description("vector size grows after push_back()"); }
Die Funktion description()
greift über eine globale Variable auf die bedingte IRuntime
und kann somit einen Kommentar an das Framework für eine Person übergeben. Die Sicherheit der Verwendung des globalen Kontexts wird durch das Framework garantiert und liegt nicht in der Verantwortung des Testschreibers.
Bei diesem Ansatz tritt weniger Code im Code auf, wenn der Kontext auf die Vergleichsanweisungen und internen Testfunktionen übertragen wird, die möglicherweise von der Hauptanweisung aufgerufen werden müssen.
Konstruktoren und Destruktoren
Da die Ausführung von Tests vollständig vom Treiber gesteuert wird, kann zusätzlicher Code um die Tests herum ausgeführt werden.
Die Cutter-Bibliothek verwendet hierfür folgende Funktionen:
cut_setup()
- vor jedem einzelnen Testcut_teardown()
- nach jedem einzelnen Testcut_startup()
- bevor alle Tests ausgeführt werdencut_shutdown()
- nach Abschluss aller Tests
Diese Funktionen werden nur aufgerufen, wenn sie in der Testdatei definiert sind. Sie können die Vorbereitung und Reinigung der Testumgebung (Fixture) in sie einfügen: die Erstellung der erforderlichen temporären Dateien, die schwierige Einrichtung der getesteten Objekte und andere Testmuster.
Für C ++ ist es möglich, eine idiomatischere Oberfläche zu entwickeln:
- objektorientierter und typsicherer
- mit besserer Unterstützung des RAII-Konzepts
- Verwendung von Lambdas zur verzögerten Ausführung
- mit Testausführungskontext
Aber jetzt denke ich noch einmal ausführlich darüber nach.
In sich geschlossene ausführbare Testdateien
Cutter verwendet zur Vereinfachung einen Ansatz für gemeinsam genutzte Bibliotheken. Verschiedene Tests werden in einer Reihe von Bibliotheken kompiliert, die von einem separaten Testdienstprogramm gefunden und ausgeführt werden. Auf Wunsch kann natürlich der gesamte Code des Testtreibers direkt in die ausführbare Datei eingebettet werden, wobei die üblichen separaten Dateien abgerufen werden. Dies erfordert jedoch die Zusammenarbeit mit dem Build-System, um das Layout dieser ausführbaren Dateien richtig zu organisieren: ohne „unbenutzte“ Funktionen mit den richtigen Abhängigkeiten usw. auszuschneiden.
Andere
Cutter und andere Frameworks haben auch viele andere nützliche Dinge, die das Leben beim Schreiben von Tests erleichtern können:
- flexible und erweiterbare Testanweisungen
- Erstellen und Abrufen von Testdaten aus Dateien
- Stack-Trace-Studien, Ausnahme- und Drop-Handling
- anpassbare „Aufschlüsselungsstufen“ von Tests
- Ausführen von Tests in mehreren Prozessen
Es lohnt sich, beim Schreiben Ihres Fahrrads auf vorhandene Rahmenbedingungen zurückzublicken. UX ist ein viel tieferes Thema.
Fazit
Der vom Cutter-Framework verwendete Ansatz ermöglicht die Identifizierung von Testfunktionen mit minimaler kognitiver Belastung des Programmierers: Schreiben Sie einfach Testfunktionen und fertig. Der Code erfordert keine speziellen Vorlagen oder Makros, was die Lesbarkeit erhöht.
Die Funktionen zum Zusammenstellen und Ausführen von Tests können in wiederverwendbaren Modulen für Montagesysteme wie Makefile, CMake usw. verborgen sein. Fragen zu einer separaten Zusammenstellung von Tests müssen noch auf die eine oder andere Weise gestellt werden.
Zu den Nachteilen dieses Ansatzes gehört die Schwierigkeit, Tests in derselben Datei (derselben Übersetzungseinheit) wie der Hauptcode abzulegen. Leider ist es in diesem Fall ohne zusätzliche Hinweise nicht mehr möglich herauszufinden, welche Funktionen gestartet werden müssen und welche nicht. Glücklicherweise ist es in C ++ normalerweise üblich, Tests und Implementierung in verschiedene Dateien zu verteilen.
Was die endgültige Entsorgung von Makros betrifft, so scheint es mir, dass sie im Prinzip nicht aufgegeben werden sollten. Mit Makros können beispielsweise kürzere Vergleichsanweisungen geschrieben werden, um Codeduplikationen zu vermeiden:
void test_object_addition() { ensure_equals(2 + 2, 5); }
aber gleichzeitig den gleichen Informationsgehalt des Problems im Fehlerfall beibehalten:
Failure: test_object_addition <ensure_equals(2 + 2, 5)> expected: <5> actual: <4> test.c:5: test_object_addition()
Der Name der getesteten Funktion, der Dateiname und die Zeilennummer des Funktionsstarts können theoretisch aus den Debuginformationen extrahiert werden, die in der gesammelten Bibliothek enthalten sind. Der erwartete und der tatsächliche Wert der verglichenen Ausdrücke sind der Funktion ensure_equals()
. Mit dem Makro können Sie die ursprüngliche Schreibweise der Testanweisung "wiederherstellen", wodurch klarer wird, warum der Wert 4
erwartet wird.
Dies ist jedoch nicht jedermanns Sache. Endet der Nutzen von Makros für Testcode dort? Ich habe noch nicht wirklich über diesen Moment nachgedacht, der sich als ein gutes Feld für weitere herausstellen könnte Perversionen Forschung. Eine viel interessantere Frage: Ist es möglich, ein Mock-Framework für C ++ ohne Makros zu erstellen?
Der aufmerksame Leser bemerkte auch, dass die Implementierung wirklich keine SMS und Asbest enthält, was zweifellos ein Plus für die Ökologie und Ökonomie der Erde ist.