Visual Studio 2015

Crear mejor software con las pruebas unitarias inteligentes

Pratap Lakshman

El mundo del desarrollo de software mantiene una tendencia en la que los ciclos de lanzamiento son cada vez más cortos. Hace mucho que los tiempos en que los equipos de desarrollo de software podían secuenciar estrictamente las funciones de especificación, implementación y pruebas en un modelo en cascada quedaron atrás. El desarrollo de software de alta calidad es difícil en un mundo tan dinámico y requiere una reevaluación de las metodologías de desarrollo existentes.

Para reducir el número de errores en un producto de software, todos los miembros del equipo deben estar de acuerdo en lo que se supone que el sistema de software debe hacer, y eso es un reto clave. Normalmente, la especificación, la implementación y las pruebas han tenido lugar en silos, sin ningún medio de comunicación común. Los distintos idiomas o artefactos que se usan para cada uno dificultan que evolucionen a la vez a medida que avanza la actividad de implementación del software y, por tanto, aunque un documento de especificación debería conectar el trabajo de todos los miembros del equipo, en la práctica raramente es el caso. La especificación original y la implementación real pueden ser distintas, y al final lo único que contiene todos los elementos juntos es el código, que termina plasmando la especificación final y las diferentes decisiones de diseño que se han tomado por el camino. Las pruebas intentan conciliar esta diferencia al recurrir a solo unos pocos escenarios integrales conocidos.

Es posible mejorar esta situación. Es necesario un medio común para especificar el comportamiento previsto del sistema de software, uno que se pueda compartir entre diseño, implementación y pruebas, y que evolucione con facilidad. La especificación debe estar directamente relacionada con el código y el medio de estar codificado como un conjunto de pruebas exhaustivo. Las técnicas basadas en herramientas que habilitan las pruebas unitarias inteligentes pueden ayudar a cubrir esta necesidad.

Pruebas unitarias inteligentes

Las pruebas unitarias inteligentes, una característica de Visual Studio 2015 Preview (consulte la figura 1), son un ayudante inteligente para el desarrollo de software, que ayuda a que los equipos de desarrollo encuentren errores pronto y reduzcan el coste de mantenimiento de las pruebas. Se basa en el trabajo previo de Microsoft Research conocido como "Pex." Su motor usa análisis de código de caja blanca y resolución de restricciones para sintetizar los valores de entrada de prueba precisos y cubrir todas las rutas de código en el código que se somete a prueba, los conserva como un conjunto compacto de pruebas unitarias tradicionales con alta cobertura y evolucionar automáticamente el conjunto de pruebas a medida que evoluciona el código.

Las pruebas unitarias inteligentes están totalmente integradas en Visual Studio 2015 Preview
Figura 1 Las pruebas unitarias inteligentes están totalmente integradas en Visual Studio 2015 Preview

Además, y esto se recomienda encarecidamente, las propiedades de corrección especificadas como aserciones en el código se pueden usar para guiar aún más la generación del caso de prueba.

De forma predeterminada, si no hace nada más que ejecutar pruebas unitarias inteligentes en un fragmento de código, los casos de prueba generados capturan el comportamiento observado del código sometido a prueba de cada uno de los valores de entrada sintetizados. En esta etapa, salvo en los casos de prueba que produzcan errores en tiempo de ejecución, se considerará que el resto pasa las pruebas (después de todo, ese es el comportamiento observado).

Además, si escribe aserciones que especifiquen las propiedades de corrección del código sometido a prueba, las pruebas unitarias inteligentes arrojarán valores de entrada de prueba que pueden producir errores en las aserciones, cada uno de los valores de entrada descubrirá un error en el código y, por tanto, un caso de prueba erróneo. Las pruebas unitarias inteligentes no puede proponer dichas propiedades de corrección por sí mismas; las escribiría usted basándose en su conocimiento del dominio.

Generación de casos de prueba

En general, las técnicas de análisis de programas se dividen entre los dos extremos siguientes:

  • Las técnicas de análisis estático comprueban que una propiedad se mantiene como verdadera en todas las rutas de ejecución. Dado que el objetivo es la comprobación del programa, estas técnicas suelen excesivamente conservadoras y marcar posibles infracciones como errores, lo que da lugar a falsos positivos.
  • Las técnicas de análisis dinámico comprueban que una propiedad se mantiene como verdadera en algunas rutas de ejecución. Las pruebas adoptan un enfoque de análisis dinámico cuyo objetivo es la detección de errores, pero normalmente no pueden demostrar la ausencia de errores. Por tanto, a menudo estas técnicas no detectan todos los errores.

