¿Qué es TDD y por qué debería importarme?

Test Driven Development es una metodología de desarrollo de software en la que se escriben tests para guiar la escritura del código de producción.

Los tests especifican de manera formal, ejecutable y mediante ejemplos, los comportamientos que debe realizar el software que estamos programando, definiendo pequeños objetivos que, al ser superados, nos permiten construir el software de forma progresiva, segura y estructurada.

Aunque hablemos de tests, no estamos hablando de Quality Assurance (en adelante: QA), aunque al trabajar con metodología TDD conseguimos el efecto secundario de hacernos con una suite de tests unitarios que es válida y que tiene la máxima cobertura posible. De hecho, lo normal es que una parte de los tests creados en TDD sean innecesarios para una batería comprensiva de tests de regresión, por lo que es habitual eliminarlos a medida que nuevos tests los convierten en redundantes.

Es decir: tanto TDD como QA se basan en la utilización de los tests como herramientas, pero este uso se diferencia en varios aspectos. Específicamente, en TDD:

  • El test se escribe antes de que el software que ejecuta siquiera exista.
  • Los tests son muy pequeños y su objetivo es forzar la escriture del código de producción mínimo necesario para que el test pase, que tiene el efecto de implementar el comportamiento definido por el test.
  • Los tests guían el desarrollo del código y el proceso contribuye al diseño del sistema.

En TDD los tests se definen como especificaciones ejecutables del comportamiento de la unidad de software considerada, mientras que en QA el test es una herramienta de verificación de ese mismo comportamiento. Expresado de manera más sencilla:

  • Cuando hacemos QA pretendemos comprobar que el software que hemos escrito se comporta según los requisitos definidos.
  • Cuando hacemos TDD escribimos software para que cumpla los requisitos definidos, uno por uno, de modo que terminamos con un producto que los cumple.

La metodología Test Driven Development

Aunque a lo largo del libro vamos a desarrollar este apartado en profundidad presentaremos brevemente lo esencial de la metodología.

En TDD los tests se escriben en una forma que podríamos considerar como de diálogo con el código de producción. Este diálogo, las normas que lo regulan y los ciclos que esta forma de interactuar con el código genera los practicaremos con la primera kata del libro: FizzBuzz.

Básicamente se trata de:

  • Escribir un test que falla
  • Escribir código que haga que el test pase
  • Mejorar la estructura del código (y del test)

Escribir un test que falle

Una vez que tenemos claro la pieza de software en la que vamos a trabajar y la funcionalidad que queremos implementar, lo primero es definir un primer test muy pequeño que fallará sin remedio porque ni siquiera existe un archivo que contenga el código de producción necesario para que se pueda ejecutar. Aunque es algo que trataremos en todas las katas, en la kata NIF profundizaremos en estrategias que nos servirán para decidir los primeros tests.

He aquí un ejemplo en Go:

 1 // roman/roman_test.go
 2 package roman
 3 
 4 import "testing"
 5 
 6 func TestRomanNumeralsConversion(t *testing.T) {
 7 	roman := decToRoman(1)
 8 
 9 	if roman != "I" {
10 		t.Errorf(
11 			"Decimal %d should convert to %s, but found %s",
12 			 1,
13 			 "I", 
14 			 roman
15 		 )
16 	}
17 }

Aunque podemos predecir que el test ni siquiera podrá compilarse o interpretarse, lo intentaremos ejecutar igualmente. En TDD es fundamental ver que los tests fallan, no basta con suponerlo. Nuestro trabajo es hacer que el test falle por la razón correcta y luego hacerlo pasar escribiendo código de producción.

1 # tddbook-go/roman [tddbook-go/roman.test]
2 ./roman_test.go:6:11: undefined: decToRoman
3 
4 Compilation finished with exit code 2

El mensaje de error nos indicará qué es lo que tenemos que hacer a continuación. Nuestro objetivo a corto plazo es hacer desaparecer ese mensaje de error y los que puedan venir después, uno por uno.

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s",
11 			1,
12 			"I",
13 			roman
14 		)
15 	}
16 }
17 
18 func decToRoman(decimal int) string {
19 	
20 }

Por ejemplo, al introducir la función decToRoman, el error cambiará. Ahora nos dice que debería devolver un valor:

1 # tddbook-go/roman [tddbook-go/roman.test]
2 ./roman_test.go:16:1: missing return at end of function
3 
4 Compilation finished with exit code 2

Podría ocurrir incluso que sea un mensaje inesperado, como que hemos querido cargar la clase Book y resulta que hemos creado un archivo brok por error. Por eso es tan importante lanzar el test y ver si falla y cómo falla exactamente.

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s",
11 			1,
12 			"I",
13 			roman
14 		)
15 	}
16 }
17 
18 func decToroman(decimal int) string {
19 	
20 }

Este código da lugar al siguiente mensaje:

1 # tddbook-go/roman [tddbook-go/roman.test]
2 ./roman_test.go:6:11: undefined: decToRoman
3 ./roman_test.go:16:1: missing return at end of function
4 
5 Compilation finished with exit code 2

Este error nos indica que hemos escrito incorrectamente el nombre de la función, así que primero lo corregimos:

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s", 
11 			1, 
12 			"I", 
13 			roman
14 		)
15 	}
16 }
17 
18 func decToRoman(decimal int) string {
19 	
20 }

Y podemos continuar. Como el test dice que espera que al pasar 1 a la función nos devuelva “I”, el test fallido debería indicarnos que no coincide el resultado recibido con el esperado. Pero, de momento, el test nos está diciendo que la función no devuelve nada. Todavía es un fallo de compilación y todavía no es la razón correcta para fallar.

1 # tddbook-go/roman [tddbook-go/roman.test]
2 ./roman_test.go:16:1: missing return at end of function
3 
4 Compilation finished with exit code 2

Para conseguir que el test falle por la razón que esperamos, tenemos que hacer que la función devuelva un string, aunque sea vacío:

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s",
11 			1, 
12 			"I", 
13 			roman
14 		)
15 	}
16 }
17 
18 func decToRoman(decimal int) string {
19 	return ""
20 }

Y este cambio hace que el error ahora sea uno relacionado con que el test no pasa, pues no obtiene el resultado que espera. Esta es la razón correcta para fallar, la que nos forzará a escribir código de producción que haga pasar el test:

1 === RUN   TestRomanNumeralsConversion
2 --- FAIL: TestRomanNumeralsConversion (0.00s)
3     roman_test.go:9: Decimal 1 should convert to I, but found 
4 FAIL
5 
6 Process finished with exit code 1

Y así estaríamos listas para dar el siguiente paso:

Escribir código que haga que el test pase

Como respuesta al resultado anterior, se escribe el código de producción necesario para que el test pase, pero nada más. Siguiendo con nuestro ejemplo:

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s", 
11 			1, 
12 			"I", 
13 			roman
14 		)
15 	}
16 }
17 
18 func decToRoman(decimal int) string {
19 	return "I"
20 }

Tras hacer pasar el primer test podemos empezar creando el archivo que contendrá la unidad bajo test. Podríamos incluso volver a lanzar el test ahora, lo cual seguramente provocará que el compilador o intérprete nos devuelva un mensaje de error distinto. Aquí ya dependemos un poco de circunstancias, como las convenciones del lenguaje en que estamos desarrollando, el IDE con el que trabajamos, etc.

En todo caso, se trata de ir dando pequeños pasos hasta que el compilador o intérprete quede conforme y pueda ejecutar el test. En principio, el test debería ejecutarse y fallar indicando que el resultado recibido de la unidad de software no coincide con el esperado.

En este punto hay que hacer una salvedad porque dependiendo del lenguaje, del framework y de algunas prácticas en testing, la forma concreta de este primer test puede ser un poco distinta. Por ejemplo, hay frameworks de test en los que basta con que la ejecución del test no arroje errores o excepciones para considerar que pasa, por lo que un test que simplemente instancia un objeto o invoca uno de sus métodos sería suficiente. En otros casos, es necesario que el test incluya una aserción y si no se hace ninguna considera que el test no pasa.

En cualquier caso, el objetivo de esta fase es lograr que el test se ejecute con éxito.

Con la kata Prime Factors estudiaremos el modo en que puede cambiar el código de producción para incorporar nueva funcionalidad.

Mejorar la estructura del código (y del test)

