jueves, 31 de julio de 2014

La falacia del testing y algo más

Hoy quiero hablarles de una de las más aceptadas metodologías de desarrollo en el ámbito industrial (e incluso en el académico), aquella conocida como "test driven development". En este enfoque, lo primero en diseñarse son los casos de prueba que se desea que pase exitosamente un cierto programa y luego se implementa dicho programa de forma que satisfaga estas expectativas. El proceso puede ser iterativo, incorporando nuevos casos y posiblemente alterando la implementación realizada acorde a estos. Esta estrategia tiene una grandiosa ventaja: Permite al programador estar más claro desde el principio en lo que quiere implementar y las propiedades que cumple. Además le otorga un mecanismo para descubrir fallas en su implementación o en su intuición (siempre puede haber errores en la definición de casos de prueba también). Incluso hay muy buenas herramientas que permiten realizar pruebas unitarias con facilidad. Lo que es una lástima... es que está severamente limitado.

Ni tan exagerado. XD

¿Cómo que está limitado? El enfoque del "test driven development" está basado en un conjunto de prueba que sirve como heurística para la totalidad de las posibles entradas al sistema implementado. ¿Pero como asegurar que es una buena heurística? Un programa tan sencillo como un factorial tiene como dominio a la totalidad de los números enteros. Incluso restringiendo el mismo a un lenguaje de programación con enteros de 32 bits, existen 2^32 posibles entradas para el programa. Hacer 100 o hasta 1000 pruebas es totalmente insignificante. Para programas apenas un poco más complejos, las posibilidades se hacen completamente inmanejables. Claro, estas pruebas pueden diseñarse para englobar características de un gran conjunto de posibles entradas que, junto a la "buena intención" del programador, pueden dar una confianza aceptable en la calidad de la solución. ¿Pero cómo asegurar que se considera la totalidad de las opciones? Esto puede mejorarse incluso haciendo que el diseño de pruebas sea realizado por un equipo diferente e independiente del equipo implementador (de lo contrario, las pruebas estarán influenciadas por las propias expectativas de la solución ya planificada). Pero aún con todo esto, no parece ser suficiente.

Me imagino dos reacciones razonables al párrafo anterior: "Si, la estrategia tiene fallas, pero es mejor que nada" y la clásica "Bueno, ¿y tu que propones?" (no pude evitarlo, jajaja). La primera tiene toda la razón. Una estrategia que haga consciente al programador de lo que quiere implementar antes de empezar a echar código es mucho más responsable que la alternativa "dale play y si explota ahí vemos". Pero hay una opción que es mucho mejor y con eso aprovecho y respondo a la pregunta de la segunda reacción: el "contract driven development" o "design by contract" como lo llamó Bertrand Meyer (el pana que creó el lenguaje Eiffel). Sin embargo esto no es nuevo, viene de una larga línea de grandes pensadores de las ciencias de la computación en donde destacan personalidades como Edsger Dijkstra, Tony Hoare y hasta el mismísimo Alan Turing.

Este enfoque de diseño por contratos está basado en la filosofía de que todo programa puede especificarse formalmente. Esto es, las condiciones que se esperan al inicio, al final e invariantemente durante la ejecución del mismo pueden ser descritas por medio de alguna lógica formal. Esto se extiende a instrucciones, expresiones, tipos de datos, subrutinas, etc. Estas especificaciones o contratos se establecen antes de comenzar a implementar las soluciones y cumplen con dos objetivos importantes:
  • Los programadores tienen una idea clara de lo que deben implementar, las garantías con las que cuenta su programa (la forma del dominio) y lo que se espera del mismo al finalizar su ejecución. Esto guiará una implementación mucho más sólida y clara.
  • Una vez implementada la solución, la misma puede verificarse formalmente vía una demostración rigurosa que asegura, sin cabida a dudas, que la implementación cumple con lo establecido en su contrato.
¿Y que pasó con los lenguajes funcionales, que están en un nivel de abstracción tan alto que el programa mismo es su propio contrato? ¡Mejor aún! Tal nivel de abstracción es ideal para hacer pruebas simbólicas sobre los programas escritos directamente y finalmente tener la misma calidad y confianza (quizá hasta más que en un contexto imperativo). Más aún, pueden especificarse propiedades adicionales apoyadas por el sistema de tipos (la firma de una función es un contrato para la misma).

Sin embargo, esta solución tampoco es perfecta y tiene una buena cantidad de fallas significativas:
  • El contrato aún es tan bueno como la intuición de quien lo diseñó. Lo que se quiere puede estar mal especificado o, incluso, puede que no se esté claro siquiera cuál problema quiere resolverse realmente.
  • El tiempo dedicado para diseñar contratos y la posterior verificación de los mismos es mucho más grande que en otras estrategias de desarrollo. Los resultados tangibles pueden verse sustancialmente atrasados.
  • El contrato, similar a los casos de prueba, debe ser elaborado por un equipo independiente al implementador. La diferencia reside en que un contrato requiere de mucha más experiencia y formación que un caso de prueba, lo cual eleva las exigencias académicas y de personal necesarias para llevar a cabo un proyecto.