No sería posible detectar los errores con precisión cuando solo se aplica análisis estático o se emplea una técnica de pruebas que no conozca la estructura del código. Por ejemplo, consulte el siguiente código:

int Complicated(int x, int y)
{
  if (x == Obfuscate(y))
    throw new RareException();
  return 0;
}
int Obfuscate(int y)
{
  return (100 + y) * 567 % 2347;
}

Las técnicas de análisis estático suelen ser conservadoras, por lo que la aritmética de enteros no lineal presente en Obfuscate provoca que la mayoría de técnicas de análisis estático emitan una advertencia sobre un error potencial en Complicated. Además, las técnicas de pruebas aleatorias tienen muy pocas probabilidades de encontrar un par de valores "x" e "y" que desencadenen la excepción.

Las pruebas unitarias inteligentes implementan una técnica de análisis que se encuentra entre estos dos extremos. De forma parecida a las técnicas de análisis estático, demuestra que contiene una propiedad se mantiene en las rutas más factibles. Al igual que las técnicas de análisis dinámico, solo informa de los errores reales y no produce falsos positivos.

La generación del caso de prueba implica lo siguiente:

  • Detección dinámica de todas las bifurcaciones (explícitas e implícitas) en el código que se somete a prueba.
  • Síntesis de los valores de entrada de prueba precisos que actúan sobre las bifurcaciones.
  • Grabación de la salida del código que se somete a prueba para las entradas mencionadas.
  • Persistencia de estas como un conjunto de pruebas compacto con gran cobertura.

En la figura 2 se muestra cómo funciona con la supervisión e instrumentación en tiempo de ejecución; estos son los pasos:

  1. En primer lugar, se instrumenta el código que se somete a prueba y las devoluciones de llamada se implantan de forma que se permita que el motor de pruebas supervise la ejecución. Después, el código se ejecuta con el valor de entrada concreto más simple y pertinente (según el tipo del parámetro). Esto representa el caso de prueba inicial.
  2. El motor de pruebas supervisa la ejecución, calcula la cobertura para cada caso de prueba y realiza un seguimiento de la forma en que el valor de entrada fluye a través del código. Si se cubren todas las rutas, se detiene el proceso; todos los comportamientos excepcionales se consideran como bifurcaciones, exactamente como bifurcaciones explícitas en el código. Si todavía no se han cubierto todas las rutas, el motor de prueba toma un caso de prueba que alcanza un punto del programa desde el que sale una bifurcación no cubierta, y determina de qué forma depende la condición de la bifurcación del valor de entrada.
  3. El motor crea un sistema de restricciones que representa la condición bajo la que el control llega a ese punto del programa y, a continuación, continuaría a lo largo de la bifurcación que no se ha cubierto anteriormente. Entonces, se efectúa una consulta a un solucionador de restricciones para sintetizar un nuevo valor de entrada concreto en función de esta restricción.
  4. Si el solucionador de restricciones puede determinar un valor de entrada concreto para la restricción, se ejecuta el código en pruebas con el nuevo valor de entrada concreto.
  5. Si se aumenta la cobertura, se emite un caso de prueba.

Cómo funciona internamente la generación del caso de prueba
Figura 2 Cómo funciona internamente la generación del caso de prueba

Los pasos del 2 al 5 se repiten hasta que se cubren todas las bifurcaciones o hasta que se superan los límites de exploración preconfigurados.

Este proceso se denomina exploración (exploration). Dentro de una exploración, el código en pruebas se puede ejecutar (run) varias veces. Algunas de esas ejecuciones aumentan la cobertura y solo las ejecuciones que aumentan la cobertura emiten de casos de prueba. Por lo tanto, todas las pruebas que se generan siguen rutas factibles.

Exploración limitada

