0

Paraleliza pruebas de NUnit

Aunque las pruebas individuales no suelen llevar demasiado tiempo, todas contribuyen al tiempo total necesario para ejecutar el conjunto completo. Cuando se ejecutan con regularidad, el tiempo de espera se acumula.

En esta entrega semanal, nos adentraremos en la ejecución de pruebas en paralelo. En primer lugar, examinaremos la manera en que etiquetamos las pruebas para que se ejecuten simultáneamente. Luego, exploraremos algunos problemas que podrían dar lugar a un comportamiento inesperado al adoptar esta práctica y discutiremos las estrategias para prevenir dichos inconvenientes.

A mayor cantidad, mejores resultados

En NUnit, hacemos uso del atributo Parallelizable para indicar cuáles pruebas queremos ejecutar simultáneamente. El siguiente ejemplo ilustra la forma más sencilla de configurar todas las pruebas de una clase para ejecutarse en paralelo. Al pasar ParallelScope.All como argumento, estamos expresando la intención de que todas las pruebas dentro de dicha clase inicien su ejecución lo antes posible.

Parallelizable(ParallelScope.All)]
public class MyTests
{

No obstante, también podemos ser más específicos; contamos con otras opciones que nos permiten indicar exactamente qué pruebas pueden ejecutarse simultáneamente. Por ejemplo, en el otro extremo del espectro, podríamos querer permitir que sólo un número selecto de pruebas se ejecute en paralelo. Para lograr esto, aplicaríamos el atributo a pruebas individuales (en lugar de a su conjunto) y pasaremos ParallelScope.Self como su argumento.

Pero, si la ejecución en paralelo de pruebas puede ahorrarnos tiempo, ¿por qué no querríamos que todas las pruebas se ejecuten de esta manera?

La respuesta breve es la compatibilidad: algunas pruebas podrían no haberse diseñado considerando el paralelismo y, como resultado, podrían tener un comportamiento impredecible.

El desafío vinculado a las variables compartidas y al estado.

Lo más deseable sería que cada prueba fuera completamente autónoma. La inclusión de variables y estados compartidos podría conllevar la posibilidad de enfrentar problemas.

Configuraciones

Después de redactar algunas pruebas, es posible que notemos secciones de código comunes que aparecen en varias pruebas. En general, buscamos redactar el código de la manera más limpia posible; la experiencia me ha enseñado que hacer lo contrario puede causar problemas cuando volvemos al código más adelante. Con este propósito, podemos aplicar el principio DRY y realizar refactorizaciones para evitar la duplicación. Cuando el código repetitivo surge en las secciones iniciales de Arrange de las pruebas, es tentador utilizar atributos de configuración para declarar y/o restablecer variables compartidas en el ámbito de clase (o superior): esto reduciría el código en estas secciones.

No obstante, debemos tener precaución al ejecutar pruebas en paralelo. Supongamos que podemos ejecutar hasta dos pruebas simultáneamente, que en total tenemos tres pruebas y que cada una utiliza variables de ámbito de clase que se restablecen al principio de cada prueba mediante un atributo de configuración. Para simplificar, nos referiremos a nuestras pruebas como:

  • Test A
  • Test B
  • Test C

La siguiente secuencia de eventos haría que nuestros resultados se volvieran poco confiables.

  • Test A y empezar a correr.Test B
  • Test B termina de correr.
  • Test C comienza a ejecutarse, restableciendo las variables de ámbito de clase.
  • Test A acabados, pero sus resultados se han visto afectados por el paso (3).

En función de la prueba, una alternativa a las variables compartidas y los métodos de configuración podría ser el empleo de métodos de fábrica al establecer los componentes de la prueba.

Colecciones no concurrentes

Las colecciones locales pueden utilizarse como sustitutos de prueba para bases de datos y otros almacenes de datos. Declararlas a nivel de clase puede ser justificado al emplearlas en diversas pruebas. Supongamos que contamos con la siguiente interfaz.

public interface IMyRepository
{
    public string GetValue(string key);
    public string SetValue(string key, string value);
}

Dentro de nuestras pruebas, podríamos optar por realizar una simulación de este. Para asegurar su comportamiento adecuado, podríamos incorporar devoluciones de llamada que utilicen un Dictionary como respaldo.

public class MyRepositoryTests
{
  private readonly IDictionary<string, string> _databaseSubstitute
    = new Dictionary<string, string>();

