jueves, julio 10, 2014

Unit Testing: Qué es y cómo se usa en VFP 9

Por: Fernando D. Bozzo

Antes de comenzar, prepárense un café, esto es un poco más largo de lo que creí :-)


A todo buen desarrollador le interesa comprobar que sus programas hacen lo que tienen que hacer, comprobar las funcionalidades, los métodos, el código, las buenas prácticas, todo lo que hace que los programas funcionen bien y sean fáciles de mantener.

El método más común y más conocido por la mayoría para probar los desarrollos, es el de las pruebas manuales, o sea, entrar al programa y comenzar a hacer pruebas ingresando datos, seleccionando opciones, comprobando cálculos, etc. Podríamos decir que las pruebas intentan cubrir aspectos de Interfaz, de reglas de negocio y de datos.

Estas pruebas en general llevan tiempo, a veces bastante tiempo, y, seamos sinceros, a ningún desarrollador le gusta estar haciendo pruebas todo el tiempo, ya que es una tarea muy repetitiva y aburrida. Lo emocionante es programar, comprobar que funciona y que le sea útil a alguien, nosotros incluidos.

Pero la realidad es que todo programa o sistema se construye una vez y se mantiene el 99% del tiempo, tanto para agregar nuevas funcionalidades, como para modificar funcionalidades existentes y corregir errores, también llamadas incidencias, y eso requiere hacer muchas pruebas, y volver a probar lo que ya habíamos probado porque se ha modificado algo que puede afectar a lo que había y que podría causar errores "de regresión", y por eso a estas pruebas se las llama "pruebas de regresión".

No existen los programas libres de errores, y siempre hay alguna condición que no fue tenida en cuenta y que puede provocar un fallo, por no hablar de los fallos propios de la codificación, y por eso se hacen las pruebas.


¿Pero no sería útil poder automatizar esas pruebas? ¿No sería genial que esa tarea repetitiva y aburrida la haga la PC en vez de nosotros? ¿No estaría bien que muchas de esas pruebas que nos pueden llevar horas o días se realicen de alguna forma automatizada en segundos o minutos?

Para eso sirve el Testing Unitario o Unit Testing.



¿Qué es el Testing Unitario?


El Testing Unitario permite programar pruebas del sistema, o de partes del sistema, para que se ejecuten de forma automatizada. Es un programa que comprueba a otros sistemáticamente.

Muchos hemos realizado el típico programa de pruebas para comprobar alguna funcionalidad específica, metiendo en él todo lo necesario para que la prueba funcione, como abrir tablas, cargar objetos, poner valores de prueba, etc, y realmente esa es la base, pero no es suficiente.

Este mecanismo de hacer "un programita" para probar cosas tiene un problema, y es que muchas veces no es reutilizable, y si lo es, seguramente hay más programitas como este dispersos por el sistema y con nombres raros, pero que no permiten ser ejecutados en secuencia, uno tras otro, para automatizar esas comprobaciones.

Es aquí donde radica la principal diferencia entre un Framework de Testing Unitario y esos programas de pruebas que todos hemos hecho alguna vez. Un Framework de Testing Unitario permite hacer estos programas de pruebas con una estructura común a todos los tests, con una lógica que puede ser compartida por todos los tests, ofreciendo métodos comunes ya preparados para comprobar valores, pero principalmente, y aquí está la mayor ventaja, es que permite que todos esos tests se puedan ejecutar uno tras otro con solo pulsar un botón o ejecutar un solo comando.


Alguno ya estará pensando: "Ya, que bien, o sea que hacer tests encima requiere hacer más trabajo y programar más"

...pero antes de contestar eso, preguntémonos esto:
  • ¿Cuánto tiempo usamos en hacer pruebas manuales?
  • ¿Comprobamos realmente todos los cambios que hacemos en el sistema?
  • ¿Cuánto tiempo perdemos en volver a probar una funcionalidad solo por el hecho de haber tenido que modificar alguna función o cálculo?
  • ¿Alguno hace todas las pruebas de regresión que debería hacer?

Dependiendo de la complejidad del sistema, estas pruebas suelen llevar horas, días o meses, y suele ser bastante difícil comprobar todas las combinaciones posibles, por lo que se suelen probar las más importantes. Incluso en el mejor caso donde la funcionalidad sea fácil de probar, hacer manualmente todos los casos de prueba puede llevar mucho tiempo.

