Cómo hacer pruebas unitarias en CompletableFuture de Java

Cómo hacer pruebas unitarias en CompletableFuture de Java: Enfoques y mejores prácticas

1. Introducción

CompletableFuture es una herramienta poderosa para la programación asincrónica en Java. Proporciona una manera conveniente de encadenar tareas asincrónicas y manejar sus resultados. Es comúnmente utilizado en situaciones donde se necesitan realizar operaciones asincrónicas y sus resultados deben ser consumidos o procesados en una etapa posterior.

Sin embargo, las pruebas unitarias de CompletableFuture pueden ser desafiantes debido a su naturaleza asincrónica. Los métodos tradicionales de prueba, que dependen de la ejecución secuencial, a menudo fallan en capturar las sutilezas del código asincrónico. En este tutorial, discutiremos cómo probar eficazmente CompletableFuture utilizando dos enfoques diferentes: pruebas caja negra y pruebas basadas en estado.

2. Desafíos de la Prueba de Código Asincrónico

El código asincrónico presenta desafíos debido a su ejecución no bloqueante y concurrente, lo que complica los métodos de prueba tradicionales. Estos desafíos incluyen:

  • Problemas de Tiempo: Las operaciones asincrónicas introducen dependencias temporales en el código, lo que dificulta el control del flujo de ejecución y la verificación del comportamiento del código en puntos específicos en el tiempo. Los métodos de prueba tradicionales que dependen de la ejecución secuencial pueden no ser adecuados para el código asincrónico.
  • Manejo de Excepciones: Las operaciones asincrónicas pueden lanzar excepciones y es crucial asegurar que el código las maneje de manera adecuada, evitando fallos silenciosos. Las pruebas unitarias deben cubrir varios escenarios para validar los mecanismos de manejo de excepciones.
  • Condiciones de Carrera: El código asincrónico puede llevar a condiciones de carrera, donde múltiples hilos o procesos intentan acceder o modificar datos compartidos simultáneamente, lo que puede resultar en salidas inesperadas.
  • Cobertura de Pruebas: Lograr una cobertura de prueba exhaustiva para el código asincrónico puede ser difícil debido a la complejidad de las interacciones y el potencial de resultados no deterministas.

3. Pruebas Caja Negra

Las pruebas caja negra se centran en probar el comportamiento externo del código sin conocer su implementación interna. Este enfoque es adecuado para validar el comportamiento del código asincrónico desde la perspectiva del usuario. El evaluador solo conoce las entradas y salidas esperadas del código.

Aspectos a priorizar en pruebas CompletableFuture:

  • Finalización Exitosa: Verificar que el CompletableFuture se complete exitosamente, devolviendo el resultado esperado.
  • Manejo de Excepciones: Validar que el CompletableFuture maneje excepciones de manera adecuada, evitando fallos silenciosos.
  • Tiempos de Espera: Asegurarse de que el CompletableFuture se comporte como se espera cuando encuentra tiempos de espera.

Usaremos un marco de simulación como Mockito para simular las dependencias del CompletableFuture bajo prueba. Esto nos permitirá aislar el CompletableFuture y probar su comportamiento en un ambiente controlado.

3.1. Sistema Bajo Prueba

Estaremos probando un método llamado processAsync() que encapsula el proceso de recuperación y combinación de datos asincrónicos. Este método acepta una lista de objetos Microservice como entrada y devuelve un CompletableFuture<String>. Cada objeto Microservice representa un microservicio capaz de realizar una operación de recuperación asincrónica.

CompletableFuture<String> processAsync(List<Microservice> microservices) {
    List<CompletableFuture<String>> dataFetchFutures = fetchDataAsync(microservices);
    return combineResults(dataFetchFutures);
}

El método fetchDataAsync() itera a través de la lista de Microservice, invocando retrieveAsync() para cada uno, y devuelve una lista de CompletableFuture<String>:

private List<CompletableFuture<String>> fetchDataAsync(List<Microservice> microservices) {
    return microservices.stream()
        .map(client -> client.retrieveAsync(""))
        .collect(Collectors.toList());
}

El método combineResults() utiliza CompletableFuture.allOf() para esperar a que todos los futuros en la lista se completen. Una vez completados, mapea los futuros, une los resultados y devuelve una sola cadena:

private CompletableFuture<String> combineResults(List<CompletableFuture<String>> dataFetchFutures) {
    return CompletableFuture.allOf(dataFetchFutures.toArray(new CompletableFuture[0]))
      .thenApply(v -> dataFetchFutures.stream()
        .map(future -> future.exceptionally(ex -> {
            throw new CompletionException(ex);
        })
          .join())
      .collect(Collectors.joining()));
}

3.2. Caso de Prueba: Verificar Recuperación y Combinación de Datos Exitosos

Este caso de prueba verifica que el método processAsync() recupere correctamente datos de múltiples microservicios y combine los resultados en una sola cadena:

@Test
public void givenAsyncTask_whenProcessingAsyncSucceed_thenReturnSuccess() 
  throws ExecutionException, InterruptedException {
    Microservice mockMicroserviceA = mock(Microservice.class);
    Microservice mockMicroserviceB = mock(Microservice.class);

    when(mockMicroserviceA.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("Hello"));
    when(mockMicroserviceB.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("World"));

    CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));

    String result = resultFuture.get();
    assertEquals("HelloWorld", result);
}

3.3. Caso de Prueba: Verificar Manejo de Excepciones Cuando un Microservicio Lanza una Excepción

Este caso de prueba verifica que el método processAsync() lanza una ExecutionException cuando uno de los microservicios lanza una excepción. También se afirma que el mensaje de excepción es el mismo que el lanzado por el microservicio:

@Test
public void givenAsyncTask_whenProcessingAsyncWithException_thenReturnException() 
  throws ExecutionException, InterruptedException {
    Microservice mockMicroserviceA = mock(Microservice.class);
    Microservice mockMicroserviceB = mock(Microservice.class);

    when(mockMicroserviceA.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("Hello"));
    when(mockMicroserviceB.retrieveAsync(any()))
      .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Simulated Exception")));
    CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));

    ExecutionException exception = assertThrows(ExecutionException.class, resultFuture::get);
    assertEquals("Simulated Exception", exception.getCause().getMessage());
}

3.4. Caso de Prueba: Verificar Manejo de Tiempo de Espera Cuando el Resultado Combinado Excede el Tiempo de Espera

Este caso de prueba intenta recuperar el resultado combinado del método processAsync() dentro de un tiempo de espera especificado de 300 milisegundos. Se afirma que se lanza una TimeoutException cuando se excede el tiempo de espera:

@Test
public void givenAsyncTask_whenProcessingAsyncWithTimeout_thenHandleTimeoutException() 
  throws ExecutionException, InterruptedException {
    Microservice mockMicroserviceA = mock(Microservice.class);
    Microservice mockMicroserviceB = mock(Microservice.class);

    Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
    when(mockMicroserviceA.retrieveAsync(any()))
      .thenReturn(CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor));
    Executor delayedExecutor2 = CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS);
    when(mockMicroserviceB.retrieveAsync(any()))
      .thenReturn(CompletableFuture.supplyAsync(() -> "World", delayedExecutor2));
    CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));

    assertThrows(TimeoutException.class, () -> resultFuture.get(300, TimeUnit.MILLISECONDS));
}

El código anterior utiliza CompletableFuture.delayedExecutor() para crear ejecutores que retrasan la finalización de las llamadas a retrieveAsync() en 200 y 500 milisegundos respectivamente. Esto simula los retrasos causados por los microservicios y permite verificar que el método processAsync() maneja los tiempos de espera correctamente.

4. Pruebas Basadas en Estado

Las pruebas basadas en estado se centran en verificar las transiciones de estado del código a medida que se ejecuta. Este enfoque es particularmente útil para probar código asincrónico, ya que permite a los evaluadores seguir el progreso del código a través de diferentes estados y garantizar que transicione correctamente.

4.1. Caso de Prueba: Verificar Estado Después de la Finalización Exitosa

Este caso de prueba verifica que una instancia de CompletableFuture transicione al estado hecho cuando todas sus instancias constitutivas han sido completadas exitosamente:

@Test
public void givenCompletableFuture_whenCompleted_thenStateIsDone() {
    Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
    CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
    CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> " World");
    CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
    CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };

    CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);

    assertFalse(allCf.isDone());
    allCf.join();
    String result = Arrays.stream(cfs)
      .map(CompletableFuture::join)
      .collect(Collectors.joining());

    assertFalse(allCf.isCancelled());
    assertTrue(allCf.isDone());
    assertFalse(allCf.isCompletedExceptionally());
}

4.2. Caso de Prueba: Verificar Estado Después de Completar Excepcionalmente

Este caso de prueba verifica que cuando una de las instancias constitutivas CompletableFuture cf2 completa excepcionalmente, el allCf CompletableFuture transicione al estado excepcional:

@Test
public void givenCompletableFuture_whenCompletedWithException_thenStateIsCompletedExceptionally() 
  throws ExecutionException, InterruptedException {
    Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
    CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
    CompletableFuture<String> cf2 = CompletableFuture.failedFuture(new RuntimeException("Simulated Exception"));
    CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
    CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };

    CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);

    assertFalse(allCf.isDone());
    assertFalse(allCf.isCompletedExceptionally());

    assertThrows(CompletionException.class, allCf::join);

    assertTrue(allCf.isCompletedExceptionally());
    assertTrue(allCf.isDone());
    assertFalse(allCf.isCancelled());
}

4.3. Caso de Prueba: Verificar Estado Después de que la Tarea Sea Cancelada

Este caso de prueba verifica que cuando el allCf CompletableFuture es cancelado usando el método cancel(true), transicione al estado cancelado:

@Test
public void givenCompletableFuture_whenCancelled_thenStateIsCancelled() 
  throws ExecutionException, InterruptedException {
    Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
    CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
    CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> " World");
    CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
    CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };

    CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);
    assertFalse(allCf.isDone());
    assertFalse(allCf.isCompletedExceptionally());

    allCf.cancel(true);

    assertTrue(allCf.isCancelled());
    assertTrue(allCf.isDone());
}

5. Conclusión

En conclusión, hacer pruebas unitarias de CompletableFuture puede ser un desafío debido a su naturaleza asincrónica. Sin embargo, es una parte importante de la escritura de código asincrónico robusto y mantenible. Al usar enfoques de pruebas caja negra y basadas en estado, podemos evaluar el comportamiento de nuestro código CompletableFuture bajo diversas condiciones, asegurando que funcione según lo esperado y maneje posibles excepciones de manera adecuada.

Consejos Prácticos para Programadores Especializados en JAVA

  • Simular Componentes Externos: Utiliza marcos de simulación para aislar tu lógica de negocio y enfocar las pruebas en las interacciones centrales.
  • Cubrir Casos de Excepción: Siempre cubre los casos donde tu código asíncrono puede fallar, asegurando que tu código maneje las excepciones adecuadamente.
  • Validar Tiempos de Ejecución: Asegúrate de que las pruebas contemplen tiempos de espera y comportamientos en caso que la ejecución sobrepase ciertos límites.

Estos consejos te ayudarán a crear pruebas más efectivas y a garantizar que tu código asincrónico sea confiable y esté libre de errores.