Test de code multithread et asynchrone

Salut Cette semaine, la tâche consistait à écrire un test d'intégration pour une application Spring Boot utilisant une interaction asynchrone avec des systèmes externes. Actualisé beaucoup de matériel sur le débogage de code multithread. L'article «Tester le code multithread et asynchrone» de Jonathan Halterman, dont ma traduction est donnée ci-dessous, a attiré l'attention.

Merci à shalomman , schroeder et FTOH pour les commentaires de code les plus importants de l'article original.

Si vous écrivez le code assez longtemps ou peut-être pas, alors vous êtes probablement tombé sur un script dans lequel vous devez tester du code multithread. Il est généralement admis que les threads et les tests ne doivent pas être mélangés. Cela se produit généralement parce que ce qui doit être testé ne fait que commencer dans un système multithread et peut être testé individuellement sans utiliser de threads. Mais que faire si vous ne pouvez pas les séparer, ou plus, si le multithreading est cet aspect du code que vous testez?

Je suis ici pour vous dire que bien que les threads dans les tests ne soient pas très courants, ils sont assez utilisés. La police logicielle ne vous arrêtera pas pour avoir démarré un thread dans un test unitaire, bien que la façon de tester réellement le code multi-thread soit une autre affaire. Certaines excellentes technologies asynchrones, telles que Akka et Vert.x, fournissent des kits de test pour alléger ce fardeau. Mais au-delà de cela, le test de code multithread nécessite généralement une approche différente de celle d'un test unitaire synchrone typique.

Nous allons parallèlement


La première étape consiste à lancer toute action multithread dont vous souhaitez vérifier le résultat. Par exemple, utilisons une API hypothétique pour enregistrer un gestionnaire de messages sur un bus de messages et publier un message sur le bus, qui sera remis à notre gestionnaire de manière asynchrone dans un thread séparé:

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

Ça a l'air bien. Lorsque le test démarre, le bus doit livrer notre message au gestionnaire dans un autre thread, mais ce n'est pas très utile, car nous ne vérifions rien. Mettons à jour notre test pour confirmer que le bus de messages livre notre message comme prévu:

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

Ça a l'air mieux. Nous exécutons notre test et il est vert. Cool! Mais le message reçu n'a été imprimé nulle part, quelque chose n'allait pas quelque part.

Attends une seconde


Dans le test ci-dessus, lorsqu'un message est publié sur le bus de messages, il est remis par le bus au gestionnaire dans un autre thread. Mais lorsqu'un outil de test unitaire tel que JUnit exécute un test, il ne sait rien des flux de bus de messages. JUnit ne connaît que le thread principal dans lequel il exécute le test. Ainsi, alors que le bus de messages est occupé à essayer de délivrer le message, le test termine l'exécution dans le thread de test principal et JUnit signale la réussite. Comment résoudre ça? Nous avons besoin du thread de test principal pour attendre que le bus de messages transmette notre message. Ajoutons donc une déclaration de sommeil:

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

Notre test est vert et l'expression Reçu est imprimée comme prévu. Cool! Mais une seconde de sommeil signifie que notre test est effectué pendant au moins une seconde, et il n'y a rien de bon en elle. Nous pourrions réduire le temps de sommeil, mais nous courons le risque de terminer le test avant de recevoir un message. Nous avons besoin d'un moyen de coordination entre le thread de test principal et le thread du gestionnaire de messages. En regardant le package java.util.concurrent , nous sommes sûrs de trouver ce que nous pouvons utiliser. Qu'en est-il de 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(); 

Dans cette approche, nous partageons le CountDownLatch entre le thread de test principal et le thread du gestionnaire de messages. Le thread principal est obligé d'attendre le bloqueur. Le thread de test libère le thread principal en attente en appelant countDown () sur le bloqueur après avoir reçu le message. Nous n'avons plus besoin de dormir une seconde. Notre test prend exactement autant de temps que nécessaire.

Si heureux?


