CodeCoverage mit Threads und Sockets
-
Hallo zusammen
ich versuche eine möglichste hohe CodeCoverage ( soweit es zumindest Sinn macht ) mit meinen Unittests zu erreichen. Da ich aber an Backends arbeite, habe ich sehr viel mit Threads und Sockets zu tun.
In aller Regel modularisiere ich die Applikationen soweit, dass ich die eigentliche Funktionalität sauber von Threads und anderen system-api-funktionen abtrenne, damit meine Module sauber mit unittests abgedeckt werden können und leichter testbar sind.Nun zählt aber der Quellcode, der sich rund um Threads und Konnektivität dreht, ebenfalls mit zur Codecoverage. Hier einige Punkte:
- Kommunikationsprotokolle
- Übertragung von Daten zwischen Threads
- Auslesen/Beschreiben von Sockets
- Lesen und Schreiben von Dateien
Ist es üblich diese Dinge ebenfalls mit unittests zu versehen, oder hält man einfach den Codeanteil in in diesen Bereich so gering wie möglich und testet nur die eigentlich modularisierte Funktionalität?
Wenn ja, wie man ich sowas am elegantesten?
-
Du brauchst einen Integrationstest... keinen Unit-Test...
Schau doch mal hier: https://www.geeksforgeeks.org/thread-testing-in-software-engineering/
Vereinfacht gesagt, alles wird getestet, aber ggf.(!) mit gemockten Serviceklassen.
Edit: Hier ist auch ein Blog, der die Unterschiede gut erklärt: https://circleci.com/blog/unit-testing-vs-integration-testing/
-
Ich teste auch solche Dinge mit Unit-Tests.
Für eine "Socket-Transport" Klasse kannst du im Test einen lokalen TCP Server hochziehen. Um eine File-IO Klasse zu schreiben kannst du ein temporäres Verzeichnis anlegen in dem du dann deine Files für den Test erzeugen kannst.
Was Kommunikationsprotokolle angeht: idealerweise sollten diese vom "Transport" unabhängig sein. Dann lassen sich diese mittels Transport-Mocks testen.
Was Datenübertragung zwischen Threads angeht: das ist etwas schwieriger. Was noch relativ leicht geht, sind Smoke-Tests. Also einfach mit vielen Threads auf das Ding draufballern, und asserten dass das erwartete Ergebnis (bzw. ein erwartetes/korrektes Ergebnis) rauskommt.
Schwieriger wird es wenn du damit wirklich ne gute Chance haben willst Fehler zu finden. Das geht meist nur mit "intrusive" Tests.
Eine Möglichkeit dafür ist z.B. den Code als Template zu schreiben. Als Template-Parameter kannst du dabei eine "Test-Traits" Klasse mitgeben. Also z.B. so:
template <class TestTraits> class MyThreadingThingy : private TestTraits { public: explicit MyThreadingThingy(TestTraits tt = TestTraits{}) : TestTraits{tt} { } void doTheThing() { TestTraits::TestDelay(); auto expected = m_someAtomic.load(std::memory_order_relaxed); while (true) { TestTraits::TestDelay(); auto const desired = expected ...; if (m_someAtomic.compare_exchange_weak(expected, desired)) { break; } } TestTraits::TestDelay(); } // ... };
Für den Produktiv-Code verwendest du eine
TestTraits
Klasse die keine Daten-Member hat und dieTestDelay
Funktion als leere inline Funktion implementiert hat.Für die Tests kannst du dann z.B. eine Klasse verwenden die hin und wieder (zufällig) eine Pause von z.B. 50 ms macht. Oder du machst eine
TestTraits
Klasse die beim N. Aufruf eine solche Pause macht, und testest dann mit N=1, N=2, N=3 etc.
Den RNG-State bzw. den Zähler dafür kann man z.B. schön in statischen Thread-Locals ablegen.Und natürlich solltest du alle Tests von Threading-Zeugs (bzw. idealerweise überhaupt alle Tests) auch mit Thread-Sanitizer testen.
-
ps: Noch ein kleiner Tip zum Testen für Threading-Zeugs: Oft möchte man testen dass Dinge korrekt funktionieren wenn zwei Threads den selben Code (oder auch unterschiedlichen Code) "gleichzeitig" ausführen. Condition-Variablen o.Ä. eignen sich dazu nicht gut - der zeitliche Versatz zwischen dem Aufwachen der teilnehmenden Threads ist dabei viel zu hoch. Was dagegen gut geht, sind Busy-Waits auf Atomics.
D.h. du startest die 2, 3, 4... Threads die gleichzeitig loslaufen sollen. Die Threads warten erstmal alle in einem
while (!start.load(std::memory_order_acquire)) {}
. Nachdem alle Threads gestartet wurden machst du noch nen kurzen Sleep, um (halbwegs) "sicherzustellen" dass alle Threads bereits in der Warteschleife sind. Dann setzt dustart = true;
. Und danach kannst du die Threads joinen und deine Assertions machen.Damit hatte ich ziemlich gute Erfolge beim Nachstellen von diversen Threading-Bugs.
pps: Und natürlich solltest du alle Tests von Threading-Zeugs wenigstens ein paar hundert oder besser tausend mal pro Testlauf ausführen.
-
@hustbaer sagte in CodeCoverage mit Threads und Sockets:
Ich teste auch solche Dinge mit Unit-Tests.
Das Benutzen von OS-Funktionalität (wie IO, Sockets oder Threads) nennt sich dann aber Integrationstest, da nicht mehr nur noch der eigene Code ausgeführt wird und außerdem komplette Abläufe getestet werden, s.a. Softwaretest: Integrationstest.
-
@Th69
Danke. Endlich nennt mal jemand den Pudel beim Namen.@It0101 Du wirst keine 100 %ige Code-Coverage erreichen...
-
@NoIDE sagte in CodeCoverage mit Threads und Sockets:
@It0101 Du wirst keine 100 %ige Code-Coverage erreichen...
Dann erklär dich mal oO
-
@Schlangenmensch sagte in CodeCoverage mit Threads und Sockets:
@NoIDE sagte in CodeCoverage mit Threads und Sockets:
@It0101 Du wirst keine 100 %ige Code-Coverage erreichen...
Dann erklär dich mal oO
Code-Coverage zählt meist nur die Testabdeckung mit Unit-Tests, sprich welche einzelnen Funktionen getestet wurden... Aber bei Threads gibt es nicht einzeln testbare Funktionen... Es gibt mehrere Akteure und Systeme, die ineinandergreifen, und die als "Ganzes" (mit einem Integrationstest) getestet werden müssen... Wie das genau funktioniert oder, worauf man achten sollte, hat @hustbaer schon dargelegt.
Ganz vereinfacht gesagt, schießt man gleichzeitig mit mehreren Threads auf eine Klasse, ein Modul oder ein System, und schaut sich dich Effekte an... gab es Verklemmungen?, wurde nix verschluckt?, stimmt das Gesamtergebnis?, usw.
Hier in Java, aber folgende Sachen müssten im Prinzip getestet und dadurch ausgeschlossen werden: https://www.javatpoint.com/disadvantage-of-multithreading-in-java (und auch https://docs.oracle.com/cd/E13203_01/tuxedo/tux71/html/pgthr5.htm)
... Es ist aber nicht immer ganz leicht, bestimmte Fehlersituationen "künstlich" zu forcieren, denke da zum Beispiel an nicht atomare int-Operationen usw. 999.999mal kann es gut gehen, aber beim 1.000.001 Mal geht was schief, z. B.
-
Du redest an meiner Frage vorbei. Ich möchte von dir wissen, warum @It0101 deiner Meinung nach keine 100%ige Code Coverage erreichen wird.
-
Steht im ersten Satz.
-
@Th69 sagte in CodeCoverage mit Threads und Sockets:
@hustbaer sagte in CodeCoverage mit Threads und Sockets:
Ich teste auch solche Dinge mit Unit-Tests.
Das Benutzen von OS-Funktionalität (wie IO, Sockets oder Threads) nennt sich dann aber Integrationstest, da nicht mehr nur noch der eigene Code ausgeführt wird
Mit diesem Argument kann ich wenig anfangen. Bei Hochsprachen wird nie nur der eigene Code getestet. Du hast schonmal schlauen Code im Compiler bzw. der vom Compiler generiert wird. Dinge wie switch-case, range-based for etc. sind alle nicht ganz trivial. Oder vom Compiler generierte Konstruktoren, Destruktoren oder auch nur der Unwinding-Code für ganz normale Funktionen. Dieser Code wird auch mitgetestet. Weiters hast du Code in der Standard-Library. Und für Dinge wie Speicheranforderung wird auf üblichen Plattformen auch (indirekt) Code vom OS benötigt.
Und ob der Code jetzt vom Compiler kommt, aus der Standard-Library, aus einen third-party Library oder vom OS, macht für mich keinen relevanten Unterschied. Das alles sind Dinge auf die man sich üblicherweise verlässt. (Was natürlich nicht heisst dass sie Fehlerfrei sind, wir haben in allen genannten bereits genügend Fehler gefunden.)
Natürlich kann man den Begriff Unit-Test so definieren, dass man alles wo in der Implementierung Standard-Library Klassen verwendet werden oder auch nur Speicher angefordert wird etc. als Integrationstest (oder sonst wie anders) bezeichnet. Damit schränkt man den Begriff Unit-Test allerdings so krass ein, dass er kaum mehr Sinn macht. Weil wozu brauche ich einen Begriff für eine Art Tests die quasi niemand schreibt?
und außerdem komplette Abläufe getestet werden, s.a. Softwaretest: Integrationstest.
Das kommt jetzt wieder darauf an wie man "komplette Abläufe" definiert. Wenn ich eine Socket-Klasse teste indem ich zu nem speziellen Test-Server connecte und ein paar Bytewürste vor und zurück schicke, dann würde ich das kaum als kompletten Ablauf bezeichnen.
Also nö, das macht für mich keinen Sinn. Wenn ich meine
TcpTransport
Klasse habe und diese mittels eines speziellen Test-Servers teste, und sonst kein eigener "production code" von mir/uns beteiligt ist, dann nenne ich das Unit-Test. Bzw. ich gehe sogar noch einen Schritt weiter: wir haben etliche low-level Utilities wie z.B.UniqueResource<>
. Diese zähle ich auch zu den grundlegenden Bausteinen wie Standard-Library & OS -- auch wenn der Code von uns ist.Ein Integrationstest wäre es wenn ich meinen
TcpTransport
mit meinemFooProtocol
zusammenknote und dann das Zusammenspiel von beiden teste. (Bzw. natürlich auch noch grössere Gebilde.)
Letztlich ist es aber auch nicht so wichtig wie man es nennt. Wichtig ist IMO dass solche Tests vorhanden sind, weil man dadurch die Testabdeckung deutlich erhöhen kann.
-
Was 100% Test-Coverage angeht: das ist immer schwierig. Und auch abhängig davon was man mit Coverage meint. Line-Coverage? Branch-Coverage? Oder gar Condition-Coverage? Und zählt man auch Code mit der gar keine "Zeilen hat" - also z.B. den Unwinding-Code von jedem Punkt aus wo potentiell eine Exception fliegen könnte?
Speziell Error-Handling Code ist super-schwer zu testen - speziell in Low-Level Klassen die mit dem OS interagieren. Der Aufwand ist enorm. Jedes
malloc
/new
/push_back
kann schief gehen sowie die meisten Aufrufe von OS Funktionen. Um die damit verbundenen Error-Handling Code-Pfade alle zu testen muss man dann schon OS- bzw. Standar-Library Funktionen hooken um da Fault-Injection machen zu können. Bzw. kenne ich keine andere praktikable Möglichkeit.ps: Wir haben übrigens auch ein paar solche Tests. Für extrem heikle Klassen haben wir Tests wo wir z.B. wirklich "out of memory" Fehler in automatisierten Tests injecten.
-
@NoIDE Dann ist das falsch. Code-Coverage beschreibt, wie viel Code durch Tests abgedeckt ist, unabhängig davon, ob man die Tests unter "Integrationstest" oder "Unittest" führt. Ganz prinzipiell würde ich die Grenze auch nicht so scharf ziehen, du musst als Entwickler halt sicherstellen, das die von dir entwickelten Sachen funktionieren und Integrationstests kann man häufig super mit den üblichen Unittestframeworks schreiben.
@hustbaer das ist genau, worauf ich mit meiner Frage richtung @NoIDE abgeziehlt habe und Line-Coverage und Branch-Coverage sind zumindest theoretisch möglich.
Wenn es wichtig ist, dass "out of memory" Fehler sauber gefangen und verarbeitet werden, sollte man das auch testen.
Häufiger sieht man aber Code, der diese Möglichkeit einfach ignoriert bzw. den Fehler dann einfach durch propagiert.
-
@hustbaer sagte in CodeCoverage mit Threads und Sockets:
Der Aufwand ist enorm. Jedes
malloc
/new
/push_back
kann schief gehen sowie die meisten Aufrufe von OS Funktionen. Um die damit verbundenen Error-Handling Code-Pfade alle zu testen muss man dann schon OS- bzw. Standar-Library Funktionen hooken um da Fault-Injection machen zu können.Error Handling kann man sich bei out-of-memory i.d.R. komplett sparen, da die verbreiteten OS massives memory overcommitment machen, und man bis auf Trivialfälle (angeforderter Speicher viel zu groß) bei realen Szenarien kein bad_alloc mehr bekommt sondern das Programm per SEGFAULT abgeschossen wird.
-
@Schlangenmensch sagte in CodeCoverage mit Threads und Sockets:
Wenn es wichtig ist, dass "out of memory" Fehler sauber gefangen und verarbeitet werden, sollte man das auch testen.
Gerade in Low-Level Code hast du viel solchen oder ähnlichen Error-Handling Code. Der dann wie gesagt schwer zu testen ist.
Häufiger sieht man aber Code, der diese Möglichkeit einfach ignoriert bzw. den Fehler dann einfach durch propagiert.
Richtig. Was oft einen "false sense of security" erzeugt. Denn du hast da im Prinzip überall versteckte Konstrukte wie z.B.:
vec.push_back(123); if (exception) { // hidden code goto unwind_1; // hidden code } // hidden code
(*)
In High-Level Code hast du das quasi nach jedem Funktionsaufruf.Das sind auch alles Branches. Und auch dafür wäre es gut wenn man Coverage hätte. Denn damit kann man sich schnell selbst in den Fuss schiessen.
Ich hatt erst letzens wieder mit einer Funktion zu tun wo ein
unique_lock
mitstd::defer_lock
erzeugt wurde und dann später erst gelockt. Hat ein bisschen gedauert bis ich draufgekommen bin wieso. Grund war dass es wichtig war denunique_lock
vor der Stelle zu erzeugen wo dann wirklich die Mutex gesperrt werden muss, damit er beim Unwinding später zerstört wird - damit die Mutex länger gesperrt bleibt. Weil einer der weiteren Guards im Fehlerfall eine Änderung machen muss, an Variablen auf die der Zugriff über die Mutex synchronisiert wird.Sowas zu übersehen ist super einfach - gerade weil der Code der im Fehlerfall ausgeführt wird nicht direkt sichtbar ist.
Soll heissen: den Fehler durchpropagieren lassen ist nicht immer so "safe" wie viele Programmierer glauben.
*: Ja, ich weiss, Unwinding wird oft über Tables gemacht, d.h. das von mir gezeigte "if" existiert so nicht im generierten Maschinencode. Aber es existiert konzeptionell.
-
Schön, dass hier so eine lebhafte Diskussion entstanden ist, die zeigt, dass die Meinungen doch teilweise weit auseinander gehen.
Ich bewerte die Situation ähnlich wie @hustbear. Ich versuche nicht allzu dogmatisch zu sein, was die Namen der Dinge angeht. Ob das jetzt ein Integrationstest ist oder nicht, spielt für mich ein untergeordnete Rolle. Für mich ist entscheidend, wie ich ein komplexes Setup aus Threads und Sockets halbwegs zeitarm in meinem Test-Framework abhandeln kann.100% CodeCoverage anzustreben ist aus meiner Sicht auch unrealistisch, weil man dann soviel Zeit darin versenkt hat, die man nie wieder bekommt. Testing soll eigentlich auch Zeitvorteil sein und einem späteres irrsinniges Debuggen in Test- oder Produktionsumgebungen ersparen.
Auf badalloc z.B. reagiere ich auch nicht. Das habe ich mal gemacht, aber ein bad_alloc zu behandeln mancht nur dann Sinn, wenn man die die Anwendung soweit "zurückdrehen" kann, dass sie in reduzierter Form weiterlaufen kann, oder aber kontrolliert runtergefahren werden kann. Bei meinen Systemen macht es kaum einen Unterschied ob die Anwendung direkt explodiert oder kontrolliert runtergefahren wird, daher spare ich mir das handling.
-
@It0101 Ob 100% oder nicht hängt auch vom Einsatzgebiet ab. Bei sicherheitsrelevanter Software (im Sinne von Menschenleben hängen dran) würde ich schon die 100% anstreben und wurde auch so gemacht in dem Projekt an dem ich mitgearbeitet habe. Für nen Word braucht man das natürlich nicht zwingend.