  [Test]
  public void MyRepositoryTest()
  {
    // Arrange

    var repository = new Mock<IMyRepository>();

    repository
      Setup(r => r.GetValue(It.IsAny<string>()))
        .Returns<string>(key => _databaseSubstitute[key]);

    repository
      .Setup(r =>
        r.SetValue(It.IsAny<string>(), It.IsAny<string>()))
      .Callback<string, string>((key, value) =>
        _databaseSubstitute[key] = value);

    // Remainder of test code...
  }
}

Esto funcionará sin problemas en el contexto de un solo hilo. Sin embargo, ejecutar varias pruebas similares en paralelo podría dar lugar a un comportamiento inesperado al realizar cualquier otra operación que no sea de lectura: no todas las operaciones de Dictionary son seguras cuando se ejecutan desde varios subprocesos simultáneamente.

Para mitigar esto, podríamos sustituir Dictionary por ConcurrentDictionary. No obstante, esto también añade una complejidad adicional. Una alternativa más simple sería crear una variable local _databaseSubstitute en cada prueba que la utilice.

Orden de las pruebas

Un aspecto final a tener en cuenta es el orden de ejecución de las pruebas. Este puede especificarse mediante el atributo Order; en su ausencia, las pruebas (aparentemente) se ejecutan en orden alfabético. Contemplemos el siguiente dispositivo. (Se redactó con el propósito de demostrar el efecto de ejecutar pruebas en paralelo en lugar de examinar el código).

public class TestOrder
{
    private readonly IList<int> _results = new List<int>();

    [Test]
    [Order(0)]
    public void Test1()
    {
        _results.Add(1);
    }

    [Test]
    [Order(1)]
    public void Test2()
    {
        _results.Add(2);
    }

    [Test]
    [Order(2)]
    public void Test3()
    {
        _results.Add(3);
    }

    [Test]
    [Order(3)]
    public void TestResultsCollection()
    {
        Assert.That(_results[0], Is.EqualTo(1));
        Assert.That(_results[1], Is.EqualTo(2));
        Assert.That(_results[2], Is.EqualTo(3));
    }
}

Todas las pruebas son exitosas cuando se ejecutan exclusivamente en un solo hilo:

  • Test1 se ejecuta primero, agregando a la lista de resultados.1
  • Test2 se ejecuta cuando se completa, agregando a .Test12_results
  • Test3 se ejecuta después de . Esto se suma a .Test23_results
  • Finalmente, se ejecuta. Como los pasos 1 a 3 ocurrieron secuencialmente, el orden de los enteros es el esperado.TestResultsCollection_results

Podemos optar por ejecutar este conjunto en paralelo aplicando [Parallelizable(ParallelScope.All)] a nivel de clase. Sin embargo, .TestResultsCollection mostrará resultados inconsistentes: en ocasiones se aprobará y en otras se fallará.

Para entender la razón, supongamos que tenemos la capacidad de ejecutar las cuatro pruebas simultáneamente. Es impredecible determinar exactamente cuándo cada prueba comenzará y finalizará; esto dependerá de diversos factores, como los tiempos de ejecución de cada prueba, el sistema de programación de subprocesos del sistema operativo y la carga general del sistema proveniente de otros procesos en segundo plano en ese momento.

El dispositivo de prueba es esencialmente un sistema simultáneo sin sincronización de hilos. En otras palabras, no podemos confiar en escribir Test1 siempre en la primera posición de la lista, Test2 en la segunda y Test3 en la tercera. Podemos abordar parcialmente la situación escribiendo los resultados en una List en lugar de un Dictionary; el orden de las tres primeras pruebas ya no importará.

[Parallelizable(ParallelScope.All)]
public class TestOrder
{
    private readonly IDictionary<string, int> _results =
        new Dictionary<string, int>();

    [Test]
    [Order(0)]
    public void Test1()
    {
        _results["Test1"] = 1;
    }

    [Test]
    [Order(1)]
    public void Test2()
    {
        _results["Test2"] = 2;
    }

    [Test]
    [Order(2)]
    public void Test3()
    {
        _results["Test3"] = 3;
    }

    [Test]
    [Order(3)]
    public void TestResultsCollection()
    {
        Assert.That(_results["Test1"], Is.EqualTo(1));
        Assert.That(_results["Test2"], Is.EqualTo(2));
        Assert.That(_results["Test3"], Is.EqualTo(3));
    }
}

No obstante, existe la posibilidad de que las aseveraciones de TestResultsCollection ocurran antes de que se completen algunas de las otras pruebas. La manera más sencilla y segura de evitar esto es reescribir las pruebas para aislar cada una de ellas y que no dependan de los resultados de otras, ya sea eso o eliminar el atributo Parallelizable.

Conclusiones

En conclusión, la ejecución de pruebas en paralelo se presenta como una estrategia eficaz para reducir los tiempos de espera en comparación con la ejecución secuencial, especialmente en entornos como NUnit. Sin embargo, es imperativo garantizar la compatibilidad de las pruebas para evitar comportamientos inesperados. La autonomía de las pruebas es esencial, desincentivando el uso de atributos de configuración que puedan interferir con otras pruebas en ejecución.

Además, la precaución debe extenderse a la seguridad para subprocesos, ya que no todas las operaciones de recopilación de datos son inherentemente seguras en entornos paralelos. La escritura desde diferentes subprocesos puede desencadenar excepciones, requiriendo una gestión cuidadosa.

Finalmente, es crucial reconocer que los ejecutores de pruebas iniciarán nuevas pruebas independientemente del estado de otras. En casos donde la secuencia de ejecución sea vital, es necesario reescribir las pruebas para evitar posibles inconvenientes. Estas consideraciones refuerzan la importancia de adoptar enfoques cautelosos y reflexivos al implementar pruebas en paralelo, maximizando su eficiencia y minimizando posibles contratiempos.

Fernando Sonego

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *