Cómo usar JUnit para probar procesos asincrónicos


204

¿Cómo se prueban los métodos que activan procesos asincrónicos con JUnit?

No sé cómo hacer que mi prueba espere a que finalice el proceso (no es exactamente una prueba unitaria, es más como una prueba de integración, ya que involucra varias clases y no solo una).


Puede probar JAT (Prueba asincrónica de Java): bitbucket.org/csolar/jat
cs0lar

2
JAT tiene 1 observador y no se ha actualizado en 1,5 años. Awaitility se actualizó hace solo 1 mes y está en la versión 1.6 en el momento de escribir este artículo. No estoy afiliado a ninguno de los proyectos, pero si fuera a invertir en una adición a mi proyecto, le daría más crédito a Awaitility en este momento.
Les Hazlewood

JAT aún no tiene actualizaciones: "Última actualización 2013-01-19". Simplemente ahorre tiempo para seguir el enlace.
deamon

@LesHazlewood, un observador es malo para JAT, pero no ha habido actualizaciones durante años ... Solo un ejemplo. ¿Con qué frecuencia actualiza la pila TCP de bajo nivel de su sistema operativo, si solo funciona? La alternativa a JAT se responde a continuación stackoverflow.com/questions/631598/… .
user1742529

Respuestas:


46

En mi humilde opinión, es una mala práctica hacer que las pruebas unitarias creen o esperen subprocesos, etc. Le gustaría que estas pruebas se ejecuten en segundos. Es por eso que me gustaría proponer un enfoque de 2 pasos para probar procesos asíncronos.

  1. Pruebe que su proceso asíncrono se envíe correctamente. Puede burlarse del objeto que acepta sus solicitudes asíncronas y asegurarse de que el trabajo enviado tenga las propiedades correctas, etc.
  2. Comprueba que tus devoluciones de llamada asíncronas están haciendo lo correcto. Aquí puede simular el trabajo enviado originalmente y asumir que se inicializó correctamente y verificar que sus devoluciones de llamada sean correctas.

148
Por supuesto. Pero a veces es necesario probar el código que se supone específicamente para administrar subprocesos.
Lacker

77
Para aquellos de nosotros que usamos Junit o TestNG para hacer pruebas de integración (y no solo pruebas de unidad), o pruebas de aceptación del usuario (por ejemplo, con pepino), es absolutamente necesario esperar una finalización asíncrona y verificar el resultado.
Les Hazlewood

38
¿Los procesos asincrónicos son algunos de los códigos más complicados para acertar y usted dice que no debe usar pruebas unitarias para ellos y solo probar con un solo hilo? Esa es una muy mala idea.
Charles

18
Las pruebas simuladas a menudo no demuestran que la funcionalidad funciona de principio a fin. La funcionalidad asíncrona necesita ser probada de manera asíncrona para asegurar que funcione. Llámelo prueba de integración si lo prefiere, pero es una prueba que todavía se necesita.
Scott Boring

44
Esta no debería ser la respuesta aceptada. Las pruebas van más allá de las pruebas unitarias. El OP lo llama más una prueba de integración que una prueba de unidad.
Jeremiah Adams

191

Una alternativa es usar la clase CountDownLatch .

public class DatabaseTest {

    /**
     * Data limit
     */
    private static final int DATA_LIMIT = 5;

    /**
     * Countdown latch
     */
    private CountDownLatch lock = new CountDownLatch(1);

    /**
     * Received data
     */
    private List<Data> receiveddata;

    @Test
    public void testDataRetrieval() throws Exception {
        Database db = new MockDatabaseImpl();
        db.getData(DATA_LIMIT, new DataCallback() {
            @Override
            public void onSuccess(List<Data> data) {
                receiveddata = data;
                lock.countDown();
            }
        });

        lock.await(2000, TimeUnit.MILLISECONDS);

        assertNotNull(receiveddata);
        assertEquals(DATA_LIMIT, receiveddata.size());
    }
}

NOTA: no puede simplemente usar sincronizado con un objeto normal como bloqueo, ya que las devoluciones de llamada rápidas pueden liberar el bloqueo antes de que se llame al método de espera del bloqueo. Vea esta publicación de blog de Joe Walnes.

