Testen von Multithread- und asynchronem Code

Hallo! Diese Woche bestand die Aufgabe darin, einen Integrationstest für eine Spring Boot-Anwendung unter Verwendung der asynchronen Interaktion mit externen Systemen zu schreiben. Es wurde viel Material zum Debuggen von Multithread-Code aktualisiert. Der Artikel „Testen von Multithread-Code und asynchronem Code“ von Jonathan Halterman, dessen Übersetzung unten aufgeführt ist, erregte Aufmerksamkeit.

Vielen Dank an Shalomman , Schroeder und FTOH für die wichtigsten Codekommentare aus dem Originalartikel.

Wenn Sie den Code lange genug schreiben oder nicht, sind Sie wahrscheinlich auf ein Skript gestoßen, in dem Sie Multithread-Code testen müssen. Es wird allgemein angenommen, dass Threads und Tests nicht gemischt werden sollten. Dies geschieht normalerweise, weil Was getestet werden soll, beginnt in einem Multithread-System und kann einzeln ohne Verwendung von Threads getestet werden. Aber was ist, wenn Sie sie nicht trennen können oder mehr, wenn Multithreading der Aspekt des Codes ist, den Sie testen?

Ich bin hier, um Ihnen zu sagen, dass die Threads in den Tests zwar nicht sehr häufig sind, aber durchaus verwendet werden. Die Software-Polizei wird Sie nicht verhaften, weil Sie einen Thread in einem Unit-Test gestartet haben, obwohl es eine andere Sache ist, wie Sie Multithread-Code tatsächlich testen. Einige hervorragende asynchrone Technologien wie Akka und Vert.x bieten Testkits , um diese Belastung zu verringern. Darüber hinaus erfordert das Testen von Multithread-Code normalerweise einen anderen Ansatz als ein typischer synchroner Komponententest.

Wir gehen parallel


Der erste Schritt besteht darin, eine Multithread-Aktion zu starten, für die Sie das Ergebnis überprüfen möchten. Verwenden wir beispielsweise eine hypothetische API, um einen Nachrichtenhandler auf einem Nachrichtenbus zu registrieren und eine Nachricht auf dem Bus zu veröffentlichen, die asynchron in einem separaten Thread an unseren Handler übermittelt wird:

messageBus.registerHandler(message - > { System.out.println("Received " + message); }); messageBus.publish("test"); 

Es sieht gut aus. Wenn der Test startet, sollte der Bus unsere Nachricht in einem anderen Thread an den Handler senden, dies ist jedoch nicht sehr nützlich, da wir nichts überprüfen. Aktualisieren wir unseren Test, um zu bestätigen, dass der Nachrichtenbus unsere Nachricht wie erwartet übermittelt:

 String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg); 

Es sieht besser aus. Wir führen unseren Test durch und er ist grün. Cool! Aber die empfangene Nachricht wurde nirgendwo gedruckt, irgendwo stimmte etwas nicht.

Warte eine Sekunde


Wenn im obigen Test eine Nachricht auf dem Nachrichtenbus veröffentlicht wird, wird sie vom Bus in einem anderen Thread an den Handler übermittelt. Wenn ein Unit-Test-Tool wie JUnit einen Test ausführt, weiß es nichts über Nachrichtenbusflüsse. JUnit kennt nur den Hauptthread, in dem der Test ausgeführt wird. Während der Nachrichtenbus damit beschäftigt ist, die Nachricht zuzustellen, schließt der Test die Ausführung im Haupttest-Thread ab und JUnit meldet den Erfolg. Wie kann man das lösen? Wir benötigen den Haupttest-Thread, um darauf zu warten, dass der Nachrichtenbus unsere Nachricht übermittelt. Fügen wir also eine Schlafanweisung hinzu:

 String msg = "test"; messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); }; messageBus.publish(msg); Thread.sleep(1000); 