La primera falla es inevitable. Programar es una actividad humana y como tal está susceptible a tener fallas siempre. Lo que uno puede intentar es reducir estas fallas lo más posible y contenerlas de forma tal que los resultados sean suficientemente confiables. La segunda falla no es tanto una falla como más bien una inversión. Sí, un proyecto que pudiera haber tomado 3 meses, tomó quizá 6 a 8. ¡Pero se ahorraron los potenciales años reparando bugs, con una solución de calidad! La tercera falla es real y difícil; mucho más cuando una gran cantidad de programadores y profesionales de la computación están completamente desligados y desinformados en lo que concierne a métodos formales. Los que si los conocen están usualmente confinados a la academia o pueden llegar a ser muy costosos. Es mi opinión, y la de algunos otros que conozco, que todo profesional de la computación debería tener una formación sólida en métodos formales y hacerlos parte de sus herramientas de trabajo en su día a día. Eventualmente, especificar un contrato será natural para muchos, tanto como lo es diseñar casos de prueba actualmente y tendremos sistemas mucho más confiables, seguros y mantenibles.

¿Pero entonces todo hay que demostrarlo? No necesariamente. Idealmente, si, eso daría el 100% de confianza. Pero el tiempo es un factor innegable y a veces de verdad no alcanza para todo el rigor involucrado en una prueba completa. Existen herramientas de verificación que permiten hacer algunas demostraciones de forma automática. Estas herramientas no son generales, ya que el problema de hacer una correspondencia de una implementación con un contrato se sabe indecidible. Sin embargo, pueden ahorrar bastante tiempo y esfuerzo en un principio y dar ciertas garantías sobre el comportamiento de un programa.

Hay que tomar en cuenta que tampoco es la intención que se elimine el testing por completo. Las máquinas no se comportan exactamente igual a la teoría; hay fallas inherentes a la implementación física de sus componentes. Además, pruebas concernientes al desempeño, a la resistencia ante muchas consultas simultáneas e incluso a la aceptación de la solución son aún necesarias, pero se delegarían a un siguiente paso del desarrollo.

Esto era lo que quería compartir por ahora (otro post más gallo aún, jajaja). Esto de los métodos formales no sólo es una rama fascinante de la computación, sino una que creo necesaria conocer para ofrecer soluciones confiables y de calidad. Como siempre, cualquier opinión es más que bienvenida. ¡Hasta la próxima! :)