EDITAR Se eliminaron los bloques sincronizados alrededor de CountDownLatch gracias a los comentarios de @jtahlborn y @Ring


8
por favor no sigas este ejemplo, es incorrecto. usted debe no estar sincronizando en un CountDownLatch ya que maneja el hilo de seguridad interna.
jtahlborn

1
Fue un buen consejo hasta la parte sincronizada, que probablemente consumió cerca de 3-4 horas de tiempo de depuración. stackoverflow.com/questions/11007551/...
Anillo

2
Disculpas por el error. He editado la respuesta adecuadamente.
Martin

77
Si está verificando que se llamó a onSuccess, debe afirmar que lock.await devuelve verdadero.
Gilbert

1
@ Martin sería correcto, pero significaría que tiene un problema diferente que necesita ser solucionado.
George Aristy

76

Puedes intentar usar el biblioteca Awaitility . Facilita la prueba de los sistemas de los que habla.


21
Un descargo de responsabilidad amistoso: Johan es el principal contribuyente al proyecto.
dbm

1
Sufre el problema fundamental de tener que esperar (las pruebas unitarias deben ejecutarse rápidamente ). Idealmente, realmente no desea esperar un milisegundo más de lo necesario, por lo que creo que usar CountDownLatch(ver respuesta de @ Martin) es mejor en este sentido.
George Aristy

Realmente asombroso.
R. Karlus

Esta es la biblioteca perfecta que cumple con mis requisitos de prueba de integración de proceso asíncrono. Realmente asombroso. La biblioteca parece estar bien mantenida y tiene características que se extienden de básico a avanzado que creo que son suficientes para satisfacer la mayoría de los escenarios. ¡Gracias por la increíble referencia!
Tanvir

Sugerencia realmente asombrosa. Gracias
RoyalTiger

68

Si usa un CompletableFuture (introducido en Java 8) o un SettableFuture (de Google Guava ), puede hacer que su prueba finalice tan pronto como termine, en lugar de esperar una cantidad de tiempo preestablecida. Su prueba se vería así:

CompletableFuture<String> future = new CompletableFuture<>();
executorService.submit(new Runnable() {         
    @Override
    public void run() {
        future.complete("Hello World!");                
    }
});
assertEquals("Hello World!", future.get());

44
... y si estás atrapado con java-less-than-eight prueba guayabas SettableFuture que hace más o menos lo mismo
Markus T


18

Un método que he encontrado bastante útil para probar métodos asincrónicos es inyectar un Executor instancia en el constructor del objeto a prueba. En producción, la instancia del ejecutor está configurada para ejecutarse de forma asíncrona, mientras que en la prueba se puede burlar para ejecutarse de forma sincrónica.

Supongamos que estoy tratando de probar el método asincrónico Foo#doAsync(Callback c),

class Foo {
  private final Executor executor;
  public Foo(Executor executor) {
    this.executor = executor;
  }

  public void doAsync(Callback c) {
    executor.execute(new Runnable() {
      @Override public void run() {
        // Do stuff here
        c.onComplete(data);
      }
    });
  }
}

En producción, construiría Foocon una Executors.newSingleThreadExecutor()instancia de Ejecutor, mientras que en la prueba probablemente lo construiría con un ejecutor síncrono que hace lo siguiente:

class SynchronousExecutor implements Executor {
  @Override public void execute(Runnable r) {
    r.run();
  }
}

Ahora mi prueba JUnit del método asincrónico está bastante limpia:

@Test public void testDoAsync() {
  Executor executor = new SynchronousExecutor();
  Foo objectToTest = new Foo(executor);

  Callback callback = mock(Callback.class);
  objectToTest.doAsync(callback);

  // Verify that Callback#onComplete was called using Mockito.
  verify(callback).onComplete(any(Data.class));

  // Assert that we got back the data that we expected.
  assertEquals(expectedData, callback.getData());
}

No funciona si quiero probar la integración de algo que involucra una llamada de biblioteca asíncrona como la de SpringWebClient
Stefan Haberl

8