Imaginemos un caso típico, una nueva funcionalidad de complejidad media que pide el cliente, donde se requieren 4 horas de pruebas manuales para comprobar esa funcionalidad, que serían realmente 4 horas nuestras al terminar de programar, más 4 horas del cliente para comprobar que funciona como quiere. Ya tenemos 8 horas solo para empezar.

Pero como no todo siempre sale bien a la primera, imaginemos que el cliente descubre un fallo o algo que no va como esperaba, por lo que tenemos que hacer las correcciones y volver a probar todo. Claro que, en este punto, ya no probamos "todo" otra vez, e intentamos optimizar el tiempo probando "solo lo que tocamos" o eso creemos...

En esta segunda prueba, tanto nosotros como el cliente dedicamos algo menos de tiempo para probar, digamos que 2 hs cada uno, pero lo que tocamos antes afectaba a más de lo que creíamos al principio, y hemos introducido un nuevo fallo que puede encontrar el cliente, o con suerte nosotros, y vuelta a empezar...


Para cuando realmente se termina de comprobar todo, puede que hayan sido necesarios 2 ciclos extra de pruebas, lo que fácilmente se pudo llevar, contando los tiempos de feedback del cliente, las modificaciones y las pruebas, otras 8 a 16 hs más... creo que queda claro el problema, y que varios pueden reconocer que les ha pasado, personalmente o a su equipo de desarrollo.

Creando tests automatizados el mayor tiempo se invierte al principio, sobre todo cuando se recién se comienza con ellos, ya que hay una curva de aprendizaje, no sólo de cómo construir los tests en un framework, sino principalmente de cómo hacerlos de la mejor forma posible para que sean fáciles de mantener y útiles en lo que comprueban.

Supongamos que para crear y ajustar los tests dedicamos 8 hs, o 16 hs. Una vez hechos estos tests, cada ejecución de los mismos suele tomar segundos o minutos si son demasiados. En otras palabras, cada vez que necesitemos ejecutar estas pruebas, ya no requeriremos 8 hs o más, sino segundos o minutos...



¿Qué tipos de pruebas se pueden hacer?


La respuesta corta es: Todas las que se puedan automatizar en nuestro sistema, lo que normalmente dependerá de qué tan bien hayamos parametrizado y encapsulado las funcionalidades del mismo.

Hay 3 tipos de pruebas: Unitarias, de Integración y Funcionales. Las pruebas Unitarias suelen comprobar métodos o módulos, lo que hace que sean mucho más rápidas de ejecutar; las pruebas de integración son pruebas que se hacen juntando los desarrollos de varios desarrolladores y probándolos de forma conjunta para comprobar que no hay incompatibilidades entre ellos; y las pruebas Funcionales suelen ser las más complejas, las que más tiempo llevan, suelen ser manuales en las aplicaciones de escritorio y en algunas web y debe participar el usuario en las mismas.

En las pruebas Unitarias, si se verifica el correcto funcionamiento de las partes (métodos, módulos), se puede llegar a controlar buena parte de la funcionalidad de la aplicación, quedando solo exceptuada la Interfaz.

Uno de los beneficios principales de contar con estos tests, es que una vez hechos pasan a ser "pruebas de regresión", por lo que no se tiran, se reutilizan y acumulan indefinidamente hasta que ya no hagan falta (por haber cambiado las reglas de negocio) o que deban ser adaptados para contemplar nuevos casos de prueba.

Otro beneficio es que, si se han hecho buenos casos de prueba, los fallos se detectan antes, mucho antes, en general de forma inmediata, antes de que se entregue al cliente (interno o externo), lo que por sí solo implica un ahorro de tiempo, costes de despliegue y mala imagen.

Un beneficio colateral de los tests automatizados, es que ayudan a programar mejor, permiten ver qué funcionalidades deberíamos separar en capas o en módulos más chicos y testeables, permiten ver que módulos requieren una mejor parametrización y en general nos muestran muchas cosas que no hacemos bien, porque si es difícil de probar con un test, en general (salvo excepciones) es indicativo de que hemos hecho código espagueti o que hemos metido demasiadas funcionalidades o responsabilidades en un módulo.

Por todo esto es que muchos programadores prefieren seguir como estaban, en la comodidad de lo que conocen, ya que no todo el mundo está preparado para asumir que su forma de trabajar puede tener fallos o puede requerir mejoras, y a veces el costo de mejorar puede ser alto. Pero los beneficios son mucho mayores, tanto individualmente como para trabajar en equipo, ya que se potencian las buenas prácticas de programación, la reusabilidad y la mantenibilidad del código, cuestiones que a veces se descuidan bastante.


