TDD – Test Driven Development
Transcripción
TDD – Test Driven Development
TDD – Test Driven Development - Qué es eso? El TDD, o Test Driven Development, o Desarrollo guiado por pruebas, es una técnica de desarrollo de software sumamente ágil, simple de implementar, y enormemente efectiva si se aplica en forma correcta, y siguiendo rigurosamente sus lineamientos y directivas. Se aplica a diversos aspectos del desarrollo de software, pero uno de los más importantes es la programación orientada a objetos. El TDD se basa en hacer las cosas al revés de lo habitual: en vez de programar primero, y probar lo programado después, el TDD indica que se deben preparar primero las pruebas que haremos a nuestro programa, y recién entonces programar lo necesario para que se pasen esas pruebas. Es decir que la mecánica se basa en plantear, lo más abarcativamente posible, la batería de tests que realizaremos sobre nuestro código, para estar completamente seguros de que funcione, y entonces, solo entonces, y jamás antes, empezar a desarrollar, por pasos muy chiquitos, cada uno de los aspectos de ese código, para ir logrando que las pruebas resulten exitosas. - Batería de tests? El TDD funciona, obligatoriamente, con un conjunto prolijo, ordenado y abarcativo, de pruebas unitarias, o Unit Tests. Recordemos que las UT (Unit Tests) son pruebas que se realizan a un software, enumeradas de forma tal que, de pasar por todas ellas, podemos asegurar que ese requisito está perfectamente desarrollado. Dicho en otras palabras: podemos confirmar que un software funciona, siempre y cuando se haya confirmado que ha pasado por todas las pruebas a las que se lo ha sometido. Justamente por eso, es fundamental asegurarnos que estamos sometiendo al software a un espectro de pruebas tales que toda su funcionalidad está siendo testeada. - Ejemplos, por favor. Empecemos con un ejemplo muy muy simple: se nos pide desarrollar una clase llamada “Matemáticas”, con una única función static, llamada “Sumar”, que reciba dos integer y devuelva el resultado de sumarlos. Mas pavote, imposible. Lets go. 1 Sabemos el nombre de la clase a programar, sabemos el nombre de la función que debemos desarrollar, sabemos qué tipo de valor debe devolver, y sabemos qué parámetros recibe. Empezamos a programar entonces? NO!!! Empezamos por las pruebas que vamos a hacerle a nuestro software. Primer paso: determinar las pruebas El primer paso consiste en determinar, con total precisión, y en forma completamente abarcativa, las pruebas a las que someteremos a nuestro software, para poder confirmar que anda perfectamente. En este caso, siendo que se trata de un software extremadamente simple, ya que solo suma dos números, esta batería de test será reducida. Sin embargo, la lista no tendrá uno o dos elementos, sino varios. Además, es fundamental que incluyamos pruebas por negativa, es decir, cosas que NO deben pasar. Por ejemplo: - Si le decimos que sume 5 y 8 debe dar 13. - Si le decimos que sume 4 y 6 NO debe dar 20. No solamente queremos verificar que haga lo que queremos que haga, sino también que NO haga lo que NO queremos que haga. Entonces, mi lista de pruebas, escritas en castellano (más adelante serán traducidas a código), podría ser: - 2+3=5 - 6+6=12 - 28+0=28 - 0+42=42 - 65+(-15)=50 - (-5)+(-5)=(-10) - (-15)+10=(-5) - (-40)+(-8)=(-48) - 0+0=0 - 6+1≠10 5+5≠20 21+0≠16 0+51≠34 51+(-22)≠17 (-9)+(-9)≠(-14) (-32)+12≠(-11) (-21)+(-6)≠(-17) 0+0≠11 2 - Eeeeehhhhh!!! 18 pruebas para verificar una simple suma de dos números? No será mucho??? Si, seguramente sea mucho. Seguramente hay muchas pruebas redundantes. No olvidemos que estamos probando un caso extremadamente simple. Pero analicemos esas pruebas que enumeré. No son ejemplos al azar. Tienen una cierta lógica. Por un lado, hay dos grupos: el primero, de sumas que deben dar un cierto resultado. El segundo, de sumas que NO deben dar un cierto resultado. A su vez, dentro de cada grupo, cubrí distintos aspectos de una suma. En cada caso, estoy sumando: 1) Dos positivos distintos 2) Dos positivos iguales 3) Un positivo y cero 4) Cero y un positivo 5) Un positivo y un negativo 6) Dos negativos iguales 7) Un negativo y un positivo 8) Dos negativos distintos 9) Cero y cero Es decir que NO estoy escribiendo lo mismo muchas veces, sino que estoy verificando distintos aspectos de una suma. Estoy cubriendo todas las posibilidades. Repito una vez más otra vez de nuevo: estamos analizando un ejemplo demasiado simple. En el segundo grupo cubro los mismos aspectos, pero ahora indicando el resultado que NO espero obtener. Ahora, recién ahora, puedo dar por terminado el primer paso. Resumiendo - Primer paso: determinar todas las pruebas necesarias para confirmar que el software anda. Primer paso: finalizado! Segundo paso: escribir el prototipo de la función. El prototipo de la función es una función que se llame exactamente como se debe llamar la función, reciba los parámetros tal como debe recibirlos (tanto en cantidad, como en orden, como en tipo), y devuelva un dato del tipo adecuado. Peeeeero, es una función vacía, que no hace nada, que no anda. Solo devuelve un dato cualquiera del tipo adecuado, pero nada mas. No tiene nada programado, solo devuelve un dato. Eso es el prototipo de una función: su cáscara, su esqueleto, su estructura, pero sin su contenido. 3 Listo. Esto compila. No funciona, obviamente, porque no suma números, pero de momento, no nos importa. Solo nos importa que compile. De hecho, es fundamental que, en este primer paso, la función NO esté programada. Por mas simple que sea programarla, NO debemos hacerlo ahora. Alguien podrá decirme: “Pero, che, si reemplazás el return Numero1+Numero2; ya tenés la función completamente terminada!”. 0; por un return Si, es cierto. Pero hay dos cuestiones: primero, en este caso particular, eso es así porque, deliberadamente, elegimos un ejemplo extremadamente simple, de una función cuya resolución de código es tan corta como una única línea de código. Habitualmente, eso no ocurre. El segundo, y más importante motivo, es que el TDD dice, explícitamente, que para cumplir con sus lineamientos, no se debe programar ni media línea de código antes de haber escrito todas las pruebas necesarias para testear ese código una vez que esté escrito. Y, por ahora, no las escribimos. Por lo tanto, obedientemente, no vamos a escribir ni media línea de código ahora. Lo haremos más luego. Cumple con lo que se pide de este paso: lo que programamos tiene el nombre que tiene que tener, recibe los parámetros que debe recibir, devuelve un valor del tipo que debe devolver y no tiene NADA programado dentro. Segundo paso: Done! Tercer paso: escribir una UT que opere, pero falle. El tercer paso es escribir una UT (Unit Test) que, estemos seguros, va a fallar. Es decir, una UT que pueda ejecutar la clase, probarla, e informar que NO funciona. 4 Hecha nuestra primera UT, la ejecutamos. Obviamente, nos encontramos con este resultado: El Test Explorer nos dice que pudo ejecutar la prueba, pero falló, ya que esperaba que obtener un 5, y obtuvo un 0. Hasta ahora, vamos bien. Cumplimos con lo que se nos pide en este paso: escribir una UT que opere correctamente, y confirme que la clase NO anda. Tercer paso: adentro! Cuarto paso: programar Para el cuarto paso, recién ahora, y no antes, vamos a programar en serio. Hasta ahora, no programamos. Hasta ahora, no hicimos nada que requiera pensar. Recién ahora encendemos motores y reemplazamos el código de la función por algo “en serio”. Pero ojo, algo muy importante: vamos programar únicamente lo mínimo necesario para que las UT escritas hasta ahora funcionen. NO vamos a programar la clase entera. SOLO vamos a lograr que las UT que llegamos a enumerar, den verde. 5 En nuestro ejemplo, la resolución es muy sencilla. situaciones más complicadas. En ejemplos posteriores, veremos Resumen - Cuarto paso: programar la funcionalidad real de la función. Cuarto paso: yeah! Quinto paso: volver a ejecutar la UT que antes fallaba, y verificar que ahora “de verde” Bien. Volvemos a ejecutar las pruebas y vemos que ahora obtenemos verde. Sexto paso: agregar cada una de las UT restantes y repetir Terminamos? No, ni ahí. Falta algo importantísimo. Volvamos un par de casilleros para atrás. Volvamos al punto en que reemplazamos el “Return 0;” por un “Return Numero1+Numero2;” (el código no es exactamente así, pero ustedes entienden). Qué hubiera pasado si hacíamos un “Return 5;”? Qué hubiera pasado, ehhh??? 6 Hubiera pasado algo terrible: nuestra Unit Test hubiera dado verde, indicando que todo anda bien. Y nosotros hubiéramos apagado la compu e ido a jugar a la playstation, felices y contentos, convencidos que nuestro trabajo fue maravilloso, porque las UT dijeron que anda. Por este motivo, justamente, para evitar eso, es que dedicamos tiempo, esfuerzo y concentración, en el primer paso, a elaborar una lista completa y taxativa de todas las pruebas que queremos hacerle a nuestro código, para poder confirmar que anda bien. Esa lista, de la que en el primer paso nos burlamos, porque nos parecía demasiado extensa para verificar una simple suma de números (y tal vez lo fuera) es la que puede confirmar que esto anda. Pues bien, vamos a implementar entonces todos los Asserts, tomándolos tal cual fueron enumerados de esa lista. No cometamos el error de inventar, de cambiar el orden, o de excluir algunos de la lista. Esa lista la pensamos con muchísimo cuidado, así que es fundamental que la respetemos. Ahora me gusta más. Ahora si podemos decir que probamos la clase para TODOS los casos posibles y anda. Entonces, resumamos los pasos: 7 1) Determinar, con total precisión, en forma completamente abarcativa y taxativa, la batería de tests que haremos a nuestro código para poder confirmar que anda. 2) Escribir el prototipo de la función. Fundamental: que sea el menor código posible, para que compile, pero que no gastemos ni un cuarto de neurona en programar nada con lógica o inteligencia. 3) Escribir una UT, con un único Assert, que opere correctamente sobre la función, y obviamente, informe que falla, es decir “de rojo”. 4) Programar correctamente la función, pero con lo mínimo necesario para que la UT escrita en 3) de verde. Ni media línea de código adicional. 5) Probar la UT definida en 3) y confirmar que da verde. 6) Agregar una UT adicional (o un Assert a la misma UT), de las previamente determinadas, y verificar que de verde. a. Si da verde, probar TODAS las UT escritas hasta ahora, y verificar que TODAS den verde. i. Si TODAS dan verde, volver a ejecutar el paso 3), agregando una nueva UT, hasta haber agregado TODAS las UT previamente determinadas. ii. Si alguna UT da rojo, volver al paso 4, corrigiendo el código para que la UT en cuestión de verde, y volver al paso 6) a., verificando que TODAS las UT den verde. b. Si da rojo, volver al paso 4, corrigiendo el código para que la UT en cuestión de verde, y volver al paso 6) a., verificando que la UT agregada de verde, y luego, que TODAS las UT den verde Solo cuando todas las UT previamente escritas den verde, podemos darnos por satisfechos, y ahora si, irnos a jugar a la playstation. Acá tenemos un diagrama muy monono que nos ayudará a recordar la secuencia, e ir siguiéndola con precisión: 8 La función Sumar es sumamente simple. Vamos a ver ahora un caso alguito más complejo. Vamos a usar como ejemplo el mismo que usamos en el tutorial de UT: una clase que maneja pilas. Como ese tutorial solo pretendía enseñar a hacer UT, la clase Pila ya estaba creada. Entonces, ahora, vamos a programar nosotros la clase Pila, usando la metodología TDD. Nuestra clase Pila tendrá dos atributos: - EstaVacia : Bool - CantidadElementos : Int Tendrá un método: - AgregarElemento (String) Y tendrá una función: 9 - ObtenerElemento ( ) : String Primer paso: determinar las pruebas Ya las teníamos, así que las podemos tomar como estaban. En todos los casos, la pruebas se hacen sobre una pila recién creada EmpiezaVacia ! La pila debe estar vacía ! La pila debe tener cero elementos ! La pila NO debe tener 5 elementos AgregarYNoVacia - Agregar un elemento ! La pila debe NO estar vacía ! La pila debe tener 1 elemento ! La pila NO debe tener 5 elementos AgregarSacarYVacia - Agregar un elemento - Sacar un elemento ! La pila debe estar vacía ! La pila debe tener cero elementos ! La pila NO debe tener 5 elementos AgregarSacarIgualAlAgregado - Agregar un elemento - Sacar un elemento ! El elemento sacado debe ser igual al agregado ! El elemento sacado NO debe ser igual a un elemento no agregado ! La pila debe estar vacía ! La pila debe tener cero elementos ! La pila NO debe tener 5 elementos AgregarSacarTresVerificarOrden - Agregar tres elementos - Sacar tres elementos ! El primer elemento sacado debe ser igual al tercer elemento agregado ! El segundo elemento sacado debe ser igual al segundo elemento agregado ! El tercer elemento sacado debe ser igual al primer elemento agregado ! La pila debe estar vacía ! La pila debe tener cero elementos ! La pila NO debe tener 5 elementos SacarYError 10 - Sacar un elemento ! Debe dispararse una excepción, porque no se puede sacar un elemento de una pila vacía. Tenemos la lista de todas las pruebas que vamos a hacer. Primer paso, listo. Segundo paso: programar el prototipo de la clase Vamos a escribir, el menor código posible, para que la clase compile, los nombres de atributos, métodos y funciones existan, los parámetros se puedan recibir y los valores se puedan devolver, pero no haga nada más que eso. Para lograr esto, tendremos una mano en el teclado, y otra mano en esto (alguien debería ayudarnos con el mouse, pero eso es otro tema): - EstaVacia : Bool - CantidadElementos : Int - AgregarElemento (String) - ObtenerElemento ( ) : String Helo aquí: 11 Tal como se pide, compila, y cumple con las especificaciones. Esto es, exactamente, el prototipo de la clase, con su atributos, métodos y funciones, perfectamente definidos, aunque vacíos. Segundo paso, listo. Tercer paso: escribir una primer UT que opere correctamente, pero obtenga rojo 12 Perfecto. Esta UT sirve para empezar. Pero, pasa algo: si la probamos, no obtenemos rojo. Por qué? Porque, de casualidad, los valores “cualquiera” que elegimos, cumplen. Es decir, el return true que pusimos en el get del atributo EstaVacia, y el return 0 que pusimos en el get del atributo CantidadElementos, son, de casualidad, los valores correctos para esta situación. Qué hacemos entonces? Cambiamos esos valores para que la UT falle? No. Esta situación se da muy pocas veces, pero se da. Si la función prototipada anda para la primera UT que hicimos, entonces, simplemente, agregamos otra UT, hasta que alguna falle. En nuestro ejemplo, no habrá que agregar mucho. La segunda UT ya dará rojo. 13 Notemos aquí algo interesante: el segundo Assert de este Test Method también debió dar rojo, ya que CantidadElementos NO devuelve 1, sino 0. Sin embargo, el Test Explorer, no lo mencionó. - Por qué? Porque al llegar al primer Assert que falla, la prueba no sigue, ya que no tiene sentido. Para que la UT de verde, se requiere que TODOS los Asserts sean exitosos. Ergo, con uno que falla, no es necesario seguir. Entonces, ahora si, terminamos el tercer paso. Cuarto paso: programar para que los UT que hicimos hasta ahora den verde. Primero que nada, declaramos una variable privada, global de la clase, para almacenar los elementos. En el constructor de la clase, la inicializamos. Modificamos el atributo EstaVacia para que devuelva true si la cantidad de elementos es cero, o false en caso contrario. 14 - Alto! Qué es esa cosa rara en ese paréntesis después del return? En realidad, podríamos haber escrito un If normal, que preguntara si _DatosDeLaPila.Count es 0. Si es, devuelve true. Si no, devuelve false. Pues bien, ese paréntesis es una expresión de tipo Boolean, ya que el valor de _DatosDeLaPila.Count == 0 valdrá true o false, según el caso. Por lo tanto, al poner: return (_DatosDeLaPila.Count==0); estoy forzando al código a que, primero, resuelva el valor del paréntesis, comparando _DatosDeLaPila.Count con 0, y generando un true o un false según sea el caso. Y luego, retornando ese valor. Modificamos el atributo CantidadElementos para que devuelva la cantidad real Y modificamos el método AgregarElemento para que almacene el elemento enviado. Cuarto paso, listo. Quinto paso: verificar que todas las UT hasta ahora den verde. 15 Volvemos a ejecutar la UT que agregamos recién, y luego todas las que ya tenemos, y vemos que ahora obtenemos todo verde. Quinto paso, listo. Sexto paso: agregar una nueva UT, y recomenzar el ciclo. Obtenemos rojo. A meter mano en el código nuevamente, para que esa UT de verde, y todas las anteriores también. En este caso, tocamos el código de ObtenerElemento. 16 Qué es lo que hago? Primero, obtengo el último elemento de la pila, es decir, el elemento cuyo índice sea la cantidad de elementos menos 1. Recordemos que el primer elemento es el cero. Entonces, si la pila tiene 5 elementos, el último es el que tiene el índice 4. Después, elimino ese último elemento de la pila, y la pila pasa a tener una dimensión menor. Ahora si, todo verde. Hay mas UT? Si, hay. Codificamos otra. La cuarta UT. La probamos, primero solita, y luego con todas. Obtenemos todo verde. 17 Seguimos iterando: hay alguna UT sin probar? Si? La codificamos. La probamos, primero sola, y luego con todas. Seguimos en todo verde. Hay mas UT? Si, una mas. La codificamos. La ejecutamos, primero sola, y ya obtenemos rojo. 18 Según las directivas del TDD, deberíamos ahora corregir nuestro código, para que la UT de verde. Lo hacemos. 19 Nuestro código ahora está corregido, ya que agregamos una validación para cuando no hay elementos en la pila. Sin embargo, la UT sigue dando rojo. Por qué? Qué hicimos mal? Hicimos algo muy peligroso: codificamos mal la UT. Nosotros escribimos las UT de forma que si dan verde es porque el software a testear está bien. Por lo tanto, asumimos que un rojo indica que el software a testear está mal, y vamos a pasarnos horas, días y semanas hasta lograr que la UT nos de verde. 20 Pero, qué pasa si, por error, codificamos mal la UT? Qué pasa si hacemos esto? Assert.AreEqual(5, Matematicas.Sumar(3, 4)); Vamos a obtener un rojo, vamos a pasar horas revisando el “complejísimo” código de la función Sumar de la clase Matematicas, y jamás vamos a encontrar un error ahí, porque el error no está ahí. Entonces, agreguemos una directiva: es fundamental que las UT estén perfectamente codificadas, porque, de no estarlo, vamos a perder el resto de nuestra vida buscando un error que jamás encontraremos, porque no existe. Entonces, nos damos un golpecito en la frente diciendo “Pero que p……!!!” (completar a gusto), y corregimos la UT: Ahora si, con la UT correctamente codificada, la ejecutamos, y ejecutamos todas. Nos queda alguna UT? No? Ninguna? Ni una solita? Entonces ahora, recién ahora, podemos decir que nuestro software está perfect…NO, NO, ALTO, ALTO, paren todo, falta algo!!! - Qué cosa? No era que una vez que todas las UT den verde podemos decir que el software anda? Si, podemos decir eso. Pero NO podemos decir si está bien programado. Por ejemplo, podría pasar que hayamos recorrido 10 veces un vector para obtener un dato, habiendo podido recorrerlo menos. Podría pasar que hayamos declarado variables de más, que se pueden evitar. Podría pasar que hayamos ejecutado muchas conexiones a una base de datos, pudiendo ejecutar menos. 21 Es decir, hay muchas cosas de nuestro código que podemos mejorar u optimizar, manteniendo la funcionalidad del mismo, pero mejorando su performance. La mayoría de esas cuestiones deben ser resueltas por nosotros, los programadores humanos. No hay forma que el Visual Studio (o cualquier otro entorno de desarrollo) nos diga que podíamos haber evitado declarar variables. Entonces, debemos revisar el código, y hacerle un proceso llamado “Refactoring”. Refactorizar el código significa mejorarlo, limpiarlo, emprolijarlo y optimizarlo, pero manteniendo la misma funcionalidad. Es decir que, gracias a los UT, podemos cambiar el código, con la tranquilidad que no estamos rompiendo nada: cambiamos algo y verificamos que sigamos teniendo todo verde. Cambiamos otra cosa, y volvemos a verificar, y así. Pero hay algo en lo que el Visual Studio SI puede ayudarnos, y es a encontrar fragmentos de código que no se usan. Es decir, fragmentos de código que no han sido ejecutados al realizar todas las UT. Si esto ocurre, hay dos posibilidades: o bien nuestras UT fueron incompletas, ya que hay cierta funcionalidad de nuestro software que NO probamos, o bien nuestras UT están perfectas, y nuestro software tiene código de más, innecesario. Ambas situaciones son problemáticas: si nos olvidamos de probar algo, quiere decir que nuestras UT no fueron completamente abarcativas, y debemos replantearlas. En cambio, si nuestro software tiene código de más, la situación es menos grave, pero requiere también atención. Empecemos por pedirle a Visual Studio que tenga la gentileza de indicarnos qué código no fue alcanzado por ninguna prueba. Para eso, vamos a ejecutar un proceso llamado Analize Code Coverage. Este proceso ejecutará todas las UT, y determinará qué fragmento de código fue utilizado para su ejecución, y cuál no. Luego de ejecutar el proceso, abrimos la ventana Code Coverage Results, y vemos: 22 Vamos abriendo el árbol de jerarquía, desde el nombre del usuario y computadora que en se ejecutó. Luego, aparece el compilado DLL que contiene nuestra clase. Luego el Namespace, y finalmente, la clase en si. En esa primera columna vemos los nombres de cada uno de los métodos y funciones de la clase. La segunda columna es la que más nos interesa: nos indica la cantidad de bloques de código que NO fueron utilizados por las pruebas. En este caso, nuestra clase Pila tiene dos. Para ver cuales son, clickeamos en Show Code Coverage Coloring. Y cuando vamos al código, vemos claramente indicado el código que nunca ha sido usado Entonces, qué pasó? Hicimos UT de menos, o escribimos código de más? 23 Luego de revisar, llegamos a la conclusión que ese Set no sirve para nada, ya que nunca vamos a asignarle nada a ese atributo. Lo mismo ocurre con el Set de EstaVacia. Entonces, borramos ambos fragmentos de código, y volvemos a ejecutar el Analize Code Coverage. Bien! El analizador ahora nos informa que no hay ni un solo bloque de código sin ejecutar. Dicho en otras palabras, TODO nuestro código fue testeado, al menos, una vez. Y siendo que tenemos todos los UT en verde, entonces, ahora si, recién ahora, y no antes, podemos decir: Desarrollo del software: terminado! 24