viernes, 16 de septiembre de 2022

Testing de procesos concurrentes

En muchas ocasiones nos encontramos con la necesidad de probar hasta que punto un proceso es "thread safe", en general este tipo de pruebas son muy difíciles y en ciertas situaciones nunca lograremos descartar completamente problemas de concurrencia mediante testing. En cualquier caso voy a hablar de una de tantas opciones, en la cual haremos uso de las características propias de la librería de testing TestNG (https://es.wikipedia.org/wiki/TestNG), una pequeña búsqueda arroja multitud de resultados enfrentando TestNG contra JUnit (ej: https://www.baeldung.com/junit-vs-testng) por lo que no voy a entrar en ese tema, me voy a limitar a exponer un ejemplo muy sencillo de uso, puedes encontrar el código de ejemplo en https://github.com/andrestraspuesto/testing-concurrency-testng.

 

Escenario a probar

El ejemplo sobre el que voy a hacer la demo consiste en un contador que será incrementado de forma concurrente. He creado 2 implementaciones: una que controla el acceso concurrente y otra que no.

Interfaz del contador:
public interface Counter {
int increaseAndGet();
int peekCounter();
}


Implementación insegura:
public class NaiveCounter implements Counter {
private int counter;

@Override
public int increaseAndGet() {
return ++counter;
}

@Override
public int peekCounter() {
return counter;
}
}

Implementación segura:
public class ThreadSafeCounter implements Counter {
private AtomicInteger counter = new AtomicInteger(0);

@Override
public int increaseAndGet() {
return counter.incrementAndGet();
}

@Override
public int peekCounter() {
return counter.get();
}
}

Implementación del handler del contador:
public class CounterHandler implements Callable<Integer>{

private final Counter counter;
public CounterHandler(Counter counter) {
this.counter = counter;
}
@Override
public Integer call() {
return counter.increaseAndGet();
}

}

Si se revisa la implementación insegura (NaiveCounter) es fácil deducir que ante un acceso concurrente es probable que el contador tome valores incorrectos.

Tests

A continuación se muestran las clases de test, por motivos didácticos se ha duplicado la clase, aunque perfectamente se podría haber utilizado una sola clase utilizando una factoría para insertar los diferentes contadores a probar.
Test de la clase NaiveCounter:
public class NaiveCounterTest
{
private final NaiveCounter naiveCounter = new NaiveCounter();

@Test(threadPoolSize = 4, invocationCount = 2000)
public void shouldIncreaseCounter() {
Thread.yield();
CounterHandler handler = new CounterHandler(naiveCounter);
Integer result = handler.call();
Assert.assertTrue(result > 0);
}

@Test(dependsOnMethods = {"shouldIncreaseCounter"})
public void shouldBe100() {
Assert.assertEquals(naiveCounter.peekCounter(), 2000);
}
}

Test de la clase ThreadSafeCounter:
public class ThreadSafeCounterTest {

private final ThreadSafeCounter concurrentCounter =
                                    new ThreadSafeCounter();

@Test(threadPoolSize = 4, invocationCount = 2000)
public void shouldIncreaseCounter() {
Thread.yield();

CounterHandler handler = new CounterHandler(concurrentCounter);
Integer result = handler.call();
Assert.assertTrue(result > 0);
}

@Test(dependsOnMethods = {"shouldIncreaseCounter"})
public void shouldBe2000() {
Assert.assertEquals(concurrentCounter.peekCounter(), 2000);
}
}

La estructura de la clase de test tiene 2 métodos de test:

Método shouldIncreaseCounter: la finalidad de este método es ejecutar concurrentemente el handler sobre el contador, para esta finalidad se usan los argumentos de la anotación test:
  • threadPoolSize: determina el tamaño del pool de hilos de ejecución , esto quiere decir que establece el número de ejecuciones concurrentes de este método.
  • invocationCount: establece cuantas veces se debe ejecutar este método
En este caso, hemos decidido que este método debe ejecutarse 2000 veces con 4 hilos concurrentes, de esta forma como pasamos el contador a cada instancia del handler, si no ha habido problemas de concurrencia el contador debería tomar valor 2000.

Método shouldBe2000: este método lo utilizamos para verificar que el contador refleja las 2000 ejecuciones. Para esto se ha utilizado el atributo dependsOnMethods que evita que el test se ejecute antes de que finalice la ejecución de shouldIncreaseCounter.

En la siguiente imagen se muestra el resultado de ejecutar los test (mvn test), donde se ve que falla la ejecución de NaiveCounterTest, porque no coincide el valor del contador.



Por último remarcar el hecho de que este tipo de test facilita las pruebas concurrentes pero no son una garantía absoluta y dependiendo de la situación podrían darse falsos positivos (entendiendo como tal que pase el test aun existiendo fallos de diseño que puedan derivar en fallos de concurrencia).