En general, en las aplicaciones de escritorio (o sea, no web), se suelen comprobar habitualmente 1 ó 2 de las 3 capas del sistema.

Por ejemplo, si consideramos que las 3 capas habituales son Interfaz (pantallas y menús), Negocio (reglas, validaciones) y Datos (accesos a la BDD, consultas, actualizaciones), los tests suelen centrarse en el Negocio y en algunos casos, en los Datos.

Aunque la Interfaz se puede comprobar, es más complejo y lleva más tiempo. Para esto hay, entre otros, un software Open Source llamado Sikuli.

Para la la parte de Reglas de Negocio y Datos, disponemos del Framework FoxUnit para Visual FoxPro 9, con el que podemos:

  • Crear plantillas de código de tests específicas para facilitar la creación de nuevos tests
  • Crear tests basados en plantillas de código prediseñadas
  • Aprovechar los métodos de testeo disponibles para comprobar igualdades, nulos, verdadero/falso, etc
  • Disponer de una interfaz que centraliza la creación y ejecución de todos los tests
  • Elegir entre ejecutar un solo test, todos los tests de una librería o todos los tests del sistema
  • Tener un indicador rojo/verde de cada test
  • Llevar la cuenta de cuántos tests han pasado bien y cuántos han fallado


Percepción de que los tiempos de desarrollo suben notablemente al hacer tests


[Nota: Esta parte la transcribí y adapté del interesante debate que tuvimos en el foro]

Obviamente que los tiempos de desarrollo haciendo tests son superiores a los tiempos de desarrollo sin hacerlos. En mi caso particular, cuando comencé a usarlos me costó bastante aprender cómo hacer un buen caso de prueba, y mis tiempos de desarrollo se duplicaban en algunos casos, dependiendo de la complejidad de lo que quisiera probar.

Pero luego está la parte que nadie cuenta, y que realmente es la que se lleva muchas veces el mayor tiempo, que es la de las pruebas manuales, donde hay de 2 tipos:
  • Pruebas manuales que se pueden automatizar
  • Pruebas manuales que no se pueden automatizar

Dentro de las pruebas manuales que no se pueden automatizar (que suelen ser los tests de integración, o sea donde se prueba como interacciona todo el conjunto), pues no hay salida, deben seguirse haciendo manualmente, aunque programas como el Sikuli, pueden ayudar en cierta medida.

Dentro de las pruebas manuales que se pueden automatizar, es donde se puede sacar mayor provecho, ya que en general toda prueba requiere la comprobación de Interfaz por un lado y de resultados por el otro, aunque al principio no nos demos cuenta y verifiquemos todo como una unidad, y justamente la parte de comprobación de resultados es la que suele poder automatizarse fácilmente, como el ejemplo del cálculo del PVP que puse antes, en este artículo.

Si contamos esta parte de los tiempos de prueba (resultados, cálculos, totales, datos, etc) y los separamos de lo que es estrictamente Interfaz (controles, refresco, enabled/disabled, click aquí o allá) las primeras suelen ser la mayor parte de los tiempos de pruebas totales, que en general suelen ser horas o días (días de 8 hs), dependiendo de la complejidad del programa o sistema, y que es a lo que me refería en el artículo.

Por eso, al automatizar esta parte de las pruebas, que implica programar casos de prueba, es donde cambia la percepción de que el tiempo de desarrollo sube, pero por otro lado el tiempo de pruebas baja bastante, y cuando hay que repetir esas pruebas, la bajada de tiempos ya es abismal, porque los casos de prueba simplemente se vuelven a ejecutar y no hay que programarlos otra vez, en contraste con las pruebas manuales que deben repetirse al completo.
Esta es una de las partes que nadie suele medir, los tiempos de pruebas.

Luego está la otra parte que nadie mide, y es la de los tiempos requeridos en resolver las incidencias. ¿Por qué? Porque nadie cuenta con ellas, y todos creen que si las pruebas manuales fueron bien, ya todo está bien y no hay de qué preocuparse. Pero luego llegan las incidencias inesperadas, esas que no debían ocurrir, y ya no se mide, "se corre" para solucionarlas cuanto antes, lo que suele implicar todas o algunas de estas fases (cada uno adapte a su caso):

  1. El cliente detecta un fallo en sus pruebas y las anota (tiempo de pruebas perdido, porque deberá repetirlas más adelante)
  2. Al terminar las pruebas, el cliente reporta los fallos encontrados (más tiempo, al teléfono o redactando el documento con los detalles)
  3. El gestor recibe los fallos y los comunica al equipo (tiempos de gestión)
  4. El equipo de desarrollo (o el único desarrollador) recibe los fallos y:
    1. Intenta entender lo que quiso decir el cliente (tiempo de "decodificación" e "interpretación" del documento, si lo hay)
    2. Intenta reproducir los fallos en su PC (tiempo de pruebas hasta dar con el caso específico)
    3. Encuentra el fallo y lo soluciona (tiempo de programación y pruebas manuales nuevamente)
    4. Se integra la corrección en la siguiente release o parche (tiempo de integración)
    5. Genera una nueva versión y la vuelve a entregar al cliente (tiempo de entrega y/o despliegue)
  5. El gestor avisa al cliente de que tiene el programa arreglado (tiempo de gestión)
  6. El cliente vuelve a realizar sus pruebas (tiempo de pruebas manuales, nuevamente)
  7. Un punto de mala imagen para el proveedor (coste de imagen y confianza)

Esto, básicamente, es la forma en que suele ocurrir en una empresa grande, en un trato directo con el cliente o PYME seguramente se pueden quitar algunos o varios pasos, pero algunos son inamovibles.

Esta es la segunda parte de los tiempos que nadie suele contar, y por eso queda la sensación de que los tiempos de desarrollo suben, que en parte es cierto porque se invirtió tiempo en hacer tests, pero los tiempos de incidencias y pruebas bajan de manera notable, y agreguemos el tiempo de desarrollo del arreglo de la incidencia, pero como en esta fase todos están corriendo por "solucionar las incidencias" que son muy importantes, nadie tiene tiempo de contar cuánto tiempo perdido se gastó. (Recalco lo de tiempo invertido y tiempo perdido)


Respecto a la complejidad de los casos de prueba, algunos son fáciles y rápidos, otros son entretenidos o creativos, y en otros a veces hay que aplicarse bastante para lograr unos buenos casos de prueba, que pueden ser inherentemente complejos y que se deben intentar simplificar para que luego sean fáciles de mantener. Pero en todo caso, una vez se terminan, se puede descansar mucho más tranquilo.



Por eso, cuando se piense en el aumento de los tiempos de desarrollo, no deben olvidarse todos los otros tiempos antes comentados.



Unit Testing en Visual FoxPro 9



Ejemplo del uso de FoxUnit para las pruebas de FoxBin2Prg (click en la imagen para agrandar y ver los detalles):





¿Cómo se usa FoxUnit? Un caso práctico y rápido


Ya hemos descargado el framework y lo hemos descomprimido en, por ejemplo, c:\desa\FoxUnit, entonces para ejecutarlo, pondríamos:

DO c:\desa\FoxUnit\foxunit.app


Pantalla de ejemplo inicial de FoxUnit:



Supongamos que necesitamos crear un nuevo método "Calcular_Precio_de_Venta" en una clase de cálculos "cl_calculos" dentro de una librería "lib_calculos.prg", para lo que hacemos la estructura del método (sin implementación!):


DEFINE CLASS cl_calculos AS Custom

    PROCEDURE Calcular_Precio_de_Venta
        LPARAMETERS tnCostoDelArticulo, tnMargenDeGanancia
        LOCAL lnPVP
        *-- Cálculo sin implementación todavía
        RETURN lnPVP
    ENDPROC

ENDDEFINE



Ahora vamos a crear un test para este cálculo, sabiendo de antemano el resultado que debe devolver. Ejecutamos el framework desde la ventana de comandos de VFP:


DO c:\desa\FoxUnit\foxunit.app


Elegimos el botón "New Class" y ponemos como nombre de programa "ut__lib_calculos__cl_calculos__Calcular_Precio_de_Venta":


Nota: Sobre la nomenclatura usada, les recomiendo usar esta, que está basada en la práctica y la experiencia. El nombre del programa lo formamos con una simple regla fácil de recordar:

"ut" + doble_guión_bajo + "nombre_libreria" + doble_guión_bajo + "nombre_clase" + doble_guión_bajo + "nombre_método"

Resumiendo: ut__libreria__clase__metodo

Así luego resulta fácil buscar todos los métodos de una clase o todas las clases de una librería con las casillas de filtrado que hay bajo los botones de la barra superior.


En la lista, elegimos "Minimal_FoxUnit_Test_case_template" [1] (el otro lo pueden elegir para ver las explicaciones, que tiene varias en Inglés) y clickeamos el primer botón largo [2]:



Una vez pulsamos el botón (1) se crea automáticamente la estructura de un programa de testeo, con 2 métodos: Setup y TearDown, debiendo nosotros crear los métodos correspondientes a los casos de prueba.

El método Setup y TearDown se ejecutan para cada caso de prueba que creamos, siendo el orden de ejecución este: Setup -> CasoDePrueba -> TearDown. Esto permite poner código para preparar el entorno necesario para que el caso de prueba se ejecute en Setup y luego liberar ese entorno en TearDown.

Para nuestro ejemplo, vamos a crear este programa de prueba, donde quité varias de las líneas de asteriscos que vienen con la plantilla y agregué el caso de prueba:


DEFINE CLASS ut__lib_calculos__cl_calculos__Calcular_Precio_de_Venta as FxuTestCase OF FxuTestCase.prg

    #IF .f.
    LOCAL THIS AS ut__lib_calculos__cl_calculos__Calcular_Precio_de_Venta OF ut__lib_calculos__cl_calculos__Calcular_Precio_de_Venta.PRG
    #ENDIF
    oBus = NULL
   

    ********************************************************************
    FUNCTION Setup
        SET PROCEDURE TO LIB_CALCULOS.PRG ADDITIVE
        THIS.oBus = CREATEOBJECT("CL_CALCULOS")
    ENDFUNC
   

    ********************************************************************
    FUNCTION TearDown
        THIS.oBus = NULL
        RELEASE PROCEDURE LIB_CALCULOS
    ENDFUNC


    *******************************************************************************************************************************************
    PROCEDURE Deberia_CalcularUn_PVP_de_11_ParaUnCostoDe_10_YUnMargeDeGananciaDe_10
        LOCAL lnCostoDelArticulo, lnMargenDeGanancia, lnPVP, lnPVP_Esperado ;
            , loBus as CL_CALCULOS OF LIB_CALCULOS.PRG
       
        *-- Inicialización y Datos de entrada
        STORE 0 TO lnCostoDelArticulo, lnMargenDeGanancia, lnPVP, lnPVP_Esperado
        loBus                = THIS.oBus
        lnCostoDelArticulo    = 10
        lnMargenDeGanancia    = 10
        lnPVP_Esperado        = 11

        *-- Testeo del cálculo
        lnPVP = loBus.calcular_precio_de_venta( lnCostoDelArticulo, lnMargenDeGanancia )
       
        *-- Información de estado
        THIS.messageout( "PVP Esperado:  " + TRANSFORM(lnPVP_Esperado) )
        THIS.messageout( "PVP Calculado: " + TRANSFORM(lnPVP) )

        *-- Validaciones
        THIS.assertequals( lnPVP_Esperado, lnPVP, "PRECIO DE VENTA (PVP)" )
    ENDPROC


ENDDEFINE



Veamos que tenemos:

  • En la cabecera de la clase definimos la propiedad que contendrá la instancia de nuestra clase (oBus = NULL)
  • En el método Setup configuramos nuestra librería de cálculos e instanciamos la clase
  • En el método TearDown hacemos exactamente lo contrario y en el orden opuesto, NULificamos la propiedad oBus y descargamos la libreria
  • Finalmente creamos el caso de prueba: Deberia_CalcularUn_PVP_de_11_ParaUnCostoDe_10_YUnMargeDeGananciaDe_10

Para quien ve esto por primera vez, puede pensar que es una exageración un nombre así de largo para un método, pero no hay que confundirse: el nombre del método se está usando como descripción del caso de prueba. Si lo leen detenidamente, lo primero que se darán cuenta es que el nombre contiene toda la descripción del caso de prueba a realizar, y que no requieren ver su código para saber que esperar.

La sintaxis no es casual: Es conveniente comenzar los casos de prueba siempre por "Deberia_", ya que con esto estamos indicando una intencionalidad de que el caso de prueba "Deberia" hacer lo que indique a continuación, que en este caso es "calcular un PVP de 11 para un costo de 10 y un margen de ganancia de 10%". Si el caso de prueba falla, entonces obviamente no está haciendo lo que debería hacer, y se debe verificar qué ocurre.

Esta nomenclatura es tomada de BDD (Behaviour Driven Development), donde en Inglés se usa "Should" que es el equivalente de "Deberia" en Español.

La importancia de comenzar con "Debería" en vez del típico "Test" es que en el primer caso se está haciendo hincapié en la funcionalidad que se desea conseguir, mientras que en el segundo caso se hace más hincapié en el test en sí, y de cara a un sistema lo más importante es conseguir y verificar una funcionalidad, no lograr que un test funcione. Con el tiempo probablemente se darán cuenta de la diferencia.


Volviendo al ejemplo, una vez creado el programa lo guardamos con CTRL+W, donde ahora podemos ver en la pantalla un registro con el nombre del programa y el caso de prueba que hicimos (hagan click en la imagen para verla mejor):



En la captura anterior se pueden observar varias cosas:
  • El botón All sirve para ejecutar todos los casos de prueba que tengan cargados en la pantalla
  • El botón Class sirve para ejecutar todos los casos de prueba de la clase seleccionada
  • El botón Selected sirve para ejecutar solo el caso de prueba seleccionado
  • El botón New Class lo usamos para crear nuevas clases de tests, como hicimos nosotros al inicio
  • El botón Load Class nos permite cargar clases que ya tengamos hechas y que no estén cargadas
  • El botón Add Test permite crear un nuevo caso de prueba en la clase actual
  • Remove Selected permite descargar los tests de la clase actual (los quita de la lista de tests, no los borra)
  • Reload Selected permite recargar los casos de prueba de la librería de clases actual. Esto es útil cuando por algún caso no se cargaron bien todos los casos.
  • Hay un par de cuadros de texto que permiten filtrar los tests por nombre de programa (izquierdo) o por nombre de caso de prueba (derecho), y en ambos casos se puede escribir solo una parte, ya que la búsqueda es por subcadenas.
  • Debajo está la lista de casos de prueba, donde en la columna izquierda están los programas que contienen los casos de prueba y en la columna derecha se muestran los casos de prueba (métodos) que hayamos creado. Viendo esta lista e imaginando como va a crecer a medida que agreguemos casos de prueba, se puede tener una idea de porqué es tan importante saber rápidamente por el nombre del caso de prueba qué es lo que hace y no tener que entrar a ver el código, ya que con el tiempo pueden haber cientos de casos de prueba.
  • Finalmente, en el panel inferior hay un PageFrame con 2 paneles, el izquierdo muestra los errores y el derecho los mensajes que hayamos hecho.

Vamos a ejecutar nuestro ejemplo y a ver que sucede. Sabemos que debería fallar, porque todavía no está implementado el cálculo, así que clickeamos el botón "Selected" y obtenemos esto:


Como podemos ver, la ventana de mensajes (Messages), donde se muestran los "this.messageout()", muestra que el valor esperado era 11 y el calculado es .F.
Se corresponde con estas líneas:

        *-- Información de estado
        THIS.messageout( "PVP Esperado:  " + TRANSFORM(lnPVP_Esperado) )
        THIS.messageout( "PVP Calculado: " + TRANSFORM(lnPVP) )



Veamos la ventana de errores (Failures and Errors) de la solapa izquierda:


Esta parte muestra las comparaciones y verificaciones que se hayan hecho con "this.assertX", en nuestro caso eran estas líneas:

        *-- Validaciones
        THIS.assertequals( lnPVP_Esperado, lnPVP, "PRECIO DE VENTA (PVP)" )



Como se ve, muestra un error de Data Type Mismatch (error de tipo de datos) y los valores esperado y obtenidos.


Respecto de los Assert, hay pocos y son fáciles de recordar, además que Intellisense facilita ver sus parámetros:

  • AssertEquals( e1, e2, me ): verifica que la expresión e1 y e2 son iguales, y opcionalmente muestra el mensaje de error me si no son iguales
  • AssertNotNull( e, me ): verifica si la expresión e es NULL o no, y opcionalmente muestra el mensaje de error me si el valor es NULL
  •  AssertNotEmpty( e, me ): verifica si la expresión e es EMPTY o no, y opcionalmente muestra el mensaje de error me si el valor es EMPTY
  • AssertNotNullOrEmpty( e, me ): verifica si la expresión e es NULL o EMPTY, y opcionalmente muestra el mensaje de error
  • AssertTrue( e, me ): verifica si la expresión e es Verdadera o no, y opcionalmente muestra el mensaje de error me si el valor es Verdadero
  • AssertFalse( e, me ): verifica si la expresión e es Falso o no, y opcionalmente muestra el mensaje de error me si el valor es Falso

Ahora que hicimos fallar el caso de prueba, vamos a implementar el cálculo que falta, por lo que cerramos la pantalla de FoxUnit, hacemos un CLEAR ALL y abrimos nuestra librería LIB_CALCULOS.PRG, agregando la línea de implementación del cálculo:




DEFINE CLASS cl_calculos AS Custom

    PROCEDURE Calcular_Precio_de_Venta
        LPARAMETERS tnCostoDelArticulo, tnMargenDeGanancia
        LOCAL lnPVP

        *-- Implementación del cálculo
        lnPVP = tnCostoDelArticulo * (1 + tnMargenDeGanancia / 100) 

        RETURN lnPVP
    ENDPROC

ENDDEFINE



Lo guardamos con CTRL+W, volvemos a cargar FoxUnit y ejecutamos el caso de prueba nuevamente:



Esta vez salió todo bien y podemos comprobar 2 cosas:
  1. Que el caso de prueba estaba bien hecho
  2. Que la funcionalidad implementa exactamente lo que indica el caso de prueba



Modificando la funcionalidad y adaptando el caso de prueba


Vamos a suponer que necesitamos crear un caso especial en el cálculo del PVP, en el que, si el coste es de 0,10 no recargaremos nada, independientemente del margen que pasemos de parámetro.

Lo primero que hacemos es crear el nuevo caso de prueba, modificamos el existente, lo copiamos y pegamos debajo con esto (resalto en amarillo los cambios):


    PROCEDURE Deberia_CalcularUn_PVP_de_0_10_ParaUnCostoDe_0_10_YUnMargeDeGananciaCualquiera
        LOCAL lnCostoDelArticulo, lnMargenDeGanancia, lnPVP, lnPVP_Esperado ;
            , loBus as CL_CALCULOS OF LIB_CALCULOS.PRG
       
        *-- Inicialización y Datos de entrada
        STORE 0 TO lnCostoDelArticulo, lnMargenDeGanancia, lnPVP, lnPVP_Esperado
        loBus                = THIS.oBus
        lnCostoDelArticulo    = 0.10
        lnMargenDeGanancia    = 10
        lnPVP_Esperado        = 0.10

        *-- Testeo del cálculo
        lnPVP = loBus.calcular_precio_de_venta( lnCostoDelArticulo, lnMargenDeGanancia )
       
        *-- Información de estado
        THIS.messageout( "PVP Esperado:  " + TRANSFORM(lnPVP_Esperado) )
        THIS.messageout( "PVP Calculado: " + TRANSFORM(lnPVP) )

        *-- Validaciones
        THIS.assertequals( lnPVP_Esperado, lnPVP, "PRECIO DE VENTA (PVP)" )
    ENDPROC



Ejecutamos nuestro nuevo caso de prueba, y obtenemos este fallo:



Pero ya lo esperábamos, porque todavía no está implementado el cambio, así que modificamos la implementación del cálculo em LIB_CALCULOS.PRG:

        *-- Implementación del cálculo
        IF tnCostoDelArticulo == 0.10
            lnPVP    = tnCostoDelArticulo
        ELSE
            lnPVP    = tnCostoDelArticulo * (1 + tnMargenDeGanancia / 100)
        ENDIF



Volvemos a FoxUnit y ejecutamos el nuevo caso de prueba otra vez:



¡Perfecto! Ya tenemos todo funcionando y verificado.



Tips y recomendaciones


Aquí les dejo algunas recomendaciones y cosas a tener en cuenta, que les va a ayudar a hacer mejores casos de prueba y más mantenibles:

  • Pensar y diseñar buenos casos de prueba: Esto es fundamental para que los tests sean útiles y realmente comprueben la funcionalidad lo más a fondo posible
  • No usar nombres inútiles como "deberia_hacer_un_calculo": Esto es más frecuente de lo que se cree, sobre todo al principio, donde muchas veces se ponen nombres que indican algo tan genérico que difícilmente sea útil al momento de validar algo específico, justamente por la falta de precisión en su definición. Si vamos al caso extremo, el sistema "deberia_funcionar" y si falla, no podemos tener una idea clara de qué es lo que falla exactamente como para encontrar el error rápidamente.
  • Ser suficientemente descriptivo con los nombres de los casos de prueba: Como en el ejemplo que puse arriba, la descripción debe ser lo más clara posible, como para que no sea necesario entrar para ver el código del mismo, y en caso de fallar, sabemos exactamente qué falló y dónde buscar.
  • No caer en el síndrome de la luz verde: Cuando se comienzan a hacer tests, es difícil no caer en la falsa seguridad de que si todo sale en verde está todo correcto. Verificar que cada test que se hace vale la pena y aporta algo al proceso. No tiene sentido hacer un test para comprobar un valor constante por ejemplo.
  • Encapsular las funcionalidades comunes de los tests: Esto puede ser útil cuando se quieran usar ciertos seteos comunes a todos los tests para evitar tener que copiar y pegar en cada uno de ellos. En FoxBin2Prg pueden ver un ejemplo de rutinas reutilizables llamadas en Setup y tearDown
  • Parametrizar bien los métodos del sistema: La mayoría de lo que se programe en un sistema o módulo debe estar parametrizado de tal forma que se pueda probar con un simple test sin requerir cargar todo el sistema para ello
  • Refactorizar el código como práctica habitual: Si un método tiene muchos comentarios, es signo de que necesita ser refactorizado en partes más chicas. La refactorización no cambia la funcionalidad de un método, solo permite partirlo en partes más chicas, testeables y mantenibles
  • TDD: Siempre que se pueda, intentar hacer primero los casos de prueba y luego la implementación. Si bien muchas veces hacerlo en este orden es más trabajoso, se logran mejores resultados. Al hacer primero el test y luego la implementación estaremos trabajando con lo que se conoce como TDD (Test Driven Development) o Desarrollo Manejado por Tests.




Nota sobre TDD:

TDD es muy útil, pero no hay que obsesionarse con esta técnica, ya que no siempre es posible aplicarla en tiempo y forma. Por ejemplo, es muy útil usarla cuando hay un diseño claro a seguir y se sabe de antemano exactamente como debe comportarse el sistema o un módulo o método (por ejemplo, al resolver incidencias o al crear o modificar ciertas funcionalidades), pero cuando se va programando sobre la marcha porque se tienen las cosas en la cabeza, se complica bastante usar esta técnica, en cuyo caso puede convenir hacer los tests al final.





Resumiendo


Aunque el ejemplo es muy básico, sirve para entrever algunas de las bondades de los Tests Unitarios. La próxima vez que tengamos que modificar la funcionalidad anterior, si llegamos a tocar alguno de los 2 cálculos que tenemos hechos, cuando ejecutemos los tests saltará el error indicando que algo no va bien, o se tocó lo que no se debía o se olvidó adaptar el caso de prueba antes de tocar, gracias a que los tests que hicimos ya son pruebas de regresión y quedarán ahí para futuras comprobaciones.

En el caso de usar un SCM, al igual que el código que escribimos, los casos de prueba también deben versionarse en el gestor de código fuente que se use junto al código del programa o sistema, en su carpeta Tests, ya que cualquier desarrollador que se baje el proyecto, debe tener estos test a mano para poder ejecutarlos antes de comprometer sus cambios.


Como dato de interés, FoxBin2Prg tiene creados por el momento (v1.19.26) unos 150 casos de prueba, no solo de reglas de negocio y validaciones, sino también de generación de pantallas (comparación por bitmap) y verificación de generación de archivos, y ni siquiera es un sistema. Gracias a esos tests el ahorro de tiempo es de varios días de pruebas por cada ejecución de todos los casos, que al estar automatizados tardan unos 5 minutos en total.

Pueden acceder a los tests disponibles abriendo una sesión de FoxPro en donde hayan bajado y descomprimido el FoxBin2Prg y ejecutando el FoxUnit, y de paso podrán ver algunas técnicas de encapsulación de tests para minimizar el código escrito y facilitar su mantenimiento.


Espero que lo anterior sirva para que comiencen a incursionar en este tema, que no solo es muy interesante, sino sumamente práctico.
Al principio cuesta, se cometen errores y se aprende, pero lo importante es comenzar aunque sea con algo simple y desde ahí seguir. Al menos les dejo algunas recomendaciones de nomenclatura para que no caigan en algunos de los errores que ya cometí al comenzar, y que cometan otros distintos.


Esto no termina aquí, se puede ir al siguiente nivel que es el uso de Integración Continua para la ejecución de los tests y más, pero eso lo dejo ya para otro artículo :-)


Hasta la próxima!

Artículos Relacionados y de interés:
What Makes a Good Programmer?



2 comentarios:

  1. Muy bueno tu Articulo... muy claro!! Muchas gracias.

    ResponderEliminar
  2. Excelente lo que has escrito y recomendado. Es una técnica muy superior y está dada para programadores idóneos y expertos. Muy buen artículo.
    Soy o creo ser programador de muchos años. Este artículo reconforta y te hace pensar muchos en los paradigmas. Muy buena experiencia casi todos caemos en lo mismo, creemos que somos buenos y dónde más fallamos son en las "eventualidades", aquellas que difícilmente ocurren, pero siempre, siempre ocurren.
    Muchas gracias por tu experiencia.

    ResponderEliminar