Si el código sometido a prueba no contiene bucles o recursividad ilimitada, la exploración normalmente se detiene rápidamente porque solo hay un número finito (pequeño) de rutas de ejecución que se deben analizar. Sin embargo, los programas más interesantes contienen bucles o recursividad ilimitada. En estos casos, el número de rutas de ejecución es (prácticamente) infinito y, de forma general, no es posible decidir si una instrucción es alcanzable. En otras palabras, una exploración tardaría una eternidad en analizar todas las rutas de ejecución del programa. Dado que la generación de pruebas implica ejecutar realmente el código sometido a pruebas, ¿cómo se puede prevenir dicha exploración descontrolada? Es en este punto donde la exploración limitada desempeña un papel fundamental. Se asegura de que las exploraciones se detengan tras un período de tiempo razonable. Existen varios límites de exploración configurables en niveles que se usan:

  • Los límites del solucionador de restricciones delimitan la cantidad de tiempo y memoria que el solucionador puede usar para buscar el siguiente valor de entrada concreto.
  • Los límites de la ruta de exploración delimitan la complejidad de la ruta de ejecución que se analiza en cuanto al número de bifurcaciones que se toman, el número de condiciones de las entradas que deben comprobarse y la profundidad de la ruta de ejecución en relación a los marcos de pila.
  • Los límites de exploración delimitan el número de ejecuciones (runs) que no producen un caso de prueba, el número total de ejecuciones permitidas y el límite de tiempo total tras el cual se detiene la exploración.

Un aspecto importante para que cualquier método de pruebas basadas en herramientas sea eficaz es recibir información rápidamente y todos estos límites se han preconfigurado para disponer de un uso interactivo rápido.

Además, el motor de pruebas usa heurística para lograr alta cobertura de código rápidamente, mediante el aplazamiento de la resolución de sistemas de restricciones severas. Puede permitir que el motor de genere rápidamente algunas pruebas del código en el que está trabajando. Sin embargo, para solucionar los problemas de generación de las entradas de pruebas severas restantes, puede aumentar los umbrales para permitir que motor de pruebas profundice más en los sistemas de restricciones complicadas.

Pruebas unitarias con parámetros

Todas las técnicas de análisis de programas intentan validar o refutar determinadas propiedades concretas de un programa dado. Existen distintas técnicas para especificar las propiedades del programa:

  • Los contratos de API especifican el comportamiento de las API individuales desde la perspectiva de la implementación. Su objetivo es garantizar la solidez, en el sentido de que no se bloqueen las operaciones y se conserven los datos sin variaciones. Un problema común de los contratos de API es su visión estrecha de las acciones de API individuales, lo que dificulta la descripción de los protocolos de todo el sistema.
  • Las pruebas unitarias incluyen escenarios de uso de ejemplo desde la perspectiva de un cliente de la API. Su objetivo es garantizar el funcionamiento correcto, en el sentido de que la interacción de varias operaciones se comporte según lo previsto. Un problema común de las pruebas unitarias es que están separadas de los detalles de implementación de la API.

Las pruebas unitarias inteligentes permiten las pruebas unitarias con parámetros, que une ambas técnicas. Compatible con un motor de generación de entradas de prueba, esta metodología combina las perspectivas del cliente y de la implementación. Las propiedades de corrección funcionales (pruebas unitarias con parámetros) se comprueban en la mayoría de los casos de la implementación (generación de entrada de prueba).

Una prueba unitaria con parámetros (PUT) es la generalización sencilla de una prueba unitaria mediante el uso de parámetros. Una PUT hace instrucciones sobre el comportamiento del código para todo un conjunto de valores de entrada posibles, en lugar de solamente un único valor de entrada de ejemplo. Expresa suposiciones en entradas de prueba, realiza una secuencia de acciones y realiza aserciones de propiedades que se deben mantener en el estado final; es decir, sirve como la especificación. Esta especificación no requiere ni introduce ningún artefacto o idioma nuevos. Se escribe en el nivel de las API que el producto de software ha implementado realmente y en el lenguaje de programación del producto de software. Los diseñadores pueden usarlas para expresar el comportamiento previsto de las API del software, los desarrolladores pueden utilizarlas para ejecutar pruebas automatizadas de desarrolladores y los evaluadores pueden aprovecharlas para la generación de pruebas exhaustivas automáticas. Por ejemplo, la siguiente PUT impone que después de agregar un elemento a una lista no nula, el elemento se encuentra realmente en la lista:

void TestAdd(ArrayList list, object element)
{
  PexAssume.IsNotNull(list);
  list.Add(element);
  PexAssert.IsTrue(list.Contains(element));
}

Las PUT separan los siguientes dos problemas:

  1. La especificación de las propiedades de corrección del código sometido a pruebas de todos los argumentos de prueba posibles.
  2. Los casos de pruebas "cerrados" reales con los argumentos concretos.

