Prueba de código multiproceso y asíncrono

Hola Esta semana, la tarea consistía en escribir una prueba de integración para una aplicación Spring Boot utilizando la interacción asincrónica con sistemas externos. Se actualizó mucho material sobre la depuración de código multiproceso. El artículo "Prueba de código multiproceso y asincrónico" por Jonathan Halterman, cuya traducción se da a continuación, atrajo la atención.

Gracias a shalomman , schroeder y FTOH por los comentarios de código más importantes del artículo original.

Si escribe código el tiempo suficiente o tal vez no, entonces probablemente se encontró con un script en el que necesita probar el código multiproceso. Generalmente se cree que los hilos y las pruebas no deben mezclarse. Esto generalmente sucede porque lo que se debe probar solo comienza dentro de un sistema de subprocesos múltiples y se puede probar individualmente sin usar hilos. Pero, ¿qué sucede si no puede separarlos, o más, si el subproceso múltiple es ese aspecto del código que está probando?

Estoy aquí para decirles que aunque los hilos en las pruebas no son muy comunes, son bastante utilizados. La policía de software no lo arrestará por comenzar un subproceso en una prueba de unidad, aunque la forma de probar el código multiproceso es otra cuestión. Algunas excelentes tecnologías asincrónicas, como Akka y Vert.x, proporcionan kits de prueba para aliviar esta carga. Pero más allá de eso, probar código de subprocesos múltiples generalmente requiere un enfoque diferente al de una prueba de unidad síncrona típica.

Vamos paralelos


El primer paso es iniciar cualquier acción multiproceso para la que desee verificar el resultado. Por ejemplo, usemos una API hipotética para registrar un controlador de mensajes en un bus de mensajes y publicar un mensaje en el bus, que se entregará a nuestro controlador de forma asincrónica en un hilo separado:

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

Se ve bien Cuando comienza la prueba, el bus debe entregar nuestro mensaje al controlador en otro hilo, pero esto no es muy útil, ya que no verificamos nada. Actualicemos nuestra prueba para confirmar que el bus de mensajes entrega nuestro mensaje como se esperaba:

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

Se ve mejor. Ejecutamos nuestra prueba y es verde. Genial! Pero el mensaje Recibido no se imprimió en ninguna parte, algo estaba mal en alguna parte.

Espera un segundo


En la prueba anterior, cuando se publica un mensaje en el bus de mensajes, el bus lo entrega al controlador en otro hilo. Pero cuando una herramienta de prueba de unidades como JUnit ejecuta una prueba, no sabe nada sobre los flujos del bus de mensajes. JUnit solo conoce el hilo principal en el que ejecuta la prueba. Por lo tanto, mientras el bus de mensajes está ocupado tratando de entregar el mensaje, la prueba completa la ejecución en el hilo de prueba principal y JUnit informa de éxito. ¿Cómo resolver esto? Necesitamos el hilo de prueba principal para esperar a que el bus de mensajes entregue nuestro mensaje. Así que agreguemos una declaración de suspensión:

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

Nuestra prueba es verde y la expresión Recibido se imprime como se esperaba. Genial! Pero un segundo de sueño significa que nuestra prueba se realiza durante al menos un segundo, y no tiene nada de bueno. Podríamos reducir el tiempo de sueño, pero luego corremos el riesgo de completar la prueba antes de recibir un mensaje. Necesitamos una forma de coordinar entre el hilo de prueba principal y el hilo del controlador de mensajes. Mirando el paquete java.util.concurrent , estamos seguros de encontrar lo que podemos usar. ¿Qué pasa con 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(); 

En este enfoque, compartimos CountDownLatch entre el hilo de prueba principal y el hilo del controlador de mensajes. El hilo principal se ve obligado a esperar en el bloqueador. El hilo de prueba libera el hilo principal pendiente llamando a countDown () en el bloqueador después de recibir el mensaje. Ya no necesitamos dormir por un segundo. Nuestra prueba lleva exactamente tanto tiempo como sea necesario.

Tan feliz?


Con nuestro nuevo encanto, CountDownLatch, comenzamos a escribir pruebas de subprocesos múltiples, como las últimas fashionistas. Pero bastante rápido, notamos que uno de nuestros casos de prueba está bloqueado para siempre y no termina. Que esta pasando Considere el escenario del bus de mensajes: el bloqueador lo hace esperar, pero se libera solo después de recibir el mensaje. Si el bus no funciona y el mensaje nunca se entrega, la prueba nunca terminará. Entonces agreguemos un tiempo de espera al bloqueador:

 latch.await(1, TimeUnit.SECONDS); 

Una prueba que está bloqueada falla después de 1 segundo con una excepción TimeoutException. Al final, encontraremos el problema y solucionaremos la prueba, pero decidiremos dejar los tiempos de espera en su lugar. Si esto vuelve a ocurrir, preferiríamos que nuestra prueba se bloquee por un segundo y se bloquee, que bloquear para siempre y no completarla en absoluto.
Otro problema que notamos cuando escribimos exámenes es que todos parecen pasar incluso cuando probablemente no deberían. ¿Cómo es esto posible? Considere la prueba de procesamiento de mensajes nuevamente:

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

Deberíamos haber usado CountDownLatch para coordinar la finalización de nuestra prueba con el hilo de prueba principal, pero ¿qué pasa con las afirmaciones? Si la validación falla, ¿JUnit lo sabrá? Resulta que, dado que no realizamos la validación en el subproceso de prueba principal, JUnit permanece completamente desapercibido. Probemos un pequeño script para probar esto:

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

¡La prueba es verde! Entonces, ¿qué hacemos ahora? Necesitamos una forma de enviar cualquier error de prueba desde la secuencia del controlador de mensajes a la secuencia de prueba principal. Si ocurre una falla en el hilo del manejador de mensajes, necesitamos que vuelva a aparecer en el hilo principal para que la prueba se voltee, como se esperaba. Intentemos hacer esto:

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

Inicio rápido y sí, la prueba falla, ¡como debería! Ahora podemos regresar y agregar bloques CountDownLatches, try / catch y AtomicReference a todos nuestros casos de prueba. Genial! En realidad, no es genial, parece una repetitiva.

Cortar la basura


Idealmente, necesitamos una API que nos permita coordinar la espera, la verificación y la reanudación de la ejecución entre subprocesos, para que las pruebas unitarias puedan pasar o fallar como se esperaba, sin importar dónde falle la validación. Afortunadamente, ConcurrentUnit proporciona un marco ligero que hace exactamente eso: Waiter. Adaptemos la prueba de procesamiento de mensajes anterior por última vez y veamos qué puede hacer Waiter de ConcurrentUnit por nosotros:

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

En esta prueba, vemos que Waiter ha tomado el lugar de nuestro CountDownLatch y AtomicReference. Con Waiter, bloqueamos el hilo de prueba principal, realizamos la prueba, luego reanudamos el hilo de prueba principal para que la prueba pueda completarse. Si la verificación falla, entonces llamar a waiter.await liberará automáticamente el bloqueo y lanzará una falla, lo que hará que la prueba pase o falle, como debería ser, incluso si la verificación se realizó desde otro hilo.

Aún más paralelo


Ahora que nos hemos convertido en probadores de subprocesos múltiples certificados, es posible que deseemos confirmar que se están produciendo varias acciones asincrónicas. ConcurrentUnit's Waiter hace esto simple:

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

Aquí publicamos dos mensajes en el bus y verificamos que ambos se entreguen, haciendo que Waiter espere a que se llame a resume () 2 veces. Si no se entregan mensajes y no se llama a reanudar dos veces en 1 segundo, la prueba fallará con un error de TimeoutException.
Un consejo general con este enfoque es asegurarse de que sus tiempos de espera sean lo suficientemente largos para completar cualquier acción concurrente. En condiciones normales, cuando el sistema bajo prueba funciona como se esperaba, el tiempo de espera no importa y solo tiene efecto en caso de falla del sistema por cualquier motivo.

Resumen


En este artículo, aprendimos que las pruebas unitarias multiproceso no son malas y son bastante fáciles de hacer. Aprendimos sobre el enfoque general cuando bloqueamos el hilo de prueba principal, realizamos comprobaciones de otros hilos y luego reanudamos el hilo principal. Y aprendimos sobre ConcurrentUnit , que puede facilitar esta tarea.
¡Feliz prueba!

Traducido por @middle_java

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


All Articles