Cuando se ha logrado hacer pasar cada test debemos examinar el trabajo realizado hasta el momento y comprobar si es posible refactorizar tanto el código de producción como el de test. Aquí aplicamos los principios habituales: si detectamos cualquier smell, dificultad para entender lo que ocurre, duplicación de conocimiento, etc. debemos refactorizar el código para ponerlo en mejor estado antes de continuar.

En el fondo, las preguntas en este momento son:

  • ¿Hay alguna manera mejor de organizar el código que he escrito?
  • ¿Hay alguna manera mejor de expresar lo que este código hace y que sea más fácil de entender?
  • ¿Puedo encontrar alguna regularidad y hacer que el algoritmo sea más general?

Para ello debemos mantener todos los tests que hayamos escrito pasando. Si alguno de los tests se pone en rojo tendríamos una regresión y habríamos estropeado, por así decir, la funcionalidad ya creada.

Tras el primer ciclo es normal no encontrar muchas oportunidades de refactor, pero no te fíes: siempre hay otra manera de ver y hacer las cosas. Por regla general, cuanto antes detectes oportunidades de reorganizar y limpiar el código y lo hagas, más fácil será el desarrollo.

Por ejemplo, nosotros hemos creado la función bajo test en el mismo archivo del test.

 1 package roman
 2 
 3 import "testing"
 4 
 5 func TestRomanNumeralsConversion(t *testing.T) {
 6 	roman := decToRoman(1)
 7 
 8 	if roman != "I" {
 9 		t.Errorf(
10 			"Decimal %d should convert to %s, but found %s", 
11 			1, 
12 			"I", 
13 			roman
14 		)
15 	}
16 }
17 
18 func decToRoman(decimal int) string {
19 	return "I"
20 }

Resulta que hay una forma mejor de organizar ese código y es crear un archivo que contenga la función. De hecho, es una práctica recomendada en casi todos los lenguajes de programación. Sin embargo, al principio nos la podemos saltar.

1 //roman/roman.go
2 
3 package roman
4 
5 func decToRoman(decimal int) string {
6 	return "I"
7 }

Y, en el caso de Go, podemos convertirla en una función exportable si su nombre comienza con mayúsculas.

1 package roman
2 
3 func DecToRoman(decimal int) string {
4 	return "I"
5 }

Para profundizar en todo lo que tiene que ver con el refactor al trabajar tendremos la kata Bowling Game.

Repetir el ciclo hasta terminar

Una vez que el código de producción hace pasar el test y está lo mejor organizado posible en esa fase, es el turno de escoger otro aspecto de la funcionalidad y crear un nuevo test que falle para describirlo.

Este nuevo test falla porque el código existente no cubre la funcionalidad deseada y es necesario introducir un cambio. Por tanto, nuestra misión ahora es poner este nuevo test en verde haciendo las transformaciones necesarias en el código, las cuales serán pequeñas si hemos sabido dimensionar correctamente nuestros tests anteriores.

Tras conseguir que el nuevo test pase, buscamos las oportunidades de refactor para tener un mejor diseño del código. A medida que avancemos en el desarrollo de la pieza de software veremos que los refactors posibles van siendo más significativos.

En los primeros ciclos comenzaremos con cambios de nombres, extracción de constantes y variables, etc. Luego pasaremos a introducir métodos privados o extraer ciertos aspectos a funciones. En algún momento descubriremos la necesidad de extraer funcionalidad a clases colaboradoras, etc.

Cuando estemos satisfechas con el estado del código repetimos el ciclo mientras nos queda funcionalidad por añadir.

¿Cuándo termina el desarrollo en TDD?

La respuesta obvia podría ser: cuando toda la funcionalidad está implementada.

Pero, ¿cómo sabemos esto?

Kent Beck proponía hacer una lista con todos los aspectos que habría que conseguir para considerar completa la funcionalidad. Cada vez que se consigue alguno se tacha de la lista. A veces, al progresar en el desarrollo nos damos cuenta de la necesidad de añadir, quitar, o mover, elementos en la lista. Es una buena recomendación.

Existe una manera más formal de asegurarnos de que una funcionalidad está completa. Básicamente, consiste en no ser capaz de crear un nuevo test que falle. En efecto, si un algoritmo está completamente implementado será imposible crear un test nuevo que pueda fallar.