El motor emite código auxiliar para el primer asunto y le anima a estructurarlos marcándolas basándose en su conocimiento del dominio. Las siguientes invocaciones de las pruebas unitarias inteligentes generarán y actualizarán automáticamente los casos de prueba cerrados individuales.

Aplicación

Los equipos de desarrollo de software pueden tener ya afianzadas distintas metodologías y no es realista esperar que adopten una nueva de la noche a la mañana. De hecho, las pruebas unitarias inteligentes no están diseñadas como un sustituto para ninguna práctica de pruebas que los equipos suelan seguir; sino que su objetivo es aumentar las prácticas existentes. Lo normal es que la adopción comience de forma progresiva, primero con un aprovechamiento de las capacidades de mantenimiento y generación de pruebas automáticas predeterminadas y, a continuación, pasando a la escritura de especificaciones en el código.

Pruebas del comportamiento observado Imagínense tener que realizar cambios en un cuerpo de código sin ninguna cobertura de pruebas. Podría querer precisar su comportamiento en relación a un conjunto de pruebas unitarias antes de comenzar, pero es más fácil decirlo que llevarlo a cabo:

  • El código (código de producto) podría no ser apto para las pruebas unitarias. Podría tener dependencias estrechas con el entorno externo que se deben aislarse y, si no las puede reconocer, podría incluso no saber por dónde empezar.
  • La calidad de las pruebas también podría ser un problema y hay muchas medidas de calidad. Existe la medida de cobertura: ¿con cuántas bifurcaciones, rutas de código u otros artefactos del programa del código de producto interactúan las pruebas? Existe la medida de aserciones que expresa si el código está haciendo lo correcto. No obstante, ninguna de estas medidas es suficiente por sí misma. En su lugar, lo que sería adecuado sería una alta densidad de aserciones que se validen con alta cobertura de código. Pero no es fácil de hacer este tipo de análisis de calidad en la cabeza mientras se escriben las pruebas y, en consecuencia, puede terminar con pruebas que traten las mismas rutas de código varias veces; quizás solo probando la "ruta fácil" y no se pueda saber si el código del producto puede siquiera soportar todos esos casos extremos.
  • Y lo que resulta frustrante es que quizás ni siquiera sepa qué aserciones debería poner. Imagine que recibe un encargo para realizar cambios en un código con el que no está familiarizado.

La capacidad de generación de pruebas automáticas de las pruebas unitarias inteligentes es especialmente útil en esta situación. Puede marcar el comportamiento actual observado del código como un conjunto de pruebas para su uso como un conjunto de regresión.

Pruebas basadas en la especificación Los equipos de software pueden usar PUT como la especificación para conducir la generación de casos de pruebas exhaustivas y descubrir las infracciones de las aserciones de pruebas. Al ahorrarse gran parte del trabajo manual necesario para escribir casos de prueba que consigan gran cobertura de código, los equipos se pueden concentrar en tareas que las pruebas unitarias inteligentes no pueden automatizar, como escribir escenarios más interesantes como PUT y desarrollar pruebas de integración que van más allá del ámbito de las PUT.

