INTRODUCCIÓN
“Diseño Ágil con TDD”, por Carlos Ble, es un libro muy interesante que nos enseña como implementar Test-Driven Development en el desarrollo de código. Muestra como basar nuestro código en los Test que escribimos, y no al revés. A continuación comparto mis experiencias leyendo éste libro.
¿QUE BENEFICIOS NOS APORTA?
Se presentan grandes beneficios de codificar de ésta forma. Se habla de conseguir un código simple, que haga lo que necesitamos para cada momento, que cuando falle nos de un correcto y constante feedback de porqué eso está ocurriendo, así como un código legible y fácil de mantener. Obliga a los programadores a pensar primero en cual es la solución que quieren conseguir antes de comenzar a implementarla, y como consecuencia llegar a manejar diversas formas de conseguir lo que se desea teniendo en cuenta más casos de uso
Un programador debería tener siempre el control del código, no tiene porque llevarse sorpresas. Cuando dedicamos tiempo a desarrollar pruebas nos estamos asegurando de que vamos a crear un código que implementa lo que cada caso de prueba requiere. Saber cuando va a fallar y por qué va a fallar nos da un control sobre el código que lo vuelve mantenible y mejorable. Incluso cuando nos esperamos un fallo en la aplicación, una prueba realizada con anterioridad nos puede estar diciendo que está fallando y por qué, por lo que nunca perdemos el sentido de lo que hace nuestra aplicación.
El ciclo de crear un test aun sin implementar código para verlo fallar (rojo), desarrollar el código para ese caso de prueba, y finalmente verlo pasar la prueba (verde) nos hace tener ese control de que sabemos lo que están haciendo nuestros métodos en todo momento. Cuando sabemos aplicar ésto y entedemos el porqué añadir pruebas a nuestros proyectos, tenemos un código mantenible, ese que facilita de la vida al programador cuando tiene que arreglar fallos en la aplicación. Por otro lado implementar TDD también comprende los ciclos de refactorización. Tras ver nuestra prueba en verde, sabemos que tenemos un código correcto y funcional, por lo que es el momento perfecto para tratar de transformar el código a uno más simple y legible. No se trata de cambiar la funcionalidad del código, ya que hemos visto que es correcta, se trata de que haga lo mismo pero con una presentación más amigable, que cuando se lea parezca que cuenta una historia fácil de seguir.
Un punto positivo de aplicar TDD es que vamos implementando sólo que necesitamos en cada momento, por lo que evitamos añadir funcionalidades “extra” que no son necesarios para el estado actual de la aplicación, una práctica que llegar a complicar mucho el código así como su compresión y el control sobre él.
¿COMO DEBEMOS TESTEAR?
Para nuestros test usamos BDD (Desarrollo guiado por el comportamiento), que nos dice que nuestro test debe seguir el patrón Given-When-Then(Preparación, ejecución y validación):
- Given → Dado unos elementos…
- When → …realiza ésto…
- Then → …y espero que ocurra lo siguiente.
Gracias a BDD, nuestros test tienen una estructura clara, sencilla y concreta, cuentan una historia. Una persona que no sepa programar debería entender que pretende hacer el test, por lo que buscamos el menor numero de líneas posibles en cada bloque. Para lograr que nuestro bloque ‘Then’ compruebe varios factores pero que aparezca en una sola línea, podemos crear nuestra propia aserción:
/*Llamada*/
assertThatList(list).isExactly(10, 20)
/*Implementación*/
fun assertThatList(list: List<Number>) : ListMatchers {
return ListMatchers(list)
}
class ListMatchers(val actualList: List<Number>) {
fun isExactly(vararg items: Number){
assertThat(items.size).isEqualTo(actualList.size)
for(i in items.indices){
assertThat(items[i]).isEqualTo(actualList[i])
}
}
}
Los nombres de los test deben tener un nombre significativo que puede formar un juego de palabras con el nombre de la clase para formar una frase con sentido y que aclare la intención del test. Deben ser más abstractos que su contenido, esto es no dar detalle de la implementación.
Tenemos la opción de crear un método decorado con la anotación @Before, que significa que el framework ejecutará ese método justo antes de cada uno de los test. Si hay N test, se ejecutará
N veces. Está pensado así para garantizar que los test no se afecten unos a los otros.Cuando recurrimos a debug para encontrar un error en nuestro test, estamos perdiendo el control del flujo del programa, por lo que la mejor solución es buscar una solución alternativa a lo que estamos haciendo que si comprendamos y que no de lugar a errores inesperados.En la misión de refactorizar nuestro gran aliado son los IDE, que realizan de forma automática las transformaciones como extraer variables, y que el punto fuerte de todo esto es evitar el error humano. No que es que sea una simple ayuda realizar refactorización automática, es que es lo aconsejable, ya que nos garantiza que un cambio realizado en una clase se efectuará en los demás sitios donde esté referenciado, algo fundamental para romper nuestro código.Por mucha cobertura de código que obtengamos realizando test a nuestras líneas de código, no nos garantiza una efectividad del 100%. Para solventar ésto, el testeo automático se debe complementar con testeo exploratorio, ese tipo de pruebas que realizamos manualmente y que prueban la aplicación por completo como si se tratase del usuario final. Cuando aplicamos TDD no buscamos reeemplazar las pruebas manuales, solo tratamos de tener que hacerlo lo menor posible cuando algo se puede automatizar facilmente para así no perder tiempo y dinero en algo repetitivo.
Incluso en el testeo exploratorio se pueden automatizar algunas partes. Para lograr ésto tenemos los test basado en propiedades, que no definen datos concretos, si no que en su lugar se definen propiedades o casos que debe cumplir el código que se va a probar, como el caso de que el resultado de una suma debe ser mayor que cualquiera de sus sumandos. Las herramientas que ofrecen este tipo de automatización generan un gran número de combinaciones complejas con el fin de probar todos los posibles caminos llegando a casos extremos en los que un programador no puede estar pensando.
PRINCIPIOS NECESARIOS
La premisa de la prioridad de transformación (TPP) es un factor fundamental para realizar TDD, y nos habla de implementar el código más sencillo para un test que está en rojo, escribir código solo para que dicho test pase, sin generalizar para otros casos. El porqué hacerlo así se debe a que te obliga a ir pensando todos los casos e ir implementando poco a poco lo que necesites, sin tener que añadir más de lo que puedes llegar a necesitar, o de añadir cosas que no comprendes porque todavía no has elaborado un caso de prueba para cierta funcionalidad. Cada test en verde debe producir una generalización en el código para otro caso de prueba nuevo. La clave para entender bien éste concepto es ver que un problema se descompone a su vez en muchos subproblemas que son los que vamos a ir resolviendo poco a poco, desde lo más sencillo, a lo más complicado.
El principio de menor sorpresa, nos cuenta que el código debe hacer en todo momento lo que espera que haga, esto es, una función debe hacer lo que intuitivamente se espera al interpretar su nombre, parámetros, etc. Sin tener que entrar a ver como está implementada se debe tener la idea de que es lo que hace. Si una función hace lo que su nombre indica pero además otras cosas por detrás que no van a acorde con la misión de dicha función, es un código que genera sorpresas y que no deseamos tener. Para evitar que una función realice más cosas de las que le corresponden podemos recurrir a la separación de responsabilidades, llevando tal comportamiento a otra función que realice solo eso. Además, es importante tener el mismo nivel de abstracción en toda la función para que ningún bloque parezca mucho más complicado que otro visualmente.
/*ANTES*/
r.wrap();
s.getText().concat("! is the best.");
/*DESPUÉS*/
r.wrap();
s.append("! is the best.");
Para aplicar TPP, nos sirven de ayuda las trasformaciones que propone Robert C. Martin:
- {} –> nil: De no haber código a devolver nulo.
- nil -> constant: De nulo a devolver un valor literal.
- constant -> constant+: De un valor literal simple a uno más complejo.
- constant -> scalar: De un valor literal a una variable.
- statement -> statements: Añadir más líneas de código sin condicionales.
- unconditional -> if: Introducir un condicional
- scalar -> array: De variable simple a colección.
- array -> container: De colección a contenedor.
- statement -> recursion: Introducir recursión.
- if -> while: Convertir condicional en bucle.
- expression -> function: Reemplazar expresión con llamada a función.
- variable -> assignment: Mutar el valor de una variable.
Algo que puede llegar a resultar chocante para un programador es que le digan que para trabajar correctamente con TDD debe dejar de pensar primero como un programador. ¿Entonces como debe actuar?, es sencillo, anteriormente he explicado que TDD nos hace ir paso a paso en el desarrollo de código para que así podamos pensar al detalle cada posible caso de prueba de la aplicación, entonces podemos decir que el programador que aplica TDD en su modus operandi siempre piensa primero como un analista de negocio que interpreta primero los posibles casos o reglas de negocio, y no como un programador que solo busca codificar cuanto antes la solución sin atender a pensar un poco como llegará hasta ahí.
Para ilustrar mejor como sería aplicar TDD desde 0 podemos ver un pequeño ejemplo de una función que comprueba si una contraseña dada se considera fuerte o segura.
/*EL CASO MÁS SIMPLE*/
describe('The password strength validator', () => {
it('considers a password to be strong when all requirements are met', () => {
expect(isStrongPassword("1234abcdABCD_")).toBe(true);
});
});
/*Y SU SOLUCIÓN PARA LLEGAR AL VERDE*/
function isStrongPassword(password){
return true;
}
/******************************************************************************/
/*UN CASO MÁS GENERAL*/
it('fails when the password is too short', () => {
expect(isStrongPassword("1aA_")).toBe(false);
});
/*Y SU SOLUCIÓN PARA LLEGAR AL VERDE*/
function isStrongPassword(password){
return password.length >= 6;
}
Podemos ver como primero se contempló el caso más obvio el más simple, que no es otro que la contraseña cumpla todos los requisitos y no necesitamos más que devolver un true para que ésto sea verdad y pase. Más adelante ya se referencia un caso en el que la contraseña es muy corta y ya necesitamos hacer una comprobación para eso, por lo que aplicamos el mínimo código para comprobar si la contraseña pasada cumple la longitud requerida o no.
TÉCNICAS DE TESTEO
Pongamos el caso de que tenemos una función que queremos testear, pero ésta función llama a otra función. Nosotros queremos comprobar que la primera función funciona, por lo que la llamada a la segunda función simplemente está ahí porque forma parte de la implementación. En un caso en el que ambas funciones realizan algo sencillo no debería importar demasiado, pero si la segunda función realice un comportamiento complejo que requiera de un tiempo de ejecucción considerable no nos interesa que nuestro test gaste fuerzas en cosas que ya están testadas por otro lado. Cuando realizamos pruebas, podemos recurrir a simular objetos o funciones (Mocks), es decir, ese objeto lo tenemos disponible para usar en nuestro test pudiendo llamar a sus métodos, pero al ser una simulación no ejecutara los métodos realmente, por lo que si el método realizase llamadas a base de datos o interactuase con el sistema de archivos realmente no lo estaría haciendo. Además nos ofrece otras ventajas como son simular la entrada de parámetros, verificar que se llama una función, etc.
Los frameworks de mocks son ideales para poder introducir test en un código legado sin caer en el intento. Cuando nos encontramos con ése código de una clase que tiene 20000 líneas y que llama a muchas otras clases o funciones, podemos ir haciendo mock de lo que no necesitamos testear por el momento, por lo que si en un momento determinado solo queremos probar una clase o sus métodos la mejor opción es mockear y ver que simplemente pasa lo correcto a llamadas externas que realiza.
Para usar éstos Mocks en Java lo podemos realizar de la siguiente forma:
Para que nuestra clase de los test pueda usar los métodos de Mockito tengo que extender de su clase.Para mockear un objeto basta con usar el método mock.Tenemos para usar método de mockitos como ‘when’ con el cual podemos usar ‘any()’ para simular que se le pasó lo que ese método necesita, y si combinamos el ‘when’ con un ’thenReturn’ podemos comprobar que cuando se ejecuta devuelve lo que esperamos, o incluso un ’thenThrow’ para cuando lanza una excepción. Otro método interesante es ‘verify’ que comprobará que se llama a la función de un objeto.
@ExtendWith(MockitoExtension.class)
class RegisterVolunteerActionShould {
TemplateService templateService = mock(TemplateService.class);
EmailService emailService = mock(EmailService.class);
@Test
void send_confirmation_email(){
when(templateService.getEmailConfirmationTemplate(any())).thenReturn(new EmailTemplate(template));
verify(emailService).sendEmail(any());
}
}
También tenemos otro tipo de dobles que son los fakes, que bien puede ser una base de datos en memoria, repositorio en memoria, un servidor de correo que realmente no envía correo. Todo para que tengamos la funcionalidad más parecida o igual al artefacto real pero facilitándonos la vida en los test simplificando la forma en que hace las validaciones.
ERRORES TÍPICOS AL HACER TDD
Infravalorar el nombre de los test es una muy mala decisión. Pensar un buen nombre para nuestro test nos da un mayor conocimiento de lo que estamos haciendo y esperamos, además de que un test con un buen nombre que nada más leerlo ya sabes que debería estar haciendo, se convierte en documentación viva del proyecto para que no nos perdamos ni nosotros ni futuros desarrolladores que vean nuestro código.
Como tanto cuidamos sus nombres, debemos cuidar su presentación. Es fundamental ir aplicando refactor tras ver los verdes para tener test legibles y fáciles de mantener. El código de nuestros test es tan importante como el de producción.
Si tenemos un test en rojo está en rojo, no intentemos desviarnos ignorando que eso está ahí para no perder la eficacia de nuestra colección de test. Debemos arreglar ésto antes de seguir en el desarrollo del proyecto para garantizar que seguimos cubiertos en lo mayormente posible.
Nuestro test solo busca verificar que se cumple un solo comportamiento del sistema, por lo que intentar meter otro tipo de comportamiento es un error. Solo así entenderemos mejor cuando falle el por qué lo hace, y estaremos teniendo a la vez una documentación precisa de como va avanzando nuestro código.
No necesitamos complejidad ciclomática introduciendo bucles o condicionales en nuestros test, no queremos que nuestro test corra el riesgo de fallar por factores ajenos al comportamiento que está testando, así como no queremos un aumento de su complejidad.
CONCLUSIÓN
TDD es una forma de codificar, cada cual es libre de seguir su metodología preferida, pero los beneficios están ahí. Cuando nos acostumbramos a trabajar de ésta forma y empezamos a ver su gran utilidad podemos desarrollar un código honesto, ese código que no pretende ser perfecto (porque eso no existe en la programación), si no que pretende tener un constante feedback con el desarrollador y ser lo más simple posible. Aprender a usarlo en un día no es posible, es la práctica quien hará de nosotros unos buenos analizadores de código que se plantean primero los problemas antes de saltar al estilo kamikaze a la acción. Para empezar, la mejor forma es realizarlo con katas (ejercicios cortos de programación) que nos obliguen a pensar en su solución, pudiendo así practicar desde lo más básico de TDD. El mob programming es perfecto para aprender más rápido, vemos las posibles soluciones de otros, sus puntos de vista y los contrastamos con los nuestros para determinar cual será la mejor solución o incluso darnos cuenta de a veces hay muchos casos que no se nos pasan por la cabeza.