No hay nada intrínsecamente malo en probar el código roscado / asíncrono, particularmente si el tema del código que está probando es enhebrar . El enfoque general para probar estas cosas es:

  • Bloquee el hilo de prueba principal
  • Capture aserciones fallidas de otros hilos
  • Desbloquee el hilo de prueba principal
  • Relanzar cualquier falla

Pero eso es mucho repetitivo para una prueba. Un enfoque mejor / más simple es usar ConcurrentUnit :

  final Waiter waiter = new Waiter();

  new Thread(() -> {
    doSomeWork();
    waiter.assertTrue(true);
    waiter.resume();
  }).start();

  // Wait for resume() to be called
  waiter.await(1000);

El beneficio de esto sobre el CountdownLatchenfoque es que es menos detallado ya que las fallas de aserción que ocurren en cualquier hilo se informan correctamente al hilo principal, lo que significa que la prueba falla cuando debería. Una valoración crítica que compara el CountdownLatchenfoque de ConcurrentUnit es aquí .

También escribí una publicación de blog sobre el tema para aquellos que quieran aprender un poco más de detalle.


Una solución similar que he usado en el pasado es github.com/MichaelTamm/junit-toolbox , también presentada como una extensión de terceros en junit.org/junit4
dschulten

4

¿Qué tal llamar SomeObject.waity notifyAllcomo se describe aquí O usar el método Robotiums Solo.waitForCondition(...) O usar una clase que escribí para hacer esto? (Ver comentarios y clase de prueba para saber cómo usar)


1
El problema con el enfoque de espera / notificación / interrupción es que el código que está probando puede interferir potencialmente con los hilos de espera (lo he visto suceder). Esta es la razón por la cual ConcurrentUnit utiliza un circuito privado que los subprocesos pueden esperar, que no pueden ser interferidos inadvertidamente por interrupciones en el subproceso de prueba principal.
Jonathan

3

Encuentro una biblioteca socket.io para probar la lógica asincrónica. Se ve de manera simple y breve usando LinkedBlockingQueue . Aquí hay un ejemplo :

    @Test(timeout = TIMEOUT)
public void message() throws URISyntaxException, InterruptedException {
    final BlockingQueue<Object> values = new LinkedBlockingQueue<Object>();

    socket = client();
    socket.on(Socket.EVENT_CONNECT, new Emitter.Listener() {
        @Override
        public void call(Object... objects) {
            socket.send("foo", "bar");
        }
    }).on(Socket.EVENT_MESSAGE, new Emitter.Listener() {
        @Override
        public void call(Object... args) {
            values.offer(args);
        }
    });
    socket.connect();

    assertThat((Object[])values.take(), is(new Object[] {"hello client"}));
    assertThat((Object[])values.take(), is(new Object[] {"foo", "bar"}));
    socket.disconnect();
}

Usando LinkedBlockingQueue, tome la API para bloquear hasta obtener el resultado de forma síncrona. Y establezca el tiempo de espera para evitar asumir demasiado tiempo para esperar el resultado.


1
Enfoque impresionante!
Aminografía

3

Vale la pena mencionar que hay un capítulo muy útil Testing Concurrent Programsen Concurrencia en la práctica que describe algunos enfoques de pruebas unitarias y brinda soluciones para los problemas.


1
¿Qué enfoque es ese? ¿Podrías dar un ejemplo?
Bruno Ferreira

2

Esto es lo que estoy usando hoy en día si el resultado de la prueba se produce de forma asincrónica.

public class TestUtil {

    public static <R> R await(Consumer<CompletableFuture<R>> completer) {
        return await(20, TimeUnit.SECONDS, completer);
    }

    public static <R> R await(int time, TimeUnit unit, Consumer<CompletableFuture<R>> completer) {
        CompletableFuture<R> f = new CompletableFuture<>();
        completer.accept(f);
        try {
            return f.get(time, unit);
        } catch (InterruptedException | TimeoutException e) {
            throw new RuntimeException("Future timed out", e);
        } catch (ExecutionException e) {
            throw new RuntimeException("Future failed", e.getCause());
        }
    }
}