Unser Test ist grün und der Ausdruck "Erhalten" wird wie erwartet gedruckt. Cool! Aber eine Sekunde Schlaf bedeutet, dass unser Test mindestens eine Sekunde lang durchgeführt wird und nichts Gutes darin ist. Wir könnten die Schlafzeit verkürzen, aber dann laufen wir Gefahr, den Test abzuschließen, bevor wir eine Nachricht erhalten. Wir brauchen eine Möglichkeit, um zwischen dem Haupttest-Thread und dem Message-Handler-Thread zu koordinieren. Wenn wir uns das Paket java.util.concurrent ansehen, werden wir sicher finden, was wir verwenden können. Was ist mit CountDownLatch ?

 String msg = "test"; CountDownLatch latch = new CountDownLatch(1); messageBus.registerHandler(message -> { System.out.println("Received " + message); assertEquals(message, msg); latch.countDown(); }; messageBus.publish(msg); latch.await(); 

Bei diesem Ansatz teilen wir den CountDownLatch zwischen dem Haupttest-Thread und dem Message-Handler-Thread. Der Haupt-Thread muss auf den Blocker warten. Der Test-Thread gibt den ausstehenden Haupt-Thread frei, indem er nach dem Empfang der Nachricht countDown () auf dem Blocker aufruft. Wir müssen keine Sekunde mehr schlafen. Unser Test dauert genau so lange wie nötig.

So glücklich?


Mit unserem neuen Charme, CountDownLatch, beginnen wir, Multithread-Tests zu schreiben, wie die neuesten Fashionistas. Aber ziemlich schnell stellen wir fest, dass einer unserer Testfälle für immer blockiert ist und nicht endet. Was ist los? Stellen Sie sich das Nachrichtenbus-Szenario vor: Der Blocker lässt Sie warten, wird jedoch erst nach Empfang der Nachricht freigegeben. Wenn der Bus nicht funktioniert und die Nachricht nie zugestellt wird, wird der Test niemals beendet. Fügen wir dem Blocker also eine Zeitüberschreitung hinzu:

 latch.await(1, TimeUnit.SECONDS); 

Ein blockierter Test schlägt nach 1 Sekunde mit einer TimeoutException-Ausnahme fehl. Am Ende werden wir das Problem finden und den Test beheben, aber entscheiden, die Zeitüberschreitungen beizubehalten. Sollte dies jemals wieder vorkommen, würden wir es vorziehen, wenn unser Test für eine Sekunde gesperrt und abgestürzt wird, als für immer zu blockieren und überhaupt nicht abgeschlossen zu werden.
Ein weiteres Problem, das wir beim Schreiben von Tests bemerken, ist, dass sie alle zu bestehen scheinen, auch wenn sie es wahrscheinlich nicht sollten. Wie ist das möglich? Betrachten Sie den Nachrichtenverarbeitungstest erneut:

 messageBus.registerHandler(message -> { assertEquals(message, msg); latch.countDown(); }; 

Wir hätten CountDownLatch verwenden sollen, um den Abschluss unseres Tests mit dem Haupttest-Thread zu koordinieren, aber was ist mit Asserts? Wenn die Validierung fehlschlägt, weiß JUnit davon? Es stellt sich heraus, dass fehlerhafte Überprüfungen von JUnit völlig unbemerkt bleiben, da wir im Haupttest-Thread keine Validierung durchführen. Versuchen wir ein kleines Skript, um dies zu testen:

 CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { assertTrue(false); latch.countDown(); }).start(); latch.await(); 

Der Test ist grün! Was machen wir jetzt? Wir benötigen eine Möglichkeit, Testfehler vom Message-Handler-Stream an den Haupttest-Stream zurückzusenden. Wenn im Message-Handler-Thread ein Fehler auftritt, muss er im Haupt-Thread erneut angezeigt werden, damit der Test wie erwartet umgedreht wird. Versuchen wir Folgendes:

 CountDownLatch latch = new CountDownLatch(1); AtomicReference<AssertionError> failure = new AtomicReference<>(); new Thread(() -> { try { assertTrue(false); } catch (AssertionError e) { failure.set(e); } latch.countDown(); }).start(); latch.await(); if (failure.get() != null) throw failure.get(); 

Schnellstart und ja, der Test schlägt fehl, wie es sollte! Jetzt können wir zurückgehen und CountDownLatches, try / catch und AtomicReference-Blöcke zu allen unseren Testfällen hinzufügen. Cool! Eigentlich nicht cool, es sieht aus wie ein Boilerplate.

Schneiden Sie den Müll aus


Im Idealfall benötigen wir eine API, mit der wir die ausstehende, überprüfende und wiederaufnehmende Ausführung zwischen Threads koordinieren können, damit Komponententests wie erwartet bestanden werden oder fehlschlagen können, unabhängig davon, wo die Prüfung fehlschlägt. Glücklicherweise bietet ConcurrentUnit ein leichtes Framework, das genau das tut: Kellner. Lassen Sie uns den obigen Nachrichtenverarbeitungstest zum letzten Mal anpassen und sehen, was Waiter von ConcurrentUnit für uns tun kann:

 String msg = "test"; Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.assertEquals(message, msg); waiter.resume(); }; messageBus.publish(msg); waiter.await(1, TimeUnit.SECONDS); 

In diesem Test sehen wir, dass der Kellner den Platz unseres CountDownLatch und unserer AtomicReference eingenommen hat. Mit Waiter blockieren wir den Haupttest-Thread, führen den Test durch und setzen dann den Haupttest-Thread fort, damit der Test abgeschlossen werden kann. Wenn die Prüfung fehlschlägt, wird durch Aufrufen von waiter.await die Sperre automatisch aufgehoben und ein Fehler ausgelöst, der dazu führt, dass der Test bestanden wird oder fehlschlägt, selbst wenn die Prüfung von einem anderen Thread aus durchgeführt wurde.

Noch paralleler


Nachdem wir zertifizierte Multithread-Tester geworden sind, möchten wir möglicherweise bestätigen, dass mehrere asynchrone Aktionen ausgeführt werden. Der Kellner von ConcurrentUnit macht dies einfach:

 Waiter waiter = new Waiter(); messageBus.registerHandler(message -> { waiter.resume(); }; messageBus.publish("one"); messageBus.publish("two"); waiter.await(1, TimeUnit.SECONDS, 2); 

Hier veröffentlichen wir zwei Nachrichten auf dem Bus und überprüfen, ob beide Nachrichten zugestellt wurden. Der Kellner wartet darauf, dass resume () zweimal aufgerufen wird. Wenn keine Nachrichten zugestellt werden und der Lebenslauf nicht innerhalb von 1 Sekunde zweimal aufgerufen wird, schlägt der Test mit einem TimeoutException-Fehler fehl.
Ein allgemeiner Tipp bei diesem Ansatz ist, sicherzustellen, dass Ihre Zeitüberschreitungen lang genug sind, um alle gleichzeitigen Aktionen auszuführen. Wenn das zu testende System unter normalen Bedingungen wie erwartet funktioniert, spielt das Zeitlimit keine Rolle und wird nur im Falle eines Systemausfalls aus irgendeinem Grund wirksam.

Zusammenfassung


In diesem Artikel haben wir gelernt, dass Multithread-Unit-Tests nicht böse sind und ziemlich einfach durchzuführen sind. Wir haben den allgemeinen Ansatz kennengelernt, wenn wir den Haupttest-Thread blockieren, Überprüfungen von einigen anderen Threads durchführen und dann den Haupt-Thread fortsetzen. Und wir haben etwas über ConcurrentUnit gelernt, das diese Aufgabe erleichtern kann.
Viel Spaß beim Testen!

Übersetzt von @middle_java

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


All Articles