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 

Documentos relacionados