Qué no es Test Driven Development

El resultado o outcome de Test Driven Development no es crear un software libre de defectos, aunque se previenen muchos de ellos; ni generar una suite de tests unitarios, aunque en la práctica se obtiene una con gran cobertura que puede llegar al 100%, con la contrapartida de que puede presentar redundancia. Pero nada de esto es el objetivo de TDD, en todo caso es un efecto colateral ciertamente beneficioso.

TDD no es Quality Assurance

Aunque usamos las mismas herramientas (tests), las usamos para finalidades distintas. Los tests en TDD guían el desarrollo, estableciendo objetivos específicos para alcanzar añadiendo código o aplicando cambios en él. El resultado de TDD es una suite de tests que puede utilizarse en QA como tests de regresión, aunque es frecuente que tengamos que retocar esos tests de una manera u otra. En unos casos para eliminar tests redundantes y en otros para asegurar que las casuísticas están bien cubiertas.

En cualquier caso, TDD ayuda enormemente el proceso de QA porque previene muchos de los defectos más comunes y contribuye a construir un código bien estructurado y con bajo acoplamiento, aspectos que incrementan la fiabilidad del software, nuestra capacidad para intervenir en caso de errores e incluso la posibilidad de crear nuevos tests en un future.

TDD no reemplaza el diseño

TDD es una herramienta para contribuir al diseño de software, pero no lo reemplaza.

Cuando desarrollamos unidades pequeñas y con una funcionalidad muy bien definida, TDD nos ayuda a establecer el diseño del algoritmo gracias a la red de seguridad proporcionada por los tests que vamos creando.

Pero cuando la unidad considerada es mayor, un análisis previo que nos lleve a un “boceto” de los elementos principales de la solución nos permite tener un marco de desarrollo.

El enfoque outside-in intenta integrar el proceso de diseño en el desarrollo, usando lo que Sandro Mancuso etiqueta como Just-in-time design: partimos de una idea general de cómo se estructura y de cómo funcionará el sistema y diseñamos en el ámbito de la iteración en la que nos encontremos.

En qué nos ayuda TDD

Lo que TDD nos proporciona es una herramienta que:

  • Guía el desarrollo del software de una forma sistemática y progresiva.
  • Nos permite realizar afirmaciones contrastables sobre si la funcionalidad requerida ha sido implementada o no.
  • Nos ayuda a evitar la necesidad de diseñar todos los detalles de implementación anticipadamente, ya que en sí misma es una herramienta de ayuda al diseño de los componentes del software.
  • Nos permite posponer decisiones a varios niveles.
  • Nos permite centrarnos en problemas muy concretos, avanzando en pasos pequeños y fáciles de revertir si introducimos errores.

Beneficios

Varios estudios han mostrado evidencias que apuntan a favor de que la aplicación de TDD tiene beneficios en los equipos de desarrollo. No son evidencias concluyentes, pero las investigaciones realizadas tienden a coincidir en que con TDD:

  • Se escribe una mayor cantidad de tests
  • El software tiene menos defectos
  • La productividad no se ve disminuida, incluso puede aumentar

Es bastante difícil cuantificar el beneficio de usar TDD en cuanto a productividad o velocidad, sin embargo, subjetivamente se pueden experimentar varios beneficios.

Uno de ellos es que la metodología TDD puede bajar la carga cognitiva del desarrollo. Esto es así porque favorece dividir el problema en tareas pequeñas con un foco muy definido, lo que nos permite ahorrar la limitada capacidad de nuestra memoria de trabajo.

La evidencia anecdótica apunta a que las desarrolladoras y equipos que introducen TDD reducen los defectos, reducen el tiempo dedicado a bugs, aumentan la confianza a la hora de desplegar y la productividad no se ve afectada negativamente.

Referencias

  • Test Driven Development1
  • Why Test-driven Development2
  • Test driven development: empirical body of evidence3
  • Does Test-Driven Development Really Improve Software Design Quality4
  • 6 Misconceptions about TDD – Part 1. TDD Brings Little Business Value and Isn’t Worth it5
  • TDD is about design, not testing6
  • Does TDD really lead to good design?7
  • Using TDD to influence design8