Búsqueda automática de errores Las aserciones que expresan propiedades de corrección pueden establecerse de varias maneras: como instrucciones assert, como contratos y otros. Lo bueno es que todos estos se compilan en bifurcaciones (una instrucción if con una bifurcación then y una rama else que representan el resultado del predicado que se está validando. Dado que las pruebas unitarias inteligentes calculan las entradas que actúan sobre todas las bifurcaciones, se convierte también en una herramienta de búsqueda de errores eficaz: cualquier entrada con la que se topa que puede desencadenar la bifurcación else representa un error en el código que se somete a prueba. Por lo tanto, todos los errores que se notifican son errores reales.

Mantenimiento reducido de los casos de prueba Con la presencia de PUT, se necesitan mantener muchos menos casos de prueba. En un mundo donde los casos de prueba cerrados individuales se han escrito manualmente, ¿qué sucedería cuando el código sometido a prueba evolucionara? Debería adaptar el código de todas las pruebas individualmente, lo que conllevaría un coste significativo. Pero al escribir PUT en su lugar, solo deben mantenerse las PUT. A continuación, las pruebas unitarias inteligentes pueden regenerar automáticamente los casos de prueba individuales.

Desafíos

Limitaciones de la herramienta La técnica del uso de análisis de código de caja blanca con resolución de restricciones funciona muy bien en código de nivel de unidad que está bien aislado. Sin embargo, el motor de pruebas tiene algunas limitaciones:

  • Lenguaje: En principio, el motor de pruebas puede analizar los programas de .NET arbitrarios, escritos en cualquier lenguaje de .NET. Sin embargo, el código de prueba sólo se genera en C#.
  • No determinismo: El motor de prueba supone que el código en pruebas es determinista. Si no es así, eliminará las rutas de ejecución no deterministas o podría ir en ciclos hasta que llegue a los límites de la exploración.
  • Simultaneidad: El motor de pruebas no controla los programas multiproceso.
  • Código nativo o código .NET que no se ha instrumentado: El motor de pruebas no entiende el código nativo, es decir, las instrucciones de x86 a las que se llama a través de la característica de invocación de plataforma (P/Invoke) de Microsoft .NET Framework. El motor de pruebas no sabe cómo traducir dichas llamadas en las restricciones que se pueden solucionar mediante un solucionador de restricciones. E incluso en el código. NET, el motor solo puede analizar el código que instrumenta.
  • Aritmética de punto flotante: El motor de pruebas usa un solucionador de restricciones automático para determinar los valores que son relevantes para el caso de prueba y el código que se somete a prueba. Sin embargo, las capacidades de solucionador de restricciones están limitadas. En concreto, no se puede analizar con precisión la aritmética de punto flotante.

En estos casos, el motor de pruebas informa al desarrollador con una advertencia, y el comportamiento del motor si existen estas limitaciones se puede controlar con atributos personalizados.

Creación de buenas pruebas unitarias con parámetros La escritura de buenas PUT puede suponer un reto. Hay dos cuestiones principales que se deben responder:

  • Cobertura: ¿Cuáles son los escenarios adecuados (secuencias de llamadas a métodos) para ejecutar el código sometido a prueba?
  • Comprobación: ¿Cuáles son las aserciones adecuadas que se pueden indicar fácilmente sin volver a implementar el algoritmo?

Una PUT solo es útil si proporciona respuesta a ambas preguntas.

  • Sin la cobertura suficiente; es decir, si el escenario es demasiado estrecho para llegar a todo el código que se somete a prueba, se limita el alcance de la PUT.
  • Sin la comprobación suficiente de los resultados calculados; es decir, si la PUT no contiene suficientes aserciones, no puede comprobar que el código esté haciendo lo correcto. Lo que la PUT hace es comprobar que el código en pruebas no se bloquee ni tenga errores en tiempo de ejecución.

En las pruebas unitarias tradicional, en el conjunto de preguntas se incluye una más: ¿Qué entradas de prueba son pertinentes? Con las PUT, de esta pregunta se ocupan las herramientas. Sin embargo, el problema de encontrar buenas aserciones es más sencillo en las pruebas unitarias tradicionales: Las aserciones tienden a ser más sencillas, ya que se escriben para entradas de pruebas concretas.

Resumen

La característica de pruebas unitarias inteligentes en Visual Studio 2015 Preview le permite especificar el comportamiento esperado del software en cuanto a su código fuente, y usa el análisis de código automatizado de caja blanca junto con un solucionador de restricciones para generar y mantener un conjunto compacto de pruebas relevantes con alta cobertura para su código .NET. Las ventajas abarcan las funciones: los diseñadores pueden usarlas para especificar el comportamiento deseado de las API del software, los desarrolladores pueden utilizarlas para dirigir las pruebas de desarrolladores automatizadas y los evaluadores pueden aprovecharlas para la generación de pruebas automáticas exhaustivas.

Los ciclos de lanzamiento cada vez más cortos en el desarrollo de software está impulsando gran parte de las actividades relacionadas con la planificación, especificación, implementación y pruebas para sucedan de manera continuada. Este mundo dinámico es un desafío para volver a evaluar las prácticas existentes en torno a esas actividades. Los ciclos de lanzamiento cortos, rápidos e iterativos necesitan que la colaboración entre estas funciones se lleve a un nuevo nivel. Las características como las pruebas unitarias inteligentes pueden ayudar a que los equipos de desarrollo de software alcancen más fácilmente dichos niveles.


Pratap Lakshman trabaja en la Developer Division de Microsoft, donde actualmente es director senior de programas en el equipo de Visual Studio y trabaja en las herramientas de prueba.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Nikolai Tillmann