Usando importaciones estáticas, la prueba dice algo agradable. (nota, en este ejemplo estoy comenzando un hilo para ilustrar la idea)

    @Test
    public void testAsync() {
        String result = await(f -> {
            new Thread(() -> f.complete("My Result")).start();
        });
        assertEquals("My Result", result);
    }

Si f.completeno se llama, la prueba fallará después de un tiempo de espera. También puedes usar f.completeExceptionallypara fallar temprano.


2

Aquí hay muchas respuestas, pero una simple es crear un CompletableFuture completo y usarlo:

CompletableFuture.completedFuture("donzo")

Entonces en mi prueba:

this.exactly(2).of(mockEventHubClientWrapper).sendASync(with(any(LinkedList.class)));
this.will(returnValue(new CompletableFuture<>().completedFuture("donzo")));

Solo me estoy asegurando de que todas estas cosas se llamen de todos modos. Esta técnica funciona si está utilizando este código:

CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();

¡Se deslizará a través de él a medida que terminen todos los CompletableFutures!


2

Evite realizar pruebas con subprocesos paralelos siempre que pueda (que es la mayoría de las veces). Esto solo hará que sus pruebas sean escamosas (a veces pasa, a veces falla).

Solo cuando necesite llamar a otra biblioteca / sistema, es posible que tenga que esperar en otros hilos, en ese caso siempre use la biblioteca Awaitility en lugar de Thread.sleep().

Nunca solo llame get()o join()en sus pruebas, de lo contrario, sus pruebas podrían ejecutarse para siempre en su servidor CI en caso de que el futuro nunca se complete. Siempre afirme isDone()primero en sus pruebas antes de llamar get(). Para CompletionStage, eso es .toCompletableFuture().isDone().

Cuando prueba un método sin bloqueo como este:

public static CompletionStage<String> createGreeting(CompletableFuture<String> future) {
    return future.thenApply(result -> "Hello " + result);
}

entonces no solo debe probar el resultado pasando un Futuro completo en la prueba, también debe asegurarse de que su método doSomething()no se bloquee llamando join()o get(). Esto es importante en particular si utiliza un marco sin bloqueo.

Para hacer eso, pruebe con un futuro no completado que configuró para completar manualmente:

@Test
public void testDoSomething() throws Exception {
    CompletableFuture<String> innerFuture = new CompletableFuture<>();
    CompletableFuture<String> futureResult = createGreeting(innerFuture).toCompletableFuture();
    assertFalse(futureResult.isDone());

    // this triggers the future to complete
    innerFuture.complete("world");
    assertTrue(futureResult.isDone());

    // futher asserts about fooResult here
    assertEquals(futureResult.get(), "Hello world");
}

De esa manera, si agrega future.join()a doSomething (), la prueba fallará.

Si su Servicio utiliza un Servicio de Ejecutor como en thenApplyAsync(..., executorService), entonces en sus pruebas inyecte un Servicio de Ejecutor de un solo subproceso, como el de guayaba:

ExecutorService executorService = Executors.newSingleThreadExecutor();

Si su código usa forkJoinPool como thenApplyAsync(...), por ejemplo , reescriba el código para usar un ExecutorService (hay muchas buenas razones) o use Awaitility.

Para acortar el ejemplo, hice de BarService un argumento de método implementado como un lambda Java8 en la prueba, por lo general, sería una referencia inyectada de la que te burlarías.


Hola @tkruse, ¿quizás tienes un repositorio público de git con una prueba usando esta técnica?
Cristiano

@ Cristiano: eso estaría en contra de la filosofía SO. En cambio, cambié los métodos para compilar sin ningún código adicional (todas las importaciones son java8 + o junit) cuando las pego en una clase de prueba de junit vacía. Siéntase libre de votar.
tkruse

Lo entendí ahora. Gracias. Mi problema ahora es probar cuándo los métodos devuelven CompletableFuture pero aceptan otros objetos como parámetros distintos de CompletableFuture.
Cristiano

En su caso, ¿quién crea el CompletableFuture que devuelve el método? Si es otro servicio, eso puede ser burlado y mi técnica aún se aplica. Si el método mismo crea un CompletableFuture, la situación cambia mucho, por lo que podría hacer una nueva pregunta al respecto. Entonces depende de qué hilo completará el futuro que regrese su método.
tkruse

1

Prefiero usar esperar y notificar. Es simple y claro.

@Test
public void test() throws Throwable {
    final boolean[] asyncExecuted = {false};
    final Throwable[] asyncThrowable= {null};

    // do anything async
    new Thread(new Runnable() {
        @Override
        public void run() {
            try {
                // Put your test here.
                fail(); 
            }
            // lets inform the test thread that there is an error.
            catch (Throwable throwable){
                asyncThrowable[0] = throwable;
            }
            // ensure to release asyncExecuted in case of error.
            finally {
                synchronized (asyncExecuted){
                    asyncExecuted[0] = true;
                    asyncExecuted.notify();
                }
            }
        }
    }).start();

    // Waiting for the test is complete
    synchronized (asyncExecuted){
        while(!asyncExecuted[0]){
            asyncExecuted.wait();
        }
    }

    // get any async error, including exceptions and assertationErrors
    if(asyncThrowable[0] != null){
        throw asyncThrowable[0];
    }
}

Básicamente, necesitamos crear una referencia de matriz final, para ser utilizada dentro de la clase interna anónima. Prefiero crear un booleano [], porque puedo poner un valor para controlar si necesitamos esperar (). Cuando todo está hecho, solo lanzamos asyncExecuted.


1
Si su afirmación falla, el hilo de prueba principal no lo sabrá.
Jonathan

Gracias por la solución, me ayuda a depurar código con conexión websocket.
Nazar Sakharenko

@ Jonathan, actualicé el código para detectar cualquier aserción y excepción e informarlo al hilo de prueba principal.
Paulo

1

Para todos los usuarios de Spring, así es como suelo hacer mis pruebas de integración hoy en día, donde está involucrado el comportamiento asíncrono:

Active un evento de aplicación en el código de producción, cuando una tarea asíncrona (como una llamada de E / S) haya finalizado. La mayoría de las veces este evento es necesario de todos modos para manejar la respuesta de la operación asíncrona en producción.

Con este evento en su lugar, puede utilizar la siguiente estrategia en su caso de prueba:

  1. Ejecute el sistema bajo prueba
  2. Escuche el evento y asegúrese de que se haya disparado.
  3. Haz tus afirmaciones

Para desglosar esto, primero necesitará algún tipo de evento de dominio para disparar. Estoy usando un UUID aquí para identificar la tarea que se ha completado, pero por supuesto eres libre de usar otra cosa siempre que sea única.

(Tenga en cuenta que los siguientes fragmentos de código también usan anotaciones de Lombok para deshacerse del código de la placa de la caldera)

@RequiredArgsConstructor
class TaskCompletedEvent() {
  private final UUID taskId;
  // add more fields containing the result of the task if required
}

El código de producción en sí normalmente se ve así:

@Component
@RequiredArgsConstructor
class Production {

  private final ApplicationEventPublisher eventPublisher;

  void doSomeTask(UUID taskId) {
    // do something like calling a REST endpoint asynchronously
    eventPublisher.publishEvent(new TaskCompletedEvent(taskId));
  }

}

Entonces puedo usar un Spring @EventListenerpara capturar el evento publicado en el código de prueba. El oyente de eventos está un poco más involucrado, porque tiene que manejar dos casos de manera segura para subprocesos:

  1. El código de producción es más rápido que el caso de prueba y el evento ya se disparó antes de que el caso de prueba verifique el evento, o
  2. El caso de prueba es más rápido que el código de producción y el caso de prueba tiene que esperar el evento.

A CountDownLatchse usa para el segundo caso como se menciona en otras respuestas aquí. También tenga en cuenta que la @Orderanotación en el método de controlador de eventos asegura que este método de controlador de eventos se llama después de cualquier otro detector de eventos utilizado en producción.

@Component
class TaskCompletionEventListener {

  private Map<UUID, CountDownLatch> waitLatches = new ConcurrentHashMap<>();
  private List<UUID> eventsReceived = new ArrayList<>();

  void waitForCompletion(UUID taskId) {
    synchronized (this) {
      if (eventAlreadyReceived(taskId)) {
        return;
      }
      checkNobodyIsWaiting(taskId);
      createLatch(taskId);
    }
    waitForEvent(taskId);
  }

  private void checkNobodyIsWaiting(UUID taskId) {
    if (waitLatches.containsKey(taskId)) {
      throw new IllegalArgumentException("Only one waiting test per task ID supported, but another test is already waiting for " + taskId + " to complete.");
    }
  }

  private boolean eventAlreadyReceived(UUID taskId) {
    return eventsReceived.remove(taskId);
  }

  private void createLatch(UUID taskId) {
    waitLatches.put(taskId, new CountDownLatch(1));
  }

  @SneakyThrows
  private void waitForEvent(UUID taskId) {
    var latch = waitLatches.get(taskId);
    latch.await();
  }

  @EventListener
  @Order
  void eventReceived(TaskCompletedEvent event) {
    var taskId = event.getTaskId();
    synchronized (this) {
      if (isSomebodyWaiting(taskId)) {
        notifyWaitingTest(taskId);
      } else {
        eventsReceived.add(taskId);
      }
    }
  }

  private boolean isSomebodyWaiting(UUID taskId) {
    return waitLatches.containsKey(taskId);
  }

  private void notifyWaitingTest(UUID taskId) {
    var latch = waitLatches.remove(taskId);
    latch.countDown();
  }

}

El último paso es ejecutar el sistema bajo prueba en un caso de prueba. Estoy usando una prueba SpringBoot con JUnit 5 aquí, pero esto debería funcionar igual para todas las pruebas que usan un contexto Spring.

@SpringBootTest
class ProductionIntegrationTest {

  @Autowired
  private Production sut;

  @Autowired
  private TaskCompletionEventListener listener;

  @Test
  void thatTaskCompletesSuccessfully() {
    var taskId = UUID.randomUUID();
    sut.doSomeTask(taskId);
    listener.waitForCompletion(taskId);
    // do some assertions like looking into the DB if value was stored successfully
  }

}

Tenga en cuenta que, a diferencia de otras respuestas aquí, esta solución también funcionará si ejecuta sus pruebas en paralelo y múltiples hilos ejercen el código asíncrono al mismo tiempo.


0

Si desea probar la lógica, no la pruebe de forma asincrónica.

Por ejemplo, para probar este código que funciona en los resultados de un método asincrónico.

public class Example {
    private Dependency dependency;

    public Example(Dependency dependency) {
        this.dependency = dependency;            
    }

    public CompletableFuture<String> someAsyncMethod(){
        return dependency.asyncMethod()
                .handle((r,ex) -> {
                    if(ex != null) {
                        return "got exception";
                    } else {
                        return r.toString();
                    }
                });
    }
}

public class Dependency {
    public CompletableFuture<Integer> asyncMethod() {
        // do some async stuff       
    }
}

En la prueba simula la dependencia con implementación síncrona. La prueba de la unidad es completamente sincrónica y se ejecuta en 150 ms.

public class DependencyTest {
    private Example sut;
    private Dependency dependency;

    public void setup() {
        dependency = Mockito.mock(Dependency.class);;
        sut = new Example(dependency);
    }

    @Test public void success() throws InterruptedException, ExecutionException {
        when(dependency.asyncMethod()).thenReturn(CompletableFuture.completedFuture(5));

        // When
        CompletableFuture<String> result = sut.someAsyncMethod();

        // Then
        assertThat(result.isCompletedExceptionally(), is(equalTo(false)));
        String value = result.get();
        assertThat(value, is(equalTo("5")));
    }

    @Test public void failed() throws InterruptedException, ExecutionException {
        // Given
        CompletableFuture<Integer> c = new CompletableFuture<Integer>();
        c.completeExceptionally(new RuntimeException("failed"));
        when(dependency.asyncMethod()).thenReturn(c);

        // When
        CompletableFuture<String> result = sut.someAsyncMethod();

        // Then
        assertThat(result.isCompletedExceptionally(), is(equalTo(false)));
        String value = result.get();
        assertThat(value, is(equalTo("got exception")));
    }
}

No prueba el comportamiento asíncrono pero puede probar si la lógica es correcta.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.