Avec notre nouveau charme, CountDownLatch, nous commençons à écrire des tests multi-thread, comme les dernières fashionistas. Mais assez rapidement, nous remarquons que l'un de nos cas de test est bloqué pour toujours et ne se termine pas. Que se passe-t-il? Considérez le scénario du bus de messages: le bloqueur vous fait attendre, mais il n'est libéré qu'après avoir reçu le message. Si le bus ne fonctionne pas et que le message n'est jamais transmis, le test ne se terminera jamais. Ajoutons donc un délai d'attente au bloqueur:

 latch.await(1, TimeUnit.SECONDS); 

Un test bloqué échoue après 1 seconde avec une exception TimeoutException. À la fin, nous trouverons le problème et corrigerons le test, mais nous déciderons de laisser les délais d'attente en place. Si cela se reproduit, nous préférerions que notre test se verrouille une seconde et se bloque, que de bloquer pour toujours et de ne pas être terminé du tout.
Un autre problème que nous remarquons lors de l'écriture des tests est qu'ils semblent tous réussir même s'ils ne devraient probablement pas. Comment est-ce possible? Considérez à nouveau le test de traitement des messages:

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

Nous aurions dû utiliser CountDownLatch pour coordonner la fin de notre test avec le thread de test principal, mais qu'en est-il des assertions? Si la validation échoue, JUnit le saura-t-il? Il s'avère que puisque nous n'effectuons pas de validation dans le thread de test principal, toutes les vérifications défectueuses restent complètement inaperçues par JUnit. Essayons un petit script pour tester ceci:

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

Le test est vert! Alors qu'est-ce qu'on fait maintenant? Nous avons besoin d'un moyen de renvoyer toutes les erreurs de test du flux du gestionnaire de messages vers le flux de test principal. Si une défaillance se produit dans le thread du gestionnaire de messages, nous en avons besoin pour réapparaître dans le thread principal afin que le test bascule, comme prévu. Essayons de faire ceci:

 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(); 

Démarrage rapide et oui, le test échoue, comme il se doit! Maintenant, nous pouvons revenir en arrière et ajouter des blocs CountDownLatches, try / catch et AtomicReference à tous nos cas de test. Cool! En fait, pas cool, ça ressemble à un passe-partout.

Découpez la poubelle


Idéalement, nous avons besoin d'une API qui nous permet de coordonner l'attente, la vérification et la reprise de l'exécution entre les threads, afin que les tests unitaires puissent réussir ou échouer comme prévu, peu importe où la validation échoue. Heureusement, ConcurrentUnit fournit un cadre léger qui fait exactement cela: Waiter. Adaptons le test de traitement des messages ci-dessus pour la dernière fois et voyons ce que Waiter de ConcurrentUnit peut faire pour nous:

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

Dans ce test, nous voyons que Waiter a pris la place de CountDownLatch et AtomicReference. Avec Waiter, nous bloquons le thread de test principal, effectuons le test, puis reprenons le thread de test principal afin que le test puisse se terminer. Si la vérification échoue, l'appel de waiter.await libérera automatiquement le verrou et lancera un échec, ce qui entraînera la réussite ou l'échec du test, comme il se doit, même si la vérification a été effectuée à partir d'un autre thread.

Encore plus parallèle


Maintenant que nous sommes devenus des testeurs multi-threads certifiés, nous pourrions vouloir confirmer que plusieurs actions asynchrones se produisent. Le serveur de ConcurrentUnit rend cela simple:

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

Ici, nous publions deux messages sur le bus et vérifions que les deux messages sont livrés, ce qui fait que Waiter attend que resume () soit appelé 2 fois. Si les messages ne sont pas remis et que la reprise n'est pas appelée deux fois en 1 seconde, le test échoue avec une erreur TimeoutException.
Une astuce générale avec cette approche est de vous assurer que vos délais d'attente sont suffisamment longs pour effectuer toutes les actions simultanées. Dans des conditions normales, lorsque le système testé fonctionne comme prévu, la temporisation n'a pas d'importance et ne prend effet qu'en cas de défaillance du système pour une raison quelconque.

Résumé


Dans cet article, nous avons appris que le test unitaire multithread n'est pas mauvais et qu'il est assez facile à faire. Nous avons appris l'approche générale lorsque nous bloquons le thread de test principal, effectuons des vérifications à partir d'autres threads, puis reprenons le thread principal. Et nous avons découvert ConcurrentUnit , qui peut faciliter cette tâche.
Bon test!

Traduit par @middle_java

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


All Articles