7 comentarios:

  1. Agregando a la discusión, existe un tipo de error del que ningún tipo de heurística o modelo de programación pueden salvarte: Un idiota haciendo merge.

    Esto es equivalente a los errores de corrupción silenciosa que ocurren en los medios de almacenamiento, en los que el sistema operativo confía en los datos al momento en que lo escribe pero (excepto ZFS) pocas veces puede asegurar que al leer sea lo mismo que se escribió.

    Enter: testing.

    El modelo orientado a pruebas tiene 2 ventajas. La primera es la que resaltas en tu post: Aumenta el entendimiento del programador sobre el problema a atacar. Luego, el mismo realiza una interpolación de código hasta que logra satisfacer los criterios de aceptación.

    La segunda es que garantiza que, en el tiempo, al menos los mismos criterios de aceptación se siguen cumpliendo y ayuda, en buena medida, a prevenir las regresiones.

    Me gustaría ver una demostración que haga eso.
    Buen post! :)

    ResponderEliminar
    Respuestas
    1. ¡Gracias por el comentario man! Tal cual, el peligro de un merge mal hecho es muy real y puede ser un desastre, jajaja. Pero creo que aún puede adaptarse al modelo de contratos y demostraciones En particular si se cumplen las tripletas {P} S1 {Q} y {P} S2 {Q}, es también verdad que se cumple la tripleta {P} S1 [] S2 {Q} (donde [] es el operador de ejecución no-determinista). Por lo tanto, al hacer un merge bien hecho "debería" mantenerse la validez del programa. Sin embargo, tal como dices, el merge puede estar mal hecho y afectar el comportamiento del programa final. Esto, en el peor de los casos, requeriría una nueva demostración. Sin embargo, si se proponen herramientas formales para hacer estos merge en términos de teoría de refinamiento, es posible que esos problemas puedan evitarse. :D Creo que sería interesante tomar esto, ver si se ha atacado y empezar a proponer soluciones formales para el problema del merge.

      Eliminar
    2. Pero eso es también afectado por la primera falla que identifica Ricardo:

      «El contrato aún es tan bueno como la intuición de quien lo diseñó. Lo que se quiere puede estar mal especificado o, incluso, puede que no se esté claro siquiera cuál problema quiere resolverse realmente.

      La primera falla es inevitable. Programar es una actividad humana y como tal está susceptible a tener fallas siempre. Lo que uno puede intentar es reducir estas fallas lo más posible y contenerlas de forma tal que los resultados sean suficientemente confiables.»

      Las pruebas, las especificaciones de tipos y los contratos se representan en forma de código y se almacenan en control de versiones. Ninguna de las tres técnicas resuelve el problema fundamental de manera completa — es un problema imposible de resolver en general.

      Las otras técnicas para dar garantías de calidad de software también proveen beneficios enormes —y mucho mayores que los sistemas de pruebas, diría yo— en el entendimiento del problema. Un buen sistema de tipos te obliga a modelar tu problema de manera lógicamente consistente, formal y composicional, y si sacaste provecho del sistema, es común que puedas entender la solución únicamente a través de las declaraciones de tipos: qué tipos existen y qué funciones existen para transformarlos. De hecho, esta manera de entender el software es totalmente expresiva — objetos y morfismos, pues!

      Las demostraciones son *al menos* tan capaces como las pruebas de dar garantías de correctitud en presencia de cambios en el software. Ninguno de esos aspectos está ausente ni de sistemas de pruebas, ni de sistemas de tipos, ni de sistemas de contratos.

      Eliminar
  2. Creo que hay una distinción importante que hacer cuando se habla de TDD y testing. TDD es una combinación de una estrategia metodológica con una técnica de verificación de software. La estrategia metodológica de TDD consiste de especificar el comportamiento esperado del software antes de construirlo. La técnica de verificación de software de TDD es testing — hacer suites de pruebas que funcionan dinámicamente.

    La estrategia metodológica es ortogonal a la técnica de verificación. La estrategia metodológica que TDD usa es brillante — la técnica de verificación, no tanto.

    Yo uso la misma estrategia metodológica para programar: primero especifico lo que necesito de mi programa modelando sus conceptos en el sistema de tipos, y luego programo (o derivo!) lo que haga falta hasta que el código esté completo y funcione. La cobertura de código es del 100% siempre, porque el compilador del lenguaje hace demostraciones automáticas de que *todo* mi código cumple lo que especifiqué. La incompletitud que permite que esto funcione está en que mi especificación puede haber sido insuficientemente detallada y no modelar todas las propiedades que necesito del programa. En TDD pasa lo mismo cuando no escribes todas las pruebas que necesitas escribir (excepto que tienes que preocuparte manualmente del problema de la cobertura, y no hay un análisis lógico que garantice que el diseño sea consistente). Imagino que en la programación con contratos ocurre exactamente lo mismo: haces tus contratos, y luego programas (o derivas!) el código para que la especificación se cumpla (y completas las demostraciones donde haga falta).

    El espíritu de TDD es genial. El problema es que su técnica de verificación no sea formal, sino ad hoc.

    ResponderEliminar
    Respuestas
    1. ¡Jamás lo había visto de esta forma! Hacer la distinción entre la metodología en si y la técnica de verificación es brillante man. Pues se rescata lo mejor y más valioso que ofrece le TDD: la planificación y la comprensión del problema, incluso antes de comenzar a programar. ¡Gracias! :D

      Eliminar
  3. Hola Ricardito :D. Bueno, no estoy seguro de haber entendido todos los detalles de los que hablas, pero sí entiendo la idea general de tu post. Además, mi comentario va desde un punto de vista de un principiante :D.

    Primero, una pregunta, ¿Al final el verificador de tipos de un compilador no se comporta como un verificador de contratos? Al menos los que son sólidos pueden verificar que el programa es completo en los tipos de las varibles y firmas de funciones. ¿Por algo dicen que si un programa en haskell compila entonces funciona, no xD? ¿No puede incluirse la idea que propones dentro del concepto de compilador?.

    Ahora un comentario, siento que no le das suficiente importancia a las pruebas. Para mi es fundamental probar lo que estoy haciendo. Por supuesto que sería mucho mejor poder demostrar formalmente que lo que estoy haciendo está correcto, pero al menos en mi caso siempre estoy probando lo que voy haciendo. No solo cuando termino de escribir una función, o termino el programa, sino a medida que voy escribiendo el código. Me da confianza para seguir y cuando encuentro un error se cuales son las partes de mi código a las que les tengo confianza y cuales pudieron haber fallado. Me parece muy importante que un programador tenga la capacidad de anticipar todos los posibles casos en los que su código pueda explotar horriblemente y asegurarse que su código está preparado para afrontarlos.

    ¡Saludos Ricardito!

    ResponderEliminar
    Respuestas
    1. Épale José Luis. ¡Gracias por el comentario y tiene mucha razón! Los contratos no son únicamente aquellos correspondientes a las clásicas tripletas de Hoare, sino que es realmente cualquier mecanísmo formal que permita establecer requisitos y garantías. Un sistema de tipos fuerte (y en partícular los estáticos) permiten realizar verificaciones de estos "contratos de tipos". Así que si, algunos compiladores (por ejemplo Haskell, aunque más otros como Idris) permiten hacer verificaciones estáticas sumamente interesantes y útiles. :)

      Con respecto a lo de las pruebas, lo que propongo no es que no se hagan. Siempre se pueden realizar prueba para uno a forma de ayudar el desarrollo. Al igual que se pueden colocar flags para analizar la ejecución de un programa dado. Lo que realmente critico es el considerar "pasar las pruebas" como un medio para asegurar que el programa es correcto y confiable. Es apenas una heurística y una muy laxa realmente. Hace falta algo un poco más formal que eso para dar garantías reales y útiles, al menos eso creo yo, jeje.

      Eliminar