Aprende Test Driven Development
Aprende Test Driven Development
Fran Iglesias
Buy on Leanpub

Prólogo

Cómo usar este libro

No hay una forma correcta, o incorrecta si vamos a eso, de leer este libro. Todo depende de tus intereses.

Para empezar, se estructura en cuatro partes principales que pueden leerse separadamente:

En la primera se introducen los conceptos básicos de TDD, así como algunas estrategias para aprender a usar e introducir esta disciplina en tu práctica.

En la segunda, se presenta una selección de katas o ejercicios de código con la que se explican en profundidad los conceptos y técnicas de Test Driven Development en su definición clásica. Van desde las que son muy conocidas hasta algunas propias.

Cada una de las katas se organiza de la siguiente forma:

  • Un capítulo teórico dedicado a un aspecto destacado de TDD puesto de relieve por esa kata y sobre el que he incidido especialmente al resolverla.
  • Una introducción a la kata, su origen si es conocido, su enunciado y una serie de recomendaciones o puntos de interés sobre la misma.
  • Una solución desarrollada en un lenguaje de programación diferente y explicada en detalle. Hay un repositorio con soluciones a las katas en varios lenguajes.

La tercera parte introduce la metodología outside-in TDD. Outside-in TDD es una propuesta en la que se busca potenciar la fase de diseño, y que se puede aplicar al desarrollo en proyectos reales.

La cuarta parte está orientada a mostrar un ejemplo de un proyecto realista y cómo se puede incorporar TDD en las distintas etapas de desarrollo y mantenimiento, desde la creación de un producto mínimo viable (MVP) a la resolución de defectos y la incorporación de nuevas características.

Si estás buscando un manual para aprender TDD desde cero, mi recomendación sería leerlo en orden. Los ejercicios de código están dispuestos para introducir los conceptos en una progresión determinada, a la que he llegado por experiencia personal y cuando he enseñado a otras personas a usar TDD.

Al principio, puede parecerte que los ejercicios de TDD son muy triviales y poco realistas. Ten presente que el nombre de Kata no es casual. Una Kata, en artes marciales, es un ejercicio repetitivo que se ejecuta hasta automatizar sus movimientos y más allá. Si practicas algún deporte habrás realizado decenas de ejercicios destinados a ganar flexibilidad, fuerza, movilidad y automatismos, sin que tengan una aplicación directa en ese deporte concreto. Las Katas de TDD tienen esa misma función: preparan tu cerebro para automatizar ciertas rutinas, generar determinados hábitos y conseguir detectar patrones particulares en el proceso de desarrollo.

Posiblemente, el enfoque outside-in te parezca mucho más aplicable a tu trabajo diario. De hecho, es una forma de desarrollar proyectos usando TDD. Sin embargo, una base sólida en TDD clásico es fundamental para tener éxito usando este enfoque. Outside-in está muy próximo al Behavior Driven Development.

Como se ha mencionado antes, las distintas partes y ejercicios son relativamente independientes. Si ya tienes cierta experiencia con la disciplina del Test Driven Development, puedes ir directamente a los apartados o ejercicios que te interesen. Con frecuencia descubrirás algo nuevo. Una de las cosas que me he encontrado es que siempre acaba apareciendo alguna idea nueva aunque hayas realizado el mismo ejercicio decenas de veces.

Si buscas cómo introducir TDD en tu proceso de trabajo o en el de tu equipo, es posible que vayas directamente a la parte sobre TDD en la vida real. Es la que tiene, por así decir, más dependencia en conocimientos y experiencia previa. En ese caso, si consideras que te falta soltura en TDD posiblemente debas echar un vistazo a otras partes del libro.

Para alcanzar un buen nivel de desempeño en TDD deberías practicar los ejercicios muchas veces. No hablo de tres o cuatro, estoy hablando de decenas de veces, en distintos momentos de tu vida profesional e, idealmente, en distintos lenguajes. Existen varios repositorios de katas en los que encontrar ejercicios, y puedes inventar o descubrir los tuyos propios.

También es recomendable ver cómo otras personas realizan estos ejercicios. En la web están disponibles montones de ejemplos de Katas realizadas en una variedad de lenguajes de programación, y es una gran forma de contrastar tus soluciones y tu proceso.

Y, por supuesto, pero no en último lugar, una de las mejores formas de aprender es practicar con otras personas. Ya sea en proyectos de trabajo, formaciones o comunidades de práctica. Debatir en vivo las soluciones, el tamaño de los pasos, el comportamiento a testear, contribuirá a pulir y fortalecer vuestro proceso de desarrollo.

Asunciones

Para este libro se asumen algunos supuestos:

  • Que tienes cierta experiencia en algún lenguaje de programación y un entorno de testing de ese lenguaje. En otras palabras: sabes escribir y ejecutar tests. No importa que tu lenguaje preferido no esté contemplado en este libro.
  • Los ejemplos del libro están en varios lenguajes y en la medida de lo posible se evita usar características muy específicas. De hecho, soy novato en muchos de ellos, por lo que el código puede parecer muy simplón. Por otro lado, esto es algo deseable en TDD, como verás a lo largo del libro.
  • Tienes claro que el objetivo de los ejercicios de código no es tanto resolver el problema planteado como tal, que finalmente se resuelve, sino el proceso por el que llegamos a esa solución.
  • Entiendes que no existe una solución única, ni un camino preciso en la resolución de las katas. Si tu solución no coincide con la planteada en este libro, no es ningún problema.

Disclaimer

Las soluciones propuestas a las katas se proporcionan como ejemplos explicados de los procesos de razonamiento que se podrían seguir. No son soluciones ideales. Cuando realices tu versión podrías seguir un proceso completamente diferente que podría ser tan válido o más que el presentado aquí.

Por otra parte, sucesivas ejecuciones de una misma kata por la misma persona podrían llevarla a soluciones y recorridos diferentes. Ese es uno de sus beneficios: al acostumbrarnos a ciertos patrones de pensamiento y automatizarlos podemos prestar atención a más detalles cada vez y encontrar puntos de intervención más interesantes.

Igualmente, a medida que se incrementa nuestra fluidez en un lenguaje de programación, las implementaciones que logramos pueden ser mejores y más elegantes.

Al preparar las katas presentadas en este libro he realizado varias versiones, incluso en distintos lenguajes a fin de encontrar los recorridos más interesantes o incluso provocar algunos problemas que me interesaba poner de manifiesto. La solución que he decidido publicar en cada caso está cargada hacia algún punto que me interesaba acentuar del proceso de TDD por lo que podría no ser la óptima.

Es decir, en cierto modo las katas tienen trampa, se trata de forzar las cosas hasta cierto punto para lograr un objetivo didáctico.

En otro orden de cosas, he aprovechado este proyecto para forzarme a experimentar con lenguajes de programación diferentes. En algunos casos, son lenguajes nuevos para mí o con los que no estoy acostumbrado a trabajar, por lo que es posible que las implementaciones sean especialmente toscas o que no incluyan algunas de sus características más específicas y óptimas.

Conceptos básicos de TDD

En esta primera parte, introduciremos los conceptos básicos para entender qué es Test Driven Development y en qué se diferencia de otras disciplinas y metodologías que utilizan tests. También hablaremos de cómo puedes aprender TDD, ya sea individualmente o en un equipo o comunidad de práctica.

En el primer capítulo se hace una introducción general al proceso de test driven development.

El capítulo sobre conceptos básicos es un glosario de términos que usaremos a lo largo del libro.

Finalmente, el capítulo sobre coding-dojo y katas propone algunas ideas sencillas para empezar a practicar en equipo o individualmente.

¿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

Conceptos básicos

A continuación definiremos algunos conceptos que se usan a lo largo del libro. Hay que entenderlos en el contexto de Test Driven Development.

Test

Un test es una pequeña pieza de software, normalmente una función, que ejecuta otra pieza de software y verifica si produce un resultado o efecto que esperamos. Un test es básicamente un ejemplo de uso de la unidad bajo test en el que se define un escenario y se ejecuta la unidad probada para ver si el resultado es que el que hemos previsto.

Muchos lenguajes utilizan la noción de TestCase, una clase que agrupa un cierto número de tests relacionados entre sí. En ese caso, cada método es un test, aunque es frecuente llamar test al test case.

Test como especificación

Un test como especificación utiliza ejemplos de uso de la pieza de software probada para describir cómo debería funcionar. Se utilizan, sobre todo, ejemplos que sean significativos, pero no siempre se hace de manera formal.

Se opone al test como verificación, propio de la QA, en el que se prueba la pieza de software eligiendo los casos de prueba de manera sistemática para verificar que cumple lo que se espera de ella.

Test que falla

Un test que falla es una especificación que no se cumple todavía porque no se ha añadido el software de producción que permite hacerlo pasar. Típicamente, los frameworks de testing lo representan en color rojo.

Test que pasa

Un test que pasa es una especificación que ejecuta un código de producción que genera el efecto o respuesta esperado. Los frameworks de testing los suelen representar en color verde.

Tipos de tests

Unitarios

Son tests que prueban una unidad de software en aislamiento, sus dependencias se doblan para mantener controlada su influencia en el resultado.

De integración

Los tests de integración habitualmente pruebas conjuntos de unidades de software, de modo que podemos verificar su comunicación y su acción combinada.

De aceptación

Los tests de aceptación son tests de integración que prueban un sistema de software como un consumidor más del mismo. Normalmente, los escribimos en función de los intereses del negocio.

Test Case

Es una clase que agrupa varios tests.

Test Suite

Es un conjunto de test y/o test cases, que habitualmente se pueden ejecutar juntos.

Código de producción

En TDD nos referimos con el nombre de código de producción al código que escribimos para pasar los tests y que, eventualmente, acabará siendo ejecutado en un sistema en producción.

Unidad de software

Unidad de software es un concepto bastante flexible y que hay que interpretar en un contexto, pero se refiere normalmente a una pieza de software que se puede ejecutar de forma unitaria y aislada, incluso aunque esté compuesta de varios elementos.

Subject under test

La unidad de software que es ejercitada en un test. Existe una discusión sobre cuál es el alcance de una unidad. En un extremo se encuentran quienes consideran que una unidad es una función, un método o incluso una clase. Sin embargo, también podemos considerar como unidad bajo test un conjunto de funciones o clases que son testeadas a través de la interfaz pública de una de ellas.

Refactor

Refactor es un cambio en el código que no altera su comportamiento ni su interfaz. La mejor manera de asegurar esto es que exista al menos un test que ejercita el fragmento de código que se está modificando, de modo que tras cada cambio nos aseguremos de que el test sigue pasando, lo que demostraría que no ha cambiado el comportamiento aunque se haya modificado la implementación.

Algunas técnicas o patrones de refactor están descritos en recopilaciones como esta de Refactoring Guru o el libro clásico de Martin Fowler

Refactor automático

Precisamente porque algunos refactors están muy bien identificados y caracterizados ha sido posible desarrollar herramientas capaces de ejecutarlos automáticamente. Estas herramientas están disponibles en los IDE.

Coding-dojo y katas

Kata

En el mundo del software llamamos katas a ejercicios de diseño y programación que plantean problemas relativamente sencillos y acotados con los que practicar metodologías de desarrollo.

El término es un prestamo de la palabra japonesa que designa los ejercicios de entrenamiento característicos de las artes marciales. Se atribuye su introducción a Dave Thomas (The Pragmatic Programmer)1, refiriéndose a la realización de pequeños ejercicios de código, repetidos una y otra vez hasta alcanzar un alto grado de fluidez o automatización.

Aplicado a TDD, las katas persiguen entrenar los ciclos de test-producción-refactor y la capacidad de añadir comportamiento mediante pequeños incrementos de código. Estos ejercicios te ayudarán a dividir una funcionalidad en partes pequeñas, a escoger ejemplos, a proceder paso a paso en el proyecto, a cambiar prioridades según la información que nos proporcionan los tests, etc.

La idea es repetir una misma kata muchas veces. Además de adquirir soltura con el proceso, en cada una de las repeticiones existe la posibilidad de descubrir nuevas estrategias. Con la práctica repetida, podremos favorecer el desarrollo de ciertos hábitos y el reconocimiento de patrones, automatizando hasta cierto punto nuestro proceso de desarrollo.

Puedes ejercitarte con katas de forma individual o con otras personas. Una forma sistemática de hacerlo es mediante un Coding Dojo.

Coding-dojo

Un coding dojo es un taller en el que un grupo de personas, con independencia de su nivel de conocimiento, realiza una kata de forma colaborativa y no competitiva.

La idea de Coding Dojo o Coder’s Dojo fue presentada en la conferencia XP2005 por Laurent Bossavit y Emmanuel Gaillot.

La estructura básica de un coding-dojo es bastante sencilla:

  • Presentación del problema, organización del ejercicio (5-10 min)
  • Sesión de código (30-40 min)
  • Puesta en común sobre el estado del ejercicio (5-10 min)
  • Continua la sesión de código (30-40 min)
  • Puesta en común de las soluciones alcanzadas

La sesión de código puede desarrollarse de varias formas:

  • Prepared kata. Un presentador explica cómo resolver el ejercicio, pero contando con el feedback de las personas presentes. No se avanza hasta que se consigue un consenso. Es una forma muy adecuada de trabajar cuando el grupo se está iniciando y pocas personas están acostumbradas a la metodología.
  • Randori kata. La kata se realiza en pairing usando algún sistema para alternar entre la conductora (al teclado) y la copiloto. Las demás presentes colaboran haciendo sugerencias.
  • Hands-on workshop. Una alternativa es que las participantes formen parejas y trabajen colaborativamente en la kata. A mitad del ejercicio se hace una parada para comentar sobre lo realizado unos minutos. Al final de la sesión se presentan las distintas soluciones en el punto donde hayan llegado. Cada pareja puede elegir el lenguaje de programación preferido, por lo que es una gran oportunidad para quienes quieran iniciarse en uno nuevo. También puede ser una buena forma para quienes se inician si se forman parejas con distinto nivel de experiencia.

Consejos para realizar las katas individualmente

Al principio puede ser buena idea asistir a katas dirigidas. Básicamente, se trata de una kata que hace una persona experta en forma de live coding mientras explica o comenta con la audiencia los distintos pasos, de modo que puedes ver la dinámica en acción. Si no tienes esta posibilidad, que puede ser lo más habitual, es buena idea ver alguna kata en vídeo. En los capítulos dedicados a cada kata puedes encontrar algunos enlaces.

Ante todo, el objetivo de las katas es ejercitar la disciplina TDD, la aplicación de las tres leyes y el ciclo red-green-refactor. El código de producción es lo de menos en el sentido de que no es el objeto del aprendizaje, aunque siempre será correcto si los tests pasan. Sin embargo, cada ejecución de la kata nos puede llevar a descubrir detalles nuevos y formas diferentes de afrontar cada fase.

Es decir, las katas están diseñadas para aprender a desarrollar software usando los tests como guía y para entrenar el mindset y los procesos de razonamiento y análisis que nos ayudan en esa tarea. Por lo general, desarrollar una buena metodología TDD nos ayudará a escribir mejor software gracias a las restricciones que nos impone.

Obviamente, los primeros intentos te llevarán su tiempo, te meterás en caminos aparentemente sin retorno o te saltarás los pasos del ciclo. Cuando ocurre eso, no tienes más que retroceder o volver a empezar de cero. Se trata de ejercicios que no tienen una respuesta correcta única.

De hecho, cada lenguaje de programación, enfoque o librería de test podrían favorecer unas soluciones u otras. Puedes hacer una kata varias veces intentando asumir diferentes supuestos de partida en cada intento o aplicando distintos paradigmas o condiciones.

Si encuentras puntos en los que puedes elegir diversos cursos de acción, toma nota de ellos para repetir el ejercicio y probar más adelante otro camino a ver a dónde te dirige.

En TDD es muy importante centrarse en el aquí y el ahora que nos define cada test que no pasa y no agobiarse por alcanzar el objetivo final. Esto no quiere decir dejarlo de lado o dedicarnos a otra cosa. Simplemente, quiere decir que hay que recorrer ese camino paso a paso, y hacerlo así nos llevará a la meta casi sin darnos cuenta, con mucho menos esfuerzo y más solidez. Adquirir esta mentalidad, ocuparse solo del problema que tengo delante, nos ayudará a reducir el estrés y pensar con más claridad.

Si es posible prueba a hacer la misma kata en diferentes lenguajes, incluso en diferentes frameworks de testing. Las dos familias más conocidas son:

  • xSpec, que están orientados a TDD y tienden a favorecer el testing mediante ejemplos, proporcionando sintaxis y utilidades específicas. Su hándicap es que no suelen funcionar bien para QA.
  • xUnit, que son los frameworks de testing más genéricos, aunque más orientados a QA. Sin embargo, puedes usarse para TDD perfectamente.

Cómo introducir TDD en equipos de desarrollo

Introducir la metodología TDD en equipos de desarrollo es un proceso complejo. Ante todo, es importante contribuir a generar una cultura abierta a la innovación, a la calidad y al aprendizaje. Las mayores reticencias suelen venir del miedo a que el uso de TDD ralentice el desarrollo, o que al principio no se vea una aplicación directa en los problemas diarios.

Personalmente, creo que puede ser interesante utilizar canales formales e informales. He aquí algunas ideas.

  • Establecer un tiempo semanal, unas dos horas, para un coding-dojo abierto a todo el equipo. Dependiendo del nivel de experiencia puede empezarse con katas dirigidas, o sesiones tipo hands-on, o el formato que nos parezca más adecuado. Lo ideal es que diversas personas lo puedan dinamizar.
  • Introducir en los equipos personas con experiencia que puedan ayudar a introducir TDD en sesiones de trabajo en pairing o mob-programming, guiando a otras compañeras.
  • Organizar formación específica, con ayuda externa si no se cuenta con personas con suficiente experiencia.
  • Introducir, si no se tiene, un blog técnico en el que se publiquen artículos, ejercicios y ejemplos sobre el tema.

Repositorios de katas

  • Katalyst2
  • Kata-log3
  • Coding dojo4
  • Codekata5
  • Agile kata6

Referencias

  • The Programming Dojo7
  • What is coding dojo8
  • The Coder’s Dojo – A Different Way to Teach and Learn Programming - Abstract9

TDD clásica

En esta parte presentamos una serie de ejercicios de código en los que exploraremos en profundidad cómo se hace Test Driven Development.

Usaremos el estilo o aproximación clásica de la disciplina. TDD es una metodología de desarrollo de software redescubierta por Kent Beck, basándose en el modo en que se construían los primeros programas de ordenador. Entonces, se realizaban primero los cálculos a mano para tener la referencia del resultado esperado y que debía reproducirse con el ordenador. En TDD, se escribe un programa muy sencillo que comprueba que el resultado de otro programa es el que se espera. La clave está en que ese programa aún no está escrito. Es así de simple.

La metodología fue presentada por Beck en su libro TDD by example, en el cual, entre otras cosas, se enseña a construir un framework de testing mediante TDD. Posteriormente, diversos autores han contribuido a refinar y sistematizar el modelo.

Las leyes de TDD

Desde la introducción de la metodología TDD por Kent Beck se ha intentado definir un framework sencillo que proporcione una guía para aplicarla en la práctica.

Inicialmente, Kent Beck propuso dos reglas muy básicas:

  • No escribir una línea de código sin antes tener un test automático que falle.
  • Eliminar la duplicación.

Es decir, para poder escribir código de producción, primero debemos tener un test que no pase y que requiera que escribamos ese código, precisamente porque eso es lo necesario para que el test pase.

Una vez que lo hemos escrito y viendo que el test pasa, nuestro esfuerzo se centra en revisar el código escrito y eliminar en lo posible la duplicación. Esto es muy genérico, porque, por una parte, se refiere al refactoring y, por otra parte, al acoplamiento entre el test y el código de producción. Y al ser tan genérico resulta difícil bajarlo en acciones prácticas.

Además, estas reglas no nos dicen nada acerca de cuan grandes son los saltos de código implicados en cada ciclo. Beck sugiere en su libro que los pasos o baby steps pueden ser tan pequeños o tan grandes como nos resulten útiles. En general, recomienda usar pasos pequeños cuando tenemos inseguridad o poco conocimiento del algoritmo, mientras que permite pasos más grandes si por experiencia y conocimientos tenemos claro qué hacer a continuación.

Con el tiempo, y a partir de la metodología aprendida del propio Beck, Robert C. Martin estableció las “tres leyes”, que no solo definen el ciclo de acciones en TDD, sino que también proporcionan criterios sobre cómo de grandes deberían ser los pasos en cada ciclo:

  • No se permite escribir ningún código de producción a menos que haga pasar un test unitario que falle
  • No se permite escribir más de un test unitario que sea suficiente para fallar; y los errores de compilación son fallos.
  • No se permite escribir más código de producción del que sea necesario para hacer pasar un test unitario que falle

Las tres leyes son lo que hace diferente TDD de simplemente escribir tests antes que el código.

Estas leyes imponen una serie de restricciones cuyo objetivo es forzarnos a seguir un determinado orden y ritmo de trabajo. Definen una serie de condiciones que, si se cumplen, generan un ciclo y guían nuestra toma de decisiones. Entender cómo funcionan, nos ayudará a aprovechar al máximo la capacidad de TDD para ayudarnos a generar código de calidad y que podamos mantener.

Estas leyes se tienen que cumplir todas a la vez porque funcionan juntas.

Las leyes en detalle

No se permite escribir ningún código de producción a menos que haga pasar un test unitario que falle

La primera ley nos dice que no podemos escribir código de producción si no hace pasar un test unitario existente que actualmente está fallando. Esto implica lo siguiente:

  • Tiene que existir un test que describa un aspecto nuevo del comportamiento de la unidad que estamos desarrollando.
  • Este test tiene que fallar porque en el código de producción no existe nada que lo haga pasar.

En resumen, la primera ley nos fuerza a escribir un test que defina el comportamiento que vamos a implementar en la unidad de software que estamos desarrollando antes de plantearnos cómo hacerlo.

Ahora bien, ¿cómo tiene que ser el test que escribamos?

No se permite escribir más de un test unitario que sea suficiente para fallar; y los errores de compilación son fallos.

La segunda ley nos dice que el test debe ser suficiente para fallar y que tenemos que considerar fallos los errores de compilación o su equivalente en lenguajes interpretados. Por ejemplo, entre estos errores estarían algunos tan obvios como que la clase o función no existe o no ha sido definida.

Debemos evitar la tentación de escribir un esqueleto de la clase o la función antes de escribir el primer test. Recuerda que estamos hablando de Test Driven Development. Por tanto, son los tests los que nos dicen qué código de producción escribir y cuándo y no al revés.

Que el test sea suficiente para fallar quiere decir que el test ha de ser muy pequeño en diversos sentidos y es algo que al principio resulta bastante difícil de definir. Con frecuencia se habla del test “más sencillo”, del caso más simple, pero no es exactamente así.

¿Qué condiciones tendría que reunir un test en TDD, particularmente el primero?

Pues básicamente forzarnos a escribir el mínimo código posible que se pueda ejecutar. Lo mínimo en OOP sería instanciar la clase que queremos desarrollar sin preocuparnos de más detalles, de momento. El test concreto variará un poco en función del lenguaje y framework de testing que estemos utilizando.

Veamos este ejemplo. Se trata de la kata Leap Year en la se busca crear una función para averiguar si un año dado es bisiesto (leap year) o no. Para el ejemplo, mi intención es crear un objeto Year, al que le pueda preguntar si es bisiesto enviándole el mensaje isLeap. He encontrado este ejercicio en varias recopilaciones de katas sin mención de autoría. Para este capítulo, los ejemplos están escritos en C#.

Las reglas son:

  • Los años no divisibles por 4 no son bisiestos (como 1997).
  • Los años divisibles por 4 son bisiestos (como 1996), excepto:
  • Si son divisibles por 100 no son bisiestos (como 1900).
  • Si son divisibles por 400 serán bisiestos (como 2000).

Nuestro objetivo sería poder usar objetos Year de esta forma:

1 var year = new Year(1996);
2 
3 if (year.IsLeap()) {
4 	// do something
5 }

La tentación habitual es tratar de empezar de la siguiente forma porque parece que es el ejemplo del caso más sencillo posible.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void ShouldNotBeLeapYear()
11         {
12             var year = new Year(1997);
13             Assert.False(year.IsLeap())
14         }
15     }
16 }

Sin embargo, no es el test más sencillo que pueda fallar por una única razón. En realidad puede fallar por cinco razones, al menos:

  • La clase Year no existe todavía.
  • Tampoco aceptaría parámetros pasados por la constructora.
  • No responde al mensaje IsLeap
  • Podría no retornar nada.
  • Podría devolver una respuesta incorrecta.

Es decir, podemos esperar que el test falle por estas cinco causas y solo la última es la que el test realmente describe. Tenemos que reducirlas a solo una.

En este caso, es muy fácil ver que hay una dependencia entre los diversos motivos de fallo, de tal manera que para que se pueda producir uno, tiene que haberse solucionado el anterior. Evidentemente, es necesario que exista una clase que poder instanciar. Por tanto, nuestro primer test debería ser mucho más modesto y esperar únicamente que la clase se pueda instanciar:

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }
14     }
15 }

Si lanzásemos este test veríamos que falla por razones obvias: no existe la clase que se pretende instanciar en ninguna parte. El test está fallando por un problema de compilación o equivalente. Por tanto, podría ser un test suficiente para fallar.

A lo largo del proceso veremos que este test es redundante y que podemos prescindir de él, pero no nos adelantemos. Todavía tenemos que conseguir que pase.

No se permite escribir más código de producción del que sea necesario para hacer pasar un test unitario que falle

La primera y la segunda leyes nos dicen que tenemos que escribir un test y cómo debería ser ese test. La tercera ley nos dice cómo tiene que ser el código de producción. Y la condición que nos pone es que haga pasar el test que hemos escrito.

Es muy importante entender que es el test el que nos dice qué código necesitamos implementar y, por tanto, aunque tengamos la certeza de que va a fallar porque ni siquiera tenemos un archivo con el código necesario para definir la clase, debemos ejecutar el test y esperar su mensaje de error.

Es decir: tenemos que ver que el test, efectivamente, falla.

Lo primero que nos dirá al tratar de ejecutarlo es que la clase no existe. En TDD eso no es un problema, sino una indicación de lo que debemos hacer: añadir un archivo con la definición de la clase. Seguramente con las herramientas del IDE podamos generar ese código de manera automática, y es aconsejable hacerlo así.

En nuestro ejemplo, el mensaje del test dice:

1 The type or namespace name 'Year' could not be found 
2 (are you missing a using directive or an assembly reference?)

Y simplemente tendremos que crear la clase Year.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }
14     }
15 
16     public class Year
17     {
18     }
19 }

En este punto volvemos a ejecutar el test para comprobar si pasa de rojo a verde. En muchos lenguajes este código será suficiente. En algunos casos puedes necesitar algo más.

Si es así, y el test pasa, el primer ciclo está completo y podremos pasar al siguiente comportamiento, a no ser que consideremos que tenemos posibilidades de hacer un refactor del código existente. Por ejemplo, lo habitual aquí sería mover la clase Year a su propio archivo.

1 namespace LeapYear
2 {
3     public class Year
4     {
5     }
6 }

Si el test no ha pasado, nos fijaremos en el mensaje mostrado por el test fallido y actuaremos en consecuencia, añadiendo el código mínimo necesario para que, finalmente, pase y se ponga en verde.

El segundo test y las tres leyes

Cuando hemos logrado hacer pasar el primer test aplicando las tres leyes podríamos pensar que no hemos conseguido realmente nada. Ni siquiera hemos abordado los posibles parámetros que podría necesitar la clase para ser construida, ya sean datos o colaboradores en el caso de servicios o use cases. Incluso el IDE se estará quejando de que no estamos asignando el objeto instanciado a ninguna variable.

Sin embargo, es importante ceñirse a la metodología, sobre todo en estas primeras fases. Con la práctica y la ayuda de un buen IDE el primer ciclo nos habrá llevado apenas unos pocos segundos. En esos pocos segundos hemos escrito un código, ciertamente muy pequeño, pero totalmente respaldado por un test.

Nuestro objetivo sigue siendo que los tests nos dicten qué código tenemos que escribir para implementar cada nuevo comportamiento. Como nuestro primer test ya pasa, tendríamos que escribir el segundo.

Aplicando las tres leyes, lo que viene a continuación es:

  • Escribir un nuevo test que defina un comportamiento
  • Que ese test sea el mínimo posible para obligarnos a hacer un cambio en el código de producción
  • Escribir el código de producción mínimo y suficiente que hace pasar el test

¿Cuál podría ser el próximo comportamiento que necesitamos definir? Si en el primer test nos hemos forzado a escribir el código mínimo necesario para instanciar la clase, el segundo test puede llevarnos por dos caminos:

  • Forzarnos a escribir el código necesario para validar parámetros del constructor y, por tanto, poder instanciar un objeto con todo lo necesario.
  • Forzarnos a introducir el método que ejecuta el comportamiento deseado.

Así, en nuestro ejemplo, podríamos simplemente asegurarnos de que Year es capaz de responder al mensaje IsLeap.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }        
14         
15         [Test]
16         public void CanRespondToIsLeapMessage()
17         {
18             var year = new Year();
19             year.IsLeap();
20         }
21     }
22 }

El test arrojará este mensaje de error:

1 'Year' does not contain a definition for 'IsLeap' and no accessible ext\
2 ension...

Que nos indica que el siguiente paso debería ser introducir el método que responde a ese mensaje:

1 namespace LeapYear
2 {
3     public class Year
4     {
5         public void IsLeap()
6         {
7         }
8     }
9 }

El test pasa, indicando que ahora los objetos de tipo Year, pueden atender al mensaje IsLeap.

Habiendo llegado a este punto, nos podríamos preguntar: ¿qué pasa si no cumplimos las tres leyes de TDD?

Violaciones de las tres leyes y sus consecuencias

Obviando la broma fácil de que no acabaremos en la cárcel o con una multa por incumplir las leyes de TDD, lo cierto es que sí tendríamos que apechugar con algunas consecuencias.

Primera ley: escribir código de producción sin tener un test

La consecuencia más inmediata es que rompemos el ciclo red-green. El código que escribimos ya no está guiado ni cubierto por tests. De hecho, si queremos tener esa parte testeada, tendremos que hacer un test a posteriori (un test de QA).

Imagina que hacemos esto:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         private readonly int _aYear;
 8 
 9         public Year(int aYear)
10         {
11             _aYear = aYear;
12         }
13 
14         public bool? IsLeap()
15         {
16             if (_aYear % 4 == 0)
17             {
18                 return true;
19             }
20             
21             return false;
22         }
23     }
24 }

Los tests existentes fallarán porque hay que pasar un parámetro a la constructora, además no tenemos ningún test que se haga cargo de verificar el comportamiento que hemos introducido. Tendríamos que añadir tests para cubrir la funcionalidad que hemos incorporado, pero ya no estamos dirigiendo el desarrollo.

Segunda ley: escribir más que un test que falle

Esto podemos interpretarlo de dos formas: escribir varios tests o escribir un test que supone un salto de comportamiento demasiado grande.

Escribir más de un test provocaría varios problemas. Para hacerlos pasar todos necesitaríamos implementar una gran cantidad de código y la guía que nos podrían proporcionar esos mismos tests se desdibuja tanto que es como no tenerla. No contaríamos con una indicación concreta que podamos resolver implementando nuevo código.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }        
14         
15         [Test]
16         public void CanRespondToIsLeapMessage()
17         {
18             var year = new Year();
19             year.IsLeap();
20         }
21 
22         [Test]
23         public void CommonYearsShouldNotBeLeap()
24         {
25             var year = new Year(1997);
26             Assert.False(year.IsLeap());
27         }
28         
29         [Test]
30         public void YearDivisibleBy4ShouldBeLeap()
31         {
32             var year = new Year(1996);
33             Assert.True(year.IsLeap());
34         }
35     }
36 }

Aquí hemos añadido dos tests. Para hacerlos pasar tendríamos que definir dos comportamientos. Además, son tests demasiado grandes. Todavía no hemos establecido, por ejemplo, que se pasa un parámetro a la constructora, ni que la respuesta será del tipo bool. Estos tests mezclan diversas responsabilidades y tratan de probar demasiadas cosas a la vez. Tendríamos que escribir demasiado código de producción de una sola vez, con lo que conlleva de inseguridad y espacio para que se produzcan errores.

En lugar de eso, necesitamos hacer tests para incrementos de funcionalidad más pequeños. Podemos ver varias posibilidades:

Para introducir que la respuesta es bool podemos asumir que, por defecto, los años no son bisiestos, por lo que esperaremos una respuesta false:

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }        
14         
15         [Test]
16         public void CanRespondToIsLeapMessage()
17         {
18             var year = new Year();
19             year.IsLeap();
20         }
21 
22         [Test]
23         public void ByDefaultYearsAreNotLeapYears()
24         {
25             var year = new Year();
26             Assert.False(year.IsLeap());
27         }
28     }
29 }

El error es:

1 Argument 1: cannot convert from 'void' to 'bool?'

Se puede resolver con:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         public Year()
 8         {
 9 
10         }
11 
12         public bool IsLeap()
13         {
14             return false;
15         }
16     }
17 }

Sin embargo, tenemos otra forma de hacerlo. Puesto que el lenguaje tiene tipado fuerte, podemos usar el sistema de tipos como test. Así en lugar de crear un test nuevo:

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year();
13         }        
14         
15         [Test]
16         public void CanRespondToIsLeapMessage()
17         {
18             var year = new Year();
19             year.IsLeap();
20         }
21     }
22 }

Cambiamos el tipo de retorno de IsLeap:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         public Year()
 8         {
 9 
10         }
11 
12         public bool IsLeap()
13         {
14             
15         }
16     }
17 }

Al ejecutar el test nos indicará que hay un problema, pues no devolvemos nada en la función:

1 'Year.IsLeap()': not all code paths return a value

Y finalmente, no tenemos más que añadir una respuesta por defecto, que será false:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         public Year()
 8         {
 9 
10         }
11 
12         public bool IsLeap()
13         {
14             return false;
15         }
16     }
17 }

Para introducir el parámetro de construcción podríamos recurrir a un refactor. Pero para esto el lenguaje de programación nos puede condicionar, llevándonos a distintas soluciones.

La vía del refactor es sencilla. Tan solo tenemos que incorporar el parámetro, aunque de momento no lo usaremos. En C# y otros lenguajes podemos hacerlo por la vía de introducir un constructor alternativo, de este modo los tests seguirán pasando. En otros lenguajes, podríamos marcar el parámetro como opcional.

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         private readonly int _aYear;
 8 
 9         public Year()
10         {
11             
12         }
13 
14         public Year(int aYear)
15         {
16             _aYear = aYear;
17         }
18         
19         public bool IsLeap()
20         {
21             return false;
22         }
23     }
24 }

Como para nosotras no tiene sentido un constructor sin parámetros, ahora podríamos eliminarlo, pero antes tendríamos que refactorizar los tests, de modo que usemos la versión con parámetro:

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanInstantiate()
11         {
12             new Year(1997);
13         }        
14         
15         [Test]
16         public void CanRespondToIsLeapMessage()
17         {
18             var year = new Year(1997);
19             year.IsLeap();
20         }
21     }
22 }

Lo cierto es que el primer test nos sobra, porque está implícito en el otro.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanRespondToIsLeapMessage()
11         {
12             var year = new Year(1997);
13             year.IsLeap();
14         }
15 
16     }
17 }

Y ahora podemos eliminar el constructor sin parámetros, ya que no se volverá a usar en ningún caso:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         private readonly int _aYear;
 8 
 9         public Year(int aYear)
10         {
11             _aYear = aYear;
12         }
13         
14         public bool IsLeap()
15         {
16             return false;
17         }
18     }
19 }

Tercera ley: escribir más del código de producción necesario para que pase el test

Se trata quizá de la violación más frecuente de todas. Llega un momento en que “vemos” el algoritmo con tanta claridad que nuestro impulso es escribirlo ya y terminar el proceso. Sin embargo, esto nos puede llevar a obviar algunas situaciones. Por ejemplo, en una aplicación podríamos “ver” el algoritmo general e implementarlo. Sin embargo, eso podría habernos distraído de uno o varios casos particulares y no contemplarlos, lo que una vez incorporado a la aplicación y desplegado posiblemente aparecerían errores en producción, incluso con pérdidas económicas.

Por ejemplo, si añadimos un test para probar que controlamos los años no bisiestos:

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanRespondToIsLeapMessage()
11         {
12             var year = new Year(1997);
13             year.IsLeap();
14         }
15 
16         [Test]
17         public void CommonYearsAreNoLeapYears()
18         {
19             var year = new Year(1997);
20             Assert.False(year.IsLeap());
21         }
22     }
23 }

En el estado actual de nuestro ejercicio, un exceso de código sería este:

 1 using System;
 2 
 3 namespace LeapYear
 4 {
 5     public class Year
 6     {
 7         private readonly int _aYear;
 8 
 9         public Year(int aYear)
10         {
11             _aYear = aYear;
12         }
13         
14         public bool IsLeap()
15         {
16             if (_aYear % 4 == 0)
17             {
18                 if (_aYear % 400 == 0)
19                 {
20                     return false;
21                 }
22 
23                 if (_aYear % 100 == 0)
24                 {
25                     return true;
26                 }
27 
28                 return true;
29             }
30             return false;
31         }
32     }
33 }

El test pasa con este código, pero como puedes ver se ha introducido mucho más del necesario para tener el comportamiento definido por el test, añadiendo código para controlar los años bisiestos y los casos especiales. Así que aparentemente todo está bien.

Si probamos un año bisiesto, veremos que el código funciona, lo que refuerza nuestra impresión de que todo es correcto.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanRespondToIsLeapMessage()
11         {
12             var year = new Year(1997);
13             year.IsLeap();
14         }
15 
16         [Test]
17         public void CommonYearsAreNoLeapYears()
18         {
19             var year = new Year(1997);
20             Assert.False(year.IsLeap());
21         }
22 
23         [Test]
24         public void ShouldIdentifyStandardLeapYears()
25         {
26             var year = new Year(1996);
27             Assert.True(year.IsLeap());
28         }
29     }
30 }

Pero, un nuevo test falla. Los años divisibles por 100 no deben ser bisiestos (salvo que sean divisibles por 400), y este error lleva un buen rato en nuestro código, pero hasta ahora no teníamos un test que ejecutase esa parte del código.

 1 using System;
 2 using System.Runtime.Remoting.Metadata.W3cXsd2001;
 3 using NUnit.Framework;
 4 
 5 namespace LeapYear
 6 {
 7     public class Tests
 8     {
 9         [Test]
10         public void CanRespondToIsLeapMessage()
11         {
12             var year = new Year(1997);
13             year.IsLeap();
14         }
15 
16         [Test]
17         public void CommonYearsAreNoLeapYears()
18         {
19             var year = new Year(1997);
20             Assert.False(year.IsLeap());
21         }
22 
23         [Test]
24         public void ShouldIdentifyStandardLeapYears()
25         {
26             var year = new Year(1996);
27             Assert.True(year.IsLeap());
28         }
29         
30         [Test]
31         public void ShouldIdentifyCenturyExceptionOfLeapYears()
32         {
33             var year = new Year(1900);
34             Assert.False(year.IsLeap());
35         }
36     }
37 }

Este es el tipo de problemas que pueden pasar desapercibidos cuando añadimos demasiado código para hacer pasar un test. El exceso de código posiblemente no afecta al test que tenemos entre manos, por lo que no sabremos si esconde algún tipo de problema y no lo sabremos si no llegamos a construir un test que lo ponga de manifiesto. O peor: no lo sabremos hasta que el bug explota en producción.

La solución es bastante simple: añade solo el código estrictamente necesario para que el test pase, aunque solo sea devolver el valor esperado por el propio test. No introduzcas comportamiento si no existe antes un test que te obligue a ello porque está fallando.

En nuestro caso era el test que verificaba el tratamiento de los años no bisiestos. De hecho, el siguiente test, que pretendía introducir el comportamiento de detectar los años bisiestos estándar (años divisibles por 4) pasaba sin necesidad de añadir nuevo código. Esto nos lleva al siguiente punto.

Qué significa que un test pase nada más escribirlo

Cuando escribimos un test y pasa sin añadir código de producción puede ser por alguno de estos motivos:

  • El algoritmo que hemos escrito es lo bastante general como para cubrir todos los casos posibles: hemos terminado nuestro desarrollo.
  • El ejemplo que hemos elegido no es cualitativamente diferente de otros que ya hemos usado y, por lo tanto, no nos fuerza a escribir código de producción. tenemos que encontrar otro ejemplo.
  • Hemos añadido demasiado código, que es lo que acabamos de contar en el apartado anterior.

En esta kata Leap Year, por ejemplo, llegará un momento en que no hay forma de escribir un test que falle porque el algoritmo cubre todos los casos: años no bisiestos, bisiestos, años no bisiestos cada 100 años y bisiestos cada 400 años.

La otra posibilidad es que el ejemplo escogido no sea representativo de un nuevo comportamiento, lo que puede venir dado por una mala definición de la tarea o por no haber analizado bien los posibles escenarios.

El ciclo red-green-refactor

Las tres leyes establecen un framework que podríamos llamar “de bajo nivel”. Martin Fowler, por su parte, define el ciclo TDD en estas tres fases que estarían en un nivel superior de abstracción:

  • Escribe un test para el siguiente fragmento de funcionalidad que deseas añadir.
  • Escribe el código de producción necesario para que el test pase.
  • Refactoriza el código, tanto el nuevo como el anterior, para que esté bien estructurado.

Estas tres fases definen lo que se suele conocer como el ciclo “red-green-refactor”, nombrado así por el estado de los tests en cada una de las fases del ciclo:

  • Red: la creación de un test que falla (está en rojo) y que describe la funcionalidad o comportamiento que queremos introducir en el software de producción.
  • Green: la escritura del código de producción necesario para hacer pasar el test (ponerlo en verde) con lo cual se verifica que se ha añadido el comportamiento especificado.
  • Refactor: manteniendo los tests en verde, reorganizar el código para estructurarlo mejor, haciéndolo más legible y sostenible sin perder la funcionalidad desarrollada hasta el momento.

En la práctica los ciclos de refactor surgen después de un cierto número de ciclos de las tres leyes. Los pequeños cambios impulsados por estas se acumulan hasta llegar a un punto en el que comienzan a aparecer smells de código que requieren el refactor.

Referencias

  • The three rules of TDD1
  • The three rules of TDD - video2
  • Refactoring the three laws of TDD3
  • TDD with PHPSpec4
  • The 3 Laws of TDD: Focus on One Thing at a Time5
  • Test Driven Development6
  • The cycles of TDD7

Fizz Buzz

Entendiendo las leyes y ciclos de TDD

La kata FizzBuzz es una de las katas más sencillas para empezar a practicar TDD. Plantea un problema muy simple y bien acotado, por lo que en una primera fase es muy fácil resolverla por completo en una sesión de una o dos horas. Pero también se pueden ampliar sus requerimientos y lograr desarrollos más complejos, como poner el requisito de que las reglas o el tamaño de la lista sean configurables, que se puedan añadir nuevas reglas, etc.

En este caso, al tratarse de nuestra primera kata, seguiremos la versión más sencilla.

Historia

Según Coding Dojo, no se conoce la autoría de la kata1, pero se considera que fue presentada en sociedad por Michael Feathers y Emily Bache en 2008, en el marco de la conferencia Agile2008.

Enunciado

FizzBuzz es un juego relacionado con el aprendizaje de la división en el que un grupo de estudiantes cuentan los números por turno, reemplazando cada número divisible por tres con la palabra “Fizz” y cada número divisible por cinco con la palabra “Buzz”. Si el número es divisible por ambos, entonces se dice “FizzBuzz”.

Así que nuestro objetivo será escribir un programa que imprima los números del 1 al 100 de tal manera que:

  • si el número es divisible por 3 devuelve Fizz.
  • si el número es divisible por 5 devuelve Buzz.
  • si el número es divisible por 3 y 5 devuelve FizzBuzz.

Orientaciones para resolverla

La kata Fizz Buzz nos va a servir para entender y comenzar a aplicar el ciclo Red-Green-Refactor y las Tres leyes de TDD.

Lo primero que nos conviene hacer es considerar el problema y hacernos una idea general de cómo vamos a solucionarlo. TDD es una estrategia que nos ayuda a evitar la necesidad de hacer un detallado análisis y diseño exhaustivo previo a la solución, pero eso no significa que no debamos primero entender el problema y considerar cómo lo vamos a atacar.

Esto también es necesario para evitar dejarnos llevar por el enunciado literal de la kata, que nos puede llevar a callejones sin salida.

Lo primero que vamos a hacer, una vez que tenemos esa idea general de cómo vamos a enfocar el objetivo, es aplicar la primera ley y escribir un test que falle.

Este test debería definir el primer comportamiento que necesitamos implementar.

Escribir un test que falle significa, en este momento, escribir un test que no va a funcionar porque no existe ningún código que ejecutar, cosa que nos van a decir los mensajes de error. Aunque te parezca absurdo debes intentar ejecutar el test y confirmar que no pasa. Son los mensajes del test los que te van a indicar qué hacer a continuación.

Para conseguir hacer que el test falle tenemos que aplicar la segunda ley, que dice que no podemos escribir un test más grande de lo que sea suficiente para fallar. El test más pequeño posible debería obligarnos a definir la clase instanciándola y poco más.

Por último, para hacer que el test pase, aplicaremos la tercera ley, que dice que no debemos escribir más código de producción que el necesario para que el test pase. Es decir: definir la clase, en su caso el método que vamos a ejercitar y hacer que este devuelva alguna respuesta que finalmente haga pasar el test.

Los dos primeros pasos de esta fase son bastante obvios, pero el tercero no tanto.

Con los dos primeros pasos intentamos llegar a conseguir que el test falle por los motivos adecuados. Es decir, primero falla porque no hemos escrito la clase y, en consecuencia la definimos. Luego fallará porque nos falta el método al que estamos llamando, así que lo definimos. Finalmente, fallará porque no devuelve la respuesta que esperamos, que es lo que queremos llegar a testear.

¿Y qué respuesta deberíamos estar devolviendo en cada caso? Pues ni más ni menos la que espera el test.

Una vez que tenemos un primer test y un primer código de producción que lo hace pasar nos haremos la pregunta: ¿cuál sería el siguiente comportamiento que debería implementar?

Enlaces de interés sobre la kata FizzBuzz

  • Vídeo de la kata por Jesús López de la Cruz2
  • FizzBuzz en Kata-log3
  • FizzBuzz resuelta en SmallTalk4
  • Code Katas Explained: FizzBuzz5
  • TDD — Which Order to Write Your Tests6
  • Solución en Python usando una lista de casos de uso7

Resolviendo la kata Fizz Buzz

Enunciado de la kata

Nuestro objetivo será escribir un programa que imprima los números del 1 al 100 de tal manera que:

  • si el número es divisible por 3 devuelve Fizz.
  • si el número es divisible por 5 devuelve Buzz.
  • si el número es divisible por 3 y 5 devuelve FizzBuzz.

Lenguaje y enfoque

Esta kata la vamos a hacer en Python con unittest como librería de testing. La tarea consiste crear una clase FizzBuzz, que tendrá un método generate para crear la lista, de modo que se usará más o menos así:

1 fizzbuzz = Fizzbuzz()
2 print(fizzbuzz.generate())

Para ello creo una carpeta fizzbuzzkata y dentro añado el archivo fixzzbuzz_test.py.

Definir la clase

Lo que nos pide el ejercicio es obtener una lista de los números 1 al 100 cambiando algunos de ellos por las palabras ‘Fizz’, ‘Buzz’ o ambas en caso de cumplirse ciertas condiciones.

Fíjate que no nos pide una lista de cualquier cantidad de números, sino específicamente del 1 al 100. Volveremos a eso dentro de un momento.

Ahora vamos a concentrarnos en ese primer test. Lo menos que podemos hacer es que se pueda instanciar un objeto del tipo FizzBuzz. He aquí un posible primer test:

 1 import unittest
 2 
 3 
 4 class FizzBuzzTestCase(unittest.TestCase):
 5     def test_something(self):
 6         FizzBuzz()
 7 
 8 
 9 if __name__ == '__main__':
10     unittest.main()

Puede parecer extraño. Este test se limita a intentar instanciar la clase y nada más.

Este primer test debería ser suficiente para fallar, que es lo que dice la segunda ley, y forzarnos a definir la clase para que el test pueda pasar, cumpliendo la tercera ley. En algunos entornos sería necesaria una aserción, ya que consideran que el test no ha pasado si no se ha verificado explícitamente, cosa que no sucede en Python.

Así que lo lanzamos para ver si es verdad que falla. El resultado, como era de esperar es que el test no pasa, mostrando el siguiente error, el cual es justo el que esperaríamos ver:

1 NameError: name 'FizzBuzz' is not defined

Para hacer que el test pase, tendremos que definir la clase FizzBuzz, cosa que haremos en el propio archivo del test.

 1 import unittest
 2 
 3 class FizzBuzz(object):
 4     pass
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     def test_something(self):
 8         FizzBuzz()
 9 
10 
11 if __name__ == '__main__':
12     unittest.main()

Y con esto el test pasará. Ahora que estamos en verde podemos pensar en refactorizar. La clase no tiene código. Pero el test no tiene un nombre adecuado, ahora podríamos cambiarlo:

 1 import unittest
 2 
 3 class FizzBuzz:
 4     pass
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     def test_can_instantiate(self):
 8         FizzBuzz()
 9 
10 
11 if __name__ == '__main__':
12     unittest.main()

Normalmente, es mejor que las clases estén en su propio archivo (o módulo en Python) porque es más fácil gestionar el código y saber dónde está cada cosa. Definitivamente, no queremos el código de producción en el archivo del test, así que creamos el archivo fizzbuzz.py moviendo de paso la clase.

Y en el test, la importamos:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     def test_can_instantiate(self):
 8         FizzBuzz()
 9 
10 
11 if __name__ == '__main__':
12     unittest.main()

Al introducir este cambio y ejecutar el test podemos ver que ahora sí pasa y ya estamos en verde.

Hemos cumplido las tres leyes y cerrado nuestro primer ciclo test-código-refactor. No hay mucho más que podamos hacer aquí, salvo pasar al siguiente test.

Definir el método generate

La clase FizzBuzz no solo no hace nada, ni siquiera tiene métodos. Hemos dicho que queremos que tenga un método generate que es el que devolverá la lista de los números del 1 al 100.

Para forzarnos a escribir el método generate, tenemos que escribir un test que lo llame. El método tendrá que devolver algo, ¿no? No. No siempre es necesario que devuelva algo. Nos basta con que nada se rompa al llamar al método.

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     def test_can_instantiate(self):
 8         FizzBuzz()
 9 
10     def test_responds_to_generate_message(self):
11         fizzbuzz = FizzBuzz()
12         fizzbuzz.generate()
13 
14 
15 if __name__ == '__main__':
16     unittest.main()

Al ejecutar el test nos dice que no tiene ningún método generate:

1 AttributeError: 'FizzBuzz' object has no attribute 'generate'

Por supuesto que no lo tiene, tenemos que añadirlo:

1 class FizzBuzz(object):
2     def generate(self):
3         pass

Ahora ya tenemos una clase capaz de responder al mensaje generate. ¿Podemos hacer algún refactor aquí?

Pues sí, pero no en el código de producción, sino en los tests. Resulta que este test que acabamos de escribir se superpone al anterior. Es decir, el test test_responds_to_generate_message cubre al test test_can_instantiate, convirtiéndolo en redundante. Por tanto, lo podemos quitar:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     def test_responds_to_generate_message(self):
 8         fizzbuzz = FizzBuzz()
 9         fizzbuzz.generate()
10 
11 
12 if __name__ == '__main__':
13     unittest.main()

Quizá esto te sorprenda. Es lo que comentamos al principio del libro, algunos de los tests que utilizamos para dirigir el desarrollo dejan de tener utilidad por un motivo u otro, generalmente porque son redundantes y no aportan ninguna información que no nos estén dando otros tests.

Definir un comportamiento para generate

Específicamente queremos que nos devuelva una lista de números. Pero ahora no hace falta que lo haga con los múltiplos de 3 y 5 convertidos.

El test debe verificar esto, pero debe seguir pasando cuando hayamos desarrollado el algoritmo completo. Lo que podemos verificar es que devuelve una lista de 100 elementos, sin prestar atención a los que contiene exactamente.

Este test nos forzará a darle un comportamiento en respuesta al mensaje generate:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7 
 8     def test_respond_to_generate_message(self):
 9         fizzbuzz = FizzBuzz()
10         fizzbuzz.generate()
11 
12     def test_generates_list_of_100_elements(self):
13         fizzbuzz = FizzBuzz()
14         num_list = fizzbuzz.generate()
15         self.assertEqual(100, len(num_list))
16 
17 
18 if __name__ == '__main__':
19     unittest.main()

Por supuesto, el test falla:

1 TypeError: object of type 'NoneType' has no len()

Ahora mismo, el método devuelve None. Nosotros queremos una lista:

1 class FizzBuzz(object):
2     def generate(self):
3         return []

Al hacer que generate devuelva una lista, hacemos que el test falle porque no se cumple lo que esperamos: que la lista tenga un cierto número de elementos:

1 AssertionError: 100 != 0

Este ya es un error del test. Los anteriores eran básicamente errores equivalentes a los errores de compilación (errores de sintaxis, etc.). Por eso es tan importante ver los tests fallar, para utilizar el feedback que nos proporcionan los mensajes de error.

Hacer que el test pase es bastante fácil:

1 class FizzBuzz(object):
2     def generate(self):
3         return [None] * 100

Con el test pasando vamos a pensar un poco.

En primer lugar, se puede argumentar que en este test hemos pedido que la respuesta de generate cumpla dos condiciones:

  • ser de tipo list (o array, o collection)
  • tener exactamente 100 elementos

Podríamos haber forzado esto mismo con dos tests aún más pequeños.

A estos pequeños pasos se les suele llamar baby steps y lo cierto es que no tienen una medida determinada, sino que dependen de nuestra práctica y experiencia.

Así, por ejemplo, el test que hemos creado es lo bastante pequeño como para no generar un gran salto de código en producción, aunque es capaz de verificar las dos condiciones a la vez.

En segundo lugar, fíjate que hemos escrito tan solo el código necesario para que se cumpla el test. De hecho devolvemos una lista de 100 elementos None, lo cual parece que no tiene mucho sentido, pero es suficiente para nuestro objetivo con este test. Recuerda: no escribas más código del necesario para hacer pasar el test.

En tercer lugar, ya tenemos bastante código escrito, entre test y producción, como para examinarlo y ver si tenemos alguna oportunidad de refactorizar.

La oportunidad más clara de refactor que tenemos ahora mismo es el número mágico 100, que podríamos representar mediante una constante de la clase. De nuevo, cada lenguaje te ofrecerá sus propias opciones:

1 class FizzBuzz(object):
2     _NUMBER_OF_ELEMENTS = 100
3 
4     def generate(self):
5         return [None] * self._NUMBER_OF_ELEMENTS

Y tenemos alguna en el código de test. Otra vez el test que hemos añadido se superpone e incluye al anterior, por lo que podríamos quitarlo.

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7 
 8     def test_generates_list_of_100_elements(self):
 9         fizzbuzz = FizzBuzz()
10         num_list = fizzbuzz.generate()
11         self.assertEqual(100, len(num_list))
12 
13 
14 if __name__ == '__main__':
15     unittest.main()

De igual manera, el nombre del test puede mejorar. En lugar de hacer una referencia a la cifra concreta, podríamos simplemente indicar algo más general, que no ate el test a un detalle específico de la implementación:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7 
 8     def test_generates_list_of_required_number_of_elements(self):
 9         fizzbuzz = FizzBuzz()
10         num_list = fizzbuzz.generate()
11         self.assertEqual(100, len(num_list))
12 
13 
14 if __name__ == '__main__':
15     unittest.main()

Por último, y no menos importante, igualmente tenemos un número mágico 100, al cual le pondremos un nombre:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14 
15 if __name__ == '__main__':
16     unittest.main()

Y con esto, hemos terminado un nuevo ciclo en el que ya hemos introducido la fase de refactor.

Generar una lista de números

Nuestra clase FizzBuzz ya puede generar una lista de 100 elementos, pero de momento cada uno de ellos es, literalmente, nada. Es hora de escribir un test que nos fuerce a poner números en esa lista.

Para ellos podríamos esperar que la lista generada contenga los números del 1 al 100. Sin embargo, tenemos un problema: al final del proceso de desarrollo la lista contendrá los números, pero algunos de ellos estarán representados con las palabras Fizz, Buzz o FizzBuzz. Si no tengo esto en cuenta, este tercer test empezará a fallar en cuanto comience a implementar el algoritmo que convierte los números. No parece una buena solución.

Un enfoque más prometedor es: ¿qué números no se verán afectados por el algoritmo? Pues aquellos que no sean múltiplos de 3 o de 5, por tanto podríamos escoger algunos de ellos para verificar que se incluyen en la lista sin transformar.

El más sencillo de todos es el 1, que debería figurar en la primera posición de la lista. Por razones de simetría vamos a hacer que los números se generen como strings.

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         fizzbuzz = FizzBuzz()
16         num_list = fizzbuzz.generate()
17         self.assertEqual('1', num_list[0])
18 
19 
20 if __name__ == '__main__':
21     unittest.main()

El test es muy pequeño y falla:

1 None != 1

¿Qué cambio podríamos introducir en el código de producción en este punto para que el test pase? El más obvio podría ser el siguiente:

1 class FizzBuzz(object):
2     _NUMBER_OF_ELEMENTS = 100
3 
4     def generate(self):
5         return ['1'] * self._NUMBER_OF_ELEMENTS

Lo cierto es que es suficiente como para pasar el test, así que nos vale.

Un problema que tenemos aquí es que el número 1 no aparece como tal en el test. Sí aparece su representación, pero usamos su posición en num_list, que es un array 0-indexed. Vamos a hacer explícito que estamos testeando sobre la representación de un número. Primero introducimos el concepto de posición:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         fizzbuzz = FizzBuzz()
16         num_list = fizzbuzz.generate()
17         position = 0
18         self.assertEqual('1', num_list[position])
19 
20 
21 if __name__ == '__main__':
22     unittest.main()

Y ahora el de número, así como su relación con posición:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         fizzbuzz = FizzBuzz()
16         num_list = fizzbuzz.generate()
17         number = 1
18         position = number - 1
19         self.assertEqual('1', num_list[position])
20 
21 
22 if __name__ == '__main__':
23     unittest.main()

Ya no necesitamos referirnos a la posición para nada, tan solo al número.

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         fizzbuzz = FizzBuzz()
16         num_list = fizzbuzz.generate()
17         number = 1
18         self.assertEqual('1', num_list[(number - 1)])
19 
20 
21 if __name__ == '__main__':
22     unittest.main()

Podríamos hacer el test más fácil de leer. Primero separamos la verificación:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         number = 1
16 
17         self.__assert_number_is_represented_as(number)
18 
19     def __assert_number_is_represented_as(self, number):
20         fizzbuzz = FizzBuzz()
21         num_list = fizzbuzz.generate()
22         self.assertEqual('1', num_list[(number - 1)])
23 
24 
25 if __name__ == '__main__':
26     unittest.main()

Extraemos la representación como parámetro en la aserción y hacemos un inline de number para que sea más fluida la lectura:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16 
17     def __assert_number_is_represented_as(self, number, representation):
18         fizzbuzz = FizzBuzz()
19         num_list = fizzbuzz.generate()
20         self.assertEqual(representation, num_list[(number - 1)])
21 
22 
23 if __name__ == '__main__':
24     unittest.main()

Como ves, hemos trabajado mucho en el test. Ahora será muy barato introducir nuevos ejemplos, lo que nos ayudará a escribir más tests y que el proceso sea más agradable y cómodo.

Seguimos generando números

En realidad todavía no hemos verificado que el método generate nos esté dando una lista de números, así que necesitamos seguir proponiendo nuevos tests que nos fuercen a introducir ese código.

Vamos a asegurarnos de que en la segunda posición aparece el número 2 que es el siguiente más sencillo que no es múltiplo de 3 o de 5:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17 
18     def __assert_number_is_represented_as(self, number, representation):
19         fizzbuzz = FizzBuzz()
20         num_list = fizzbuzz.generate()
21         self.assertEqual(representation, num_list[(number - 1)])
22 
23 
24 if __name__ == '__main__':
25     unittest.main()

Tenemos un nuevo test y también falla, así que vamos a añadir código en producción para que el test pase. Sin embargo, tenemos algunos problemas con esta implementación:

1 class FizzBuzz(object):
2     _NUMBER_OF_ELEMENTS = 100
3 
4     def generate(self):
5         return ['1'] * self._NUMBER_OF_ELEMENTS

Para intervenir en ella necesitaríamos refactorizarla un poco primero. Como mínimo extraer la respuesta a una variable que podamos manipular antes de devolverla.

Pero como el test ahora mismo está fallando no podemos refactorizar. Antes tenemos que anular o borrar el test que acabamos de crear. Lo más fácil es comentarlo y así no se ejecutará. Recuerda, para hacer refactor es obligatorio que los tests pasen:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_one_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         # self.__assert_number_is_represented_as(2, '2')
17 
18     def __assert_number_is_represented_as(self, number, representation):
19         fizzbuzz = FizzBuzz()
20         num_list = fizzbuzz.generate()
21         self.assertEqual(representation, num_list[(number - 1)])
22 
23 
24 if __name__ == '__main__':
25     unittest.main()

Ahora sí podemos trabajar:

1 class FizzBuzz(object):
2     _NUMBER_OF_ELEMENTS = 100
3 
4     def generate(self):
5         num_list = ['1'] * self._NUMBER_OF_ELEMENTS
6         return num_list

Y volvemos a activar el test, que ahora falla porque el número dos es representado con un ‘1’. El cambio más sencillo que se me ocurre ahora es este, tan tonto:

1 class FizzBuzz(object):
2     _NUMBER_OF_ELEMENTS = 100
3 
4     def generate(self):
5         num_list = ['1'] * self._NUMBER_OF_ELEMENTS
6         num_list[1] = '2'
7         
8         return num_list

Lo cierto es que el test está en verde. Sabemos que esta no es la implementación que resolverá el problema completo, pero nuestro código de producción solo está obligado a satisfacer los tests existentes. Por tanto, no nos precipitemos. Veamos qué podemos hacer.

El nombre del test está obsoleto, para empezar, hagámoslo más general:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17 
18     def __assert_number_is_represented_as(self, number, representation):
19         fizzbuzz = FizzBuzz()
20         num_list = fizzbuzz.generate()
21         self.assertEqual(representation, num_list[(number - 1)])
22 
23 
24 if __name__ == '__main__':
25     unittest.main()

Una vez resuelto esto, recordemos que antes vimos que el concepto de número y representación eran necesarios para definir mejor el comportamiento esperado en los tests. Podemos introducirlos ahora en nuestro código de producción:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         num_list = ['1'] * self._NUMBER_OF_ELEMENTS
 6         
 7         number = 2
 8         representation = '2'
 9         
10         num_list[number-1] = representation
11 
12         return num_list

Es un primer paso. Se pueden ver las limitaciones de la solución actual. Por ejemplo, ¿por qué tiene un tratamiento especial el 1? ¿Y qué pasará si queremos verificar otro número? Son varios problemas.

En cuanto al número 1, la clave está en la idea de lista de números. Ahora mismo generamos una lista de constantes, pero cada elemento de esa lista debería ser un número correlativo empezando por el número 1, hasta completar el número de elementos.

Y luego tendríamos que reemplazar cada número por su representación. Algo así:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         num_list = list(range(1, self._NUMBER_OF_ELEMENTS + 1))
 6 
 7         number = 1
 8         representation = '1'
 9 
10         num_list[number-1] = representation
11 
12         number = 2
13         representation = '2'
14 
15         num_list[number-1] = representation
16 
17         return num_list

El test sigue pasando con esta nueva estructura, pero esto no parece muy práctico. Sin embargo, podemos ver un patrón. Necesitamos recorrer la lista para darle una solución:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         num_list = list(range(1, self._NUMBER_OF_ELEMENTS + 1))
 6         for number in num_list:
 7             if number == 1:
 8                 representation = '1'
 9 
10             if number == 2:
11                 representation = '2'
12 
13             num_list[number-1] = representation
14 
15         return num_list

Con la información de que disponemos, podríamos asumir simplemente que nos basta con convertir el número en string y ponerlo en su lugar:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         num_list = list(range(1, self._NUMBER_OF_ELEMENTS + 1))
 6         for number in num_list:
 7             representation = str(number)
 8             num_list[number-1] = representation
 9 
10         return num_list

Claro que existen formas más pythonicas y compactas, como esta:

1 class FizzBuzz:
2 
3     _NUMBERS_IN_LIST = 100
4     
5     def generate(self):
6         return list(map(lambda num: str(num + 1), range(self._NUMBERS_I\
7 N_LIST)))

Pero debemos tener cuidado, probablemente estamos adelantándonos demasiado con este refactor y seguramente nos generará problemas cuando intentemos avanzar. Por eso, es preferible mantener una implementación más directa e ingenua y dejar las optimizaciones y estructuras más avanzadas para cuando el comportamiento del método esté completamente definido. Así que te recomendaría evitar este tipo de aproximación.

Todo este refactor con los tests pasando. Esto quiere decir que:

1. Con el test describimos el comportamiento que queremos desarrollar
2. Hacemos pasar el test con el código más sencillo posible, por estúpidamente simple que nos parezca, a fin de tener ese comportamiento implementado
3. Usamos los test en verde como red de seguridad para reestructurar el código hasta encontrar un diseño mejor: fácil de entender, mantener y extender.

Los puntos 2 y 3 se construyen basándose en estos principios:

  • KISS: Keep it simply stupid (mantenlo simplemente estúpido), que quiere decir mantener el sistema lo más tonto posible, y no intentar añadir inteligencia prematuramente. Cuanto más mecánico y simple mejor, dentro de sus necesidades. Este KISS es nuestra primera aproximación.
  • Ley de Gall: todo sistema complejo que funciona ha evolucionado desde un sistema más simple que funcionaba. Por tanto, empezamos por una implementación muy simple que funcione (KISS) y la hacemos evolucionar hacia una más compleja que también funciona, cosa que sabemos gracias a que el test sigue pasando.
  • YAGNI: Your aren’t gonna need it (no lo vas a necesitar), que nos fuerza a no implementar más comportamiento que lo estrictamente necesario para que pasen los tests actuales.

Pero ahora tenemos que implementar nuevos comportamientos.

El test que no falla

El siguiente número que no es múltiplo de 3, 5 o 15 es 4, así que añadimos un ejemplo para eso:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def __assert_number_is_represented_as(self, number, representation):
20         fizzbuzz = FizzBuzz()
21         num_list = fizzbuzz.generate()
22         self.assertEqual(representation, num_list[(number - 1)])
23 
24 
25 if __name__ == '__main__':
26     unittest.main()

Y el test pasa. ¿Buena noticia? Depende. Un test que pasa nada más crearlo siempre es motivo de sospecha, al menos desde el punto de vista de TDD. Recuerda: escribir un test que falle es lo primero. Si el test no falla es que:

  • El comportamiento ya está implementado
  • No es el test que andábamos buscando

En nuestro caso ocurre que el último refactor ha dado lugar al comportamiento general de los números que no necesitan transformación. De hecho, podemos categorizar los números en estas clases:

  • Números que se representan como ellos mismos
  • Múltiplos de tres, representados por ‘Fizz’
  • Múltiplos de cinco, representados por ‘Buzz’
  • Múltiplos de tres y cinco, representados por ‘FizzBuzz’

1 y 2 son miembros de la primera clase, por lo que son ejemplos más que suficientes, ya que cualquier número de esa clase nos valdría como ejemplo. En TDD los necesitamos porque nos han ayudado a introducir la idea de que tendríamos que recorrer la lista de números. Pero nos bastaría con un único test en QA. Por eso, al introducir el ejemplo del 4, no hace falta añadir nuevo código: el comportamiento ya está implementado.

Es hora de moverse a las otras clases de números.

Aprendiendo a decir “Fizz”

Es hora de que nuestro FizzBuzz sea capaz de convertir el 3 en “Fizz”. Un test mínimo para especificarlo sería el siguiente:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21 
22 
23     def __assert_number_is_represented_as(self, number, representation):
24         fizzbuzz = FizzBuzz()
25         num_list = fizzbuzz.generate()
26         self.assertEqual(representation, num_list[(number - 1)])
27 
28 
29 if __name__ == '__main__':
30     unittest.main()

Teniendo un test que falla, veamos qué código de producción mínimo podríamos añadir para que pase:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         num_list = list(range(1, self._NUMBER_OF_ELEMENTS + 1))
 6         for number in num_list:
 7             representation = str(number)
 8 
 9             if number == 3:
10                 representation = 'Fizz'
11 
12             num_list[number - 1] = representation
13 
14         return num_list

Hemos añadido un if que hace pasar este caso particular. De momento no hay otra forma mejor con la información que tenemos. Recuerda KISS, Gall y YAGNI para evitar avanzar más de lo debido.

En lo que toca al código, puede que haya una manera mejor de poblar la lista. En lugar de generar una lista de números y cambiarla, tal vez podamos iniciar una lista vacía e ir añadiendo las representaciones de los números en ella:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         num_list = list(range(1, self._NUMBER_OF_ELEMENTS + 1))
 7         for number in num_list:
 8             representation = str(number)
 9 
10             if number == 3:
11                 representation = 'Fizz'
12 
13             representations.append(representation)
14 
15         return representations

Esto funciona. Ahora num_list no tiene mucha razón de ser como lista. Podemos hacer un cambio:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         num_list = range(1, self._NUMBER_OF_ELEMENTS + 1)
 7         for number in num_list:
 8             representation = str(number)
 9 
10             if number == 3:
11                 representation = 'Fizz'
12 
13             representations.append(representation)
14 
15         return representations

Y eliminar la variable temporal:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number == 3:
10                 representation = 'Fizz'
11 
12             representations.append(representation)
13 
14         return representations

Todo sigue funcionando correctamente, como atestiguan los tests.

Decir “Fizz” cuando toca

Ahora queremos que nos ponga un “Fizz” cuando el número es múltiplo de 3 y no solo cuando es exactamente 3. Por supuesto, nos toca añadir un test para especificarlo. Esta vez con el número 6, que es el más cercano que tenemos y que es múltiplo de 3 (y no de cinco).

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21         self.__assert_number_is_represented_as(6, 'Fizz')
22 
23 
24     def __assert_number_is_represented_as(self, number, representation):
25         fizzbuzz = FizzBuzz()
26         num_list = fizzbuzz.generate()
27         self.assertEqual(representation, num_list[(number - 1)])
28 
29 
30 if __name__ == '__main__':
31     unittest.main()

Para hacer pasar el test el cambio que hay que hacer es bastante pequeño. Tenemos que modificar la condición para ampliarla a los múltiplos de tres. Pero vamos a hacerlo de manera progresiva.

Primero establecemos el comportamiento:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number == 3:
10                 representation = 'Fizz'
11 
12             if number == 6:
13                 representation = 'Fizz'
14 
15             representations.append(representation)
16 
17         return representations

Con esto el test, pasa. Ahora vamos a cambiar el código para que use el concepto múltiplo de:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number == 3:
10                 representation = 'Fizz'
11 
12             if number == 6:
13                 representation = 'Fizz'
14 
15             if number % 3 == 0:
16                 representation = 'Fizz'
17 
18             representations.append(representation)
19 
20         return representations

El test sigue pasando, lo que indica que nuestra hipótesis es correcta. Ahora podemos eliminar la parte de código redundante:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             representations.append(representation)
13 
14         return representations

En este punto, podrías querer probar otros ejemplos de la misma clase, aunque realmente no es necesario dado que cualquier múltiplo de tres es un representante adecuado. Por eso, nos moveremos al siguiente comportamiento.

Aprendiendo a decir “Buzz”

Este test nos permite especificar el nuevo comportamiento:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21         self.__assert_number_is_represented_as(6, 'Fizz')
22 
23     def test_five_and_its_multiples_are_represented_as_buzz(self):
24         self.__assert_number_is_represented_as(5, 'Buzz')
25 
26     def __assert_number_is_represented_as(self, number, representation):
27         fizzbuzz = FizzBuzz()
28         num_list = fizzbuzz.generate()
29         self.assertEqual(representation, num_list[(number - 1)])
30 
31 
32 if __name__ == '__main__':
33     unittest.main()

Así que modificamos el código de producción para lograr que pase el test. Como hemos hecho antes, tratamos el caso particular de forma particular:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number == 5:
13                 representation = 'Buzz'
14 
15             representations.append(representation)
16 
17         return representations

Sí, ya sabemos cómo tendríamos que tratar el caso general de los múltiplos de cinco, pero es preferible forzarse a ir despacio. Recuerda que el objetivo principal del ejercicio no es resolver la generación de la lista, sino hacerlo guiadas por tests. Nuestro interés ahora es internalizar este ciclo de pasos cortos.

No hay mucho más que podamos hacer ahora, salvo pasar al siguiente test.

Decir “Buzz” cuando toca

A estas alturas el test es bastante obvio, el siguiente múltiplo de 5 es 10:

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21         self.__assert_number_is_represented_as(6, 'Fizz')
22 
23     def test_five_and_its_multiples_are_represented_as_buzz(self):
24         self.__assert_number_is_represented_as(5, 'Buzz')
25         self.__assert_number_is_represented_as(10, 'Buzz')
26 
27     def __assert_number_is_represented_as(self, number, representation):
28         fizzbuzz = FizzBuzz()
29         num_list = fizzbuzz.generate()
30         self.assertEqual(representation, num_list[(number - 1)])
31 
32 
33 if __name__ == '__main__':
34     unittest.main()

Y, de nuevo, el cambio en el código de producción es simple al principio:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number == 5:
13                 representation = 'Buzz'
14 
15             if number == 10:
16                 representation = 'Buzz'
17 
18             representations.append(representation)
19 
20         return representations

A continuación, procedemos paso a paso en el refactor, ahora que hemos asegurado el comportamiento:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number == 5:
13                 representation = 'Buzz'
14 
15             if number == 10:
16                 representation = 'Buzz'
17                 
18             if number % 5 == 0:
19                 representation = 'Buzz'
20 
21             representations.append(representation)
22 
23         return representations

Y luego:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number % 5 == 0:
13                 representation = 'Buzz'
14 
15             representations.append(representation)
16 
17         return representations

Y con este refactor podemos pasar a la siguiente clase de números.

Aprender a decir “FizzBuzz”

La estructura es exactamente igual. Empecemos por el caso más sencillo: 15 debe devolver “FizzBuzz” ya que 15 es el primer número que es múltiplo de 3 y 5 a la vez

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21         self.__assert_number_is_represented_as(6, 'Fizz')
22 
23     def test_five_and_its_multiples_are_represented_as_buzz(self):
24         self.__assert_number_is_represented_as(5, 'Buzz')
25         self.__assert_number_is_represented_as(10, 'Buzz')
26 
27     def test_fifteen_and_its_multiples_are_represented_as_fizzbuzz(self\
28 ):
29         self.__assert_number_is_represented_as(15, 'FizzBuzz')
30 
31 
32     def __assert_number_is_represented_as(self, number, representation):
33         fizzbuzz = FizzBuzz()
34         num_list = fizzbuzz.generate()
35         self.assertEqual(representation, num_list[(number - 1)])
36 
37 
38 if __name__ == '__main__':
39     unittest.main()

El nuevo test falla. Hagámoslo pasar:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number % 5 == 0:
13                 representation = 'Buzz'
14 
15             if number == 15:
16                 representation = 'FizzBuzz'
17 
18             representations.append(representation)
19 
20         return representations

Decir “FizzBuzz” cuando toca

Y seguimos introduciendo un test para otro caso de la clase de múltiplos de 3 y 5, que será 30.

 1 import unittest
 2 
 3 from fizzbuzzkata.fizzbuzz import FizzBuzz
 4 
 5 
 6 class FizzBuzzTestCase(unittest.TestCase):
 7     _NUMBER_OF_ELEMENTS = 100
 8 
 9     def test_generates_list_of_required_number_of_elements(self):
10         fizzbuzz = FizzBuzz()
11         num_list = fizzbuzz.generate()
12         self.assertEqual(self._NUMBER_OF_ELEMENTS, len(num_list))
13 
14     def test_number_is_represented_as_itself(self):
15         self.__assert_number_is_represented_as(1, '1')
16         self.__assert_number_is_represented_as(2, '2')
17         self.__assert_number_is_represented_as(4, '4')
18 
19     def test_three_and_its_multiples_are_represented_as_fizz(self):
20         self.__assert_number_is_represented_as(3, 'Fizz')
21         self.__assert_number_is_represented_as(6, 'Fizz')
22 
23     def test_five_and_its_multiples_are_represented_as_buzz(self):
24         self.__assert_number_is_represented_as(5, 'Buzz')
25         self.__assert_number_is_represented_as(10, 'Buzz')
26 
27     def test_fifteen_and_its_multiples_are_represented_as_fizzbuzz(self\
28 ):
29         self.__assert_number_is_represented_as(15, 'FizzBuzz')
30         self.__assert_number_is_represented_as(30, 'FizzBuzz')
31 
32 
33     def __assert_number_is_represented_as(self, number, representation):
34         fizzbuzz = FizzBuzz()
35         num_list = fizzbuzz.generate()
36         self.assertEqual(representation, num_list[(number - 1)])
37 
38 
39 if __name__ == '__main__':
40     unittest.main()

Esta vez iré directamente a la implementación final, pero ya te haces a la idea:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         representations = list()
 6         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
 7             representation = str(number)
 8 
 9             if number % 3 == 0:
10                 representation = 'Fizz'
11 
12             if number % 5 == 0:
13                 representation = 'Buzz'
14 
15             if number % 15 == 0:
16                 representation = 'FizzBuzz'
17 
18             representations.append(representation)
19 
20         return representations

¡Y ya tenemos nuestro “FizzBuzz”!

Finalizando

Hemos completado el desarrollo del comportamiento especificado de la clase FizzBuzz. De hecho, cualquier test que añadamos ahora nos confirmará que el algoritmo es lo bastante general como para que todos los casos estén cubiertos. Es decir, no hay un test concebible que nos pueda obligar a introducir más código de producción: no hay nada más que debamos hacer.

En un caso de trabajo real este código sería funcional y entregable. Pero ciertamente podemos mejorarlo todavía. Todos los tests pasando indican que el comportamiento deseado está implementado, así que podríamos refactorizar sin miedo buscando una solución más flexible. Por ejemplo, con la siguiente solución sería fácil añadir algunas reglas más:

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def generate(self):
 5         rules = {
 6             3: 'Fizz',
 7             5: 'Buzz',
 8             15: 'FizzBuzz',
 9         }
10 
11         representations = list()
12 
13         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
14             representation = str(number)
15             for divisor in rules.keys():
16                 if number % divisor == 0:
17                     representation = rules[divisor]
18 
19             representations.append(representation)
20 
21         return representations

Y si te fijas bien, sería relativamente fácil modificar la clase para introducir las reglas desde fuera, ya que bastaría con pasar el diccionario con las reglas al instanciar la clase, cumpliendo el principio de Open for extension and Closed for modification. En este caso, hemos dejado que se usen las reglas originales si no se indican otras, de modo que los tests siguen pasando exactamente igual.

 1 class FizzBuzz(object):
 2     _NUMBER_OF_ELEMENTS = 100
 3 
 4     def __init__(self, rules=None):
 5         if rules is None:
 6             rules = {
 7                 3: 'Fizz',
 8                 5: 'Buzz',
 9                 15: 'FizzBuzz',
10             }
11         self.rules = rules
12 
13     def generate(self):
14 
15         representations = list()
16 
17         for number in range(1, self._NUMBER_OF_ELEMENTS + 1):
18             representation = str(number)
19             for divisor in self.rules.keys():
20                 if number % divisor == 0:
21                     representation = self.rules[divisor]
22 
23             representations.append(representation)
24 
25         return representations

Qué hemos aprendido con esta kata

  • Las leyes de TDD
  • El ciclo red->green->refactor
  • Usar tests mínimos para hacer avanzar el código de producción
  • Cambiar el código de producción lo mínimo para conseguir el comportamiento
  • Usar la fase de refactor para mejorar el diseño del código

Elección de los ejemplos y criterio de finalización

Una de las preguntas más frecuentes cuando empiezas a hacer TDD es cuántos tests tienes que escribir hasta considerar el desarrollo terminado. La respuesta corta es: tendrás que hacer todos los tests que sean necesarios y ni uno más. La respuesta larga es este capítulo.

Checklist driven testing

Una buena técnica puede ser seguir el consejo de Kent Beck y escribir una lista de control o check-list en la que anotamos todos aquellos comportamientos que queremos implementar. Obviamente, a medida que completamos cada comportamiento vamos tachando items en la lista.

También es posible que, durante el trabajo, descubramos que necesitamos testear algún otro comportamiento, que podemos suprimir alguno de los elementos de la lista, o que nos interesa cambiar el orden en que lo hemos planeado. Por supuesto que podemos hacer todo esto según nos convenga.

La lista no es más que una herramienta para no depender de nuestra memoria durante el proceso. Al fin y al cabo, uno de los beneficios de hacer Test Driven Development es reducir la cantidad de información y conocimiento que tenemos que utilizar en cada fase del proceso de desarrollo. Cada ciclo de TDD implica un problema muy pequeño, que podemos resolver con bastante poco esfuerzo. Pequeños pasos que acaban llevándonos muy lejos.

Veamos un ejemplo con la kata Leap Year, en la que tenemos que crear una función para calcular si un año es bisiesto o no. Una posible lista de control sería esta:

1 Checklist para Leap Year
2 
3 * Los años que no pueden dividir por 4 son años normales
4 * Los años divisibles por 4 son bisiestos
5 * Si son divisibles por 100 son bisiestos
6 * Si son divisibles por 400 entonces no son bisiestos

Otro ejemplo para la kata Prime Numbers, en la que el ejercicio consiste en desarrollar una función que obtenga los factores primos de un número:

1 Checklist para Prime Numbers
2 
3 * Números que no tienen factores primos
4 * Números primos (el único factor primo es el mismo número)
5 * Números no primos:
6    * Potencias de un solo factor primo
7    * Producto de distintos factores primos

Selección de ejemplos

Por cada comportamiento que queremos implementar necesitaremos un cierto número de ejemplos con los que escribir los tests. En el capítulo siguiente veremos que TDD tiene dos momentos principales: uno relacionado con el establecimiento de la interfaz de la unidad que estamos creando y otro en el que desarrollamos el comportamiento propiamente dicho. Es en este momento cuando necesitamos ejemplos que cuestionen la implementación existente y nos obliguen a introducir código que produzca el comportamiento deseado.

Una buena idea es, por tanto, anotar varios ejemplos posibles con los que probar cada item de la lista de control.

Pero, ¿cuántos ejemplos son necesarios? En QA tenemos varias técnicas para escoger ejemplos representativos con los que generar los tests, pero tienen el objetivo de optimizar la relación entre el número de tests y su capacidad de cubrir los escenarios posibles.

Podemos utilizar algunas de ellas en TDD, aunque de una forma un poco diferente, como veremos a continuación. Ten en cuenta que en TDD estamos desarrollando un algoritmo y, en muchos casos, lo vamos descubriendo mientras lo escribimos. Para eso necesitaremos varios ejemplos relacionados con el mismo comportamiento, de modo que podamos identificar regularidades y descubrir cómo generalizarlo.

Las técnicas en las que nos vamos a fijar son:

Partición por clase de equivalencia

Esta técnica se basa en que el conjunto de todos los posibles casos concebibles se puede dividir en clases mediante algún criterio. Todos los ejemplos en la misma clase serían equivalentes, por lo que bastaría hacer un test con un ejemplo de cada clase, ya que todos son igualmente representativos de la misma.

Análisis de límites

Esta técnica es similar a la anterior, pero prestando atención a los límites o fronteras entre clases. Se escogen dos ejemplos de cada clase que son justamente los que se encuentran en sus límites. Ambos ejemplos son representativos de la clase, pero nos permiten estudiar qué ocurre en los extremos del intervalo.

Se usa cuando los ejemplos son datos contínuos o nos importa especialmente el cambio que se produce al pasar de una clase a otra. Específicamente es el tipo de situación en la que el resultado depende de si el valor considerado es mayor o estrictamente mayor, etc.

Tabla de decisión

La tabla de decisión no es más que el resultado de combinar los posibles valores, agrupados en clases, de los parámetros que se pasan a la unidad bajo test.

Vamos a ver la elección de ejemplos aplicada al caso de Leap Year. Para eso, empezamos con la lista:

1 Checklist para Leap Year
2 
3 * Los años que no pueden dividir por 4 son años normales
4 * Los años divisibles por 4 son bisiestos
5 * Si son divisibles por 100 son bisiestos
6 * Si son divisibles por 400 entonces no son bisiestos

Veamos el primer item. Podríamos usar cualquier número que cumpla la condición de no ser divisible por 4:

1 * Los años que no pueden dividir por 4 son años normales
2 
3 Ejemplos: 1997, 2021, 1825

En el segundo item, los ejemplos deben cumplir la condición de ser divisibles por 4:

1 * Los años divisibles por 4 son bisiestos
2 
3 Ejemplos: 1996, 2000, 2020, 1600, 1800, 1900

Prestemos atención al siguiente elemento de la lista. La condición de ser números divisibles por 100 se superpone con la condición anterior. Por tanto, tenemos que eliminar algunos ejemplos del item anterior:

1 * Los años divisibles por 4 son bisiestos
2 
3 Ejemplos: 1996, 2020
4 
5 * Si son divisibles por 100 son bisiestos
6 
7 Ejemplos: 2000, 1600, 1800, 1900

Y ocurre lo mismo con el último de los elementos de la lista. Los ejemplos para este item son los números divisibles por 400. También se superpone con el ejemplo anterior:

1 * Si son divisibles por 100 son bisiestos
2 
3 Ejemplos: 1800, 1900
4 
5 * Si son divisibles por 400 entonces no son bisiestos
6 
7 Ejemplos: 1600, 2000

De este modo, la lista con los ejemplos quedaría así:

 1 * Los años que no pueden dividir por 4 son años normales
 2 
 3 Ejemplos: 1997, 2021, 1825
 4 
 5 * Los años divisibles por 4 son bisiestos
 6 
 7 Ejemplos: 1996, 2020
 8 
 9 * Si son divisibles por 100 son bisiestos
10 
11 Ejemplos: 1800, 1900
12 
13 * Si son divisibles por 400 entonces no son bisiestos
14 
15 Ejemplos: 1600, 2000

Por otro lado, la elección de ejemplos para Prime Factors nos podría dar esto:

 1 Checklist para Prime Numbers
 2 
 3 * Números que no tienen factores primos
 4 
 5 Ejemplos: 1
 6 
 7 * Números primos (el único factor primo es el mismo número)
 8 
 9 Ejemplos: 2, 3, 5...
10 
11 * Números no primos:
12    * Potencias de un solo factor primo
13    
14    Ejemplos: 4, 8, 9, 16, 27...
15    
16    * Producto de distintos factores primos
17    
18    Ejemplos: 6, 10, 12, 15, 18, 20...

Uso de varios ejemplos para generalizar un algoritmo

En ejercicios de código simples como puede ser la kata Leap Year, es relativamente fácil anticipar el algoritmo, de modo que no necesitaríamos usar varios ejemplos para hacerlo evolucionar e implementarlo. En realidad, sería suficiente con un ejemplo de cada clase, como hemos visto al hablar de la partición por clases de equivalencia, y en pocos minutos tendríamos el problema resuelto.

Sin embargo, si estamos empezando con TDD es buena idea ir paso a paso. Lo mismo que si nos enfrentamos a un comportamiento complejo. Es preferible tomar baby steps realmente pequeños, introducir varios ejemplos y esperar a tener suficiente información para generalizar. En este mismo libro puedes encontrar varias aplicaciones de esta técnica. Tener algo de duplicación de código es preferible a escoger la abstracción equivocada y construir sobre ella.

Una heurística que puedes aplicar es la regla de los tres. Esta regla nos dice que no deberíamos intentar generalizar código hasta tener al menos tres repeticiones del mismo. Para ello, tendremos que identificar las partes fijas y las partes que cambian.

Considera este ejemplo, tomado de un ejercicio de la kata Leap Year. En este punto los tests están pasando, pero de momento no hemos generado un algoritmo.

 1 function leapYear(year) {
 2     if (year === 1992) {
 3         return true;
 4     }
 5     if (year === 1996) {
 6         return true;
 7     }
 8     if (year === 2020) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it('should be Leap Year', () => {
17         expect(leapYear(1992)).toBeTruthy();
18         expect(leapYear(1996)).toBeTruthy();
19         expect(leapYear(2020)).toBeTruthy();
20     });
21 })

Ya tenemos tres repeticiones. ¿Qué tienen en común aparte de la estructura if/then?. Forcemos un pequeño cambio:

 1 function leapYear(year) {
 2     if (year === 498 * 4) {
 3         return true;
 4     }
 5     if (year === 499 * 4) {
 6         return true;
 7     }
 8     if (year === 505 * 4) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it ('should be Common Year', () => {
17         expect(leapYear(1997)).toBeFalsy();
18         expect(leapYear(1998)).toBeFalsy();
19         expect(leapYear(2021)).toBeFalsy();
20     });
21 	
22     it('should be Leap Year', () => {
23         expect(leapYear(1992)).toBeTruthy();
24         expect(leapYear(1996)).toBeTruthy();
25         expect(leapYear(2020)).toBeTruthy();
26     });
27 })

Claramente, los tres años son divisibles por 4. Así que podríamos expresarlo de otra manera:

 1 function leapYear(year) {
 2     if (year % 4 === 0) {
 3         return true;
 4     }
 5     if (year % 4 === 0) {
 6         return true;
 7     }
 8     if (year % 4 === 0) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it ('should be Common Year', () => {
17         expect(leapYear(1997)).toBeFalsy();
18         expect(leapYear(1998)).toBeFalsy();
19         expect(leapYear(2021)).toBeFalsy();
20     });
21 	
22     it('should be Leap Year', () => {
23         expect(leapYear(1992)).toBeTruthy();
24         expect(leapYear(1996)).toBeTruthy();
25         expect(leapYear(2020)).toBeTruthy();
26     });
27 })

Que ahora es una repetición obvia y se puede eliminar:

 1 function leapYear(year) {
 2     if (year % 4 === 0) {
 3         return true;
 4     }
 5 
 6     return false;
 7 }
 8 
 9 describe('Identify Leap Year', () => {
10     it ('should be Common Year', () => {
11         expect(leapYear(1997)).toBeFalsy();
12         expect(leapYear(1998)).toBeFalsy();
13         expect(leapYear(2021)).toBeFalsy();
14     });
15 	
16     it('should be Leap Year', () => {
17         expect(leapYear(1992)).toBeTruthy();
18         expect(leapYear(1996)).toBeTruthy();
19         expect(leapYear(2020)).toBeTruthy();
20     });
21 })

Esto ha sido muy obvio, por supuesto. Sin embargo, no siempre tendremos las cosas tan fáciles.

En resumen, si no conocemos muy bien el problema, puede ser útil esperar a que se cumpla la regla de los tres para empezar a pensar en generalizaciones del código. Esto implica que, como mínimo, introduciremos tres ejemplos que representen la misma clase antes de refactorizar la solución a una más general.

Veamos otro ejemplo en la misma kata:

 1 function leapYear(year) {
 2     if (year % 100 === 0) {
 3         return false;
 4     }
 5 
 6     if (year % 4 === 0) {
 7         return true;
 8     }
 9 
10     return false;
11 }
12 
13 describe('Identify Leap Year', () => {
14     it ('should be Common Year', () => {
15         expect(leapYear(1997)).toBeFalsy();
16         expect(leapYear(1998)).toBeFalsy();
17         expect(leapYear(2021)).toBeFalsy();
18     });
19 	
20     it('should be Leap Year', () => {
21         expect(leapYear(1992)).toBeTruthy();
22         expect(leapYear(1996)).toBeTruthy();
23         expect(leapYear(2020)).toBeTruthy();
24     });
25 
26     it('should be exceptional common year', function () {
27         expect(leapYear(1700)).toBeFalsy();
28         expect(leapYear(1800)).toBeFalsy();
29         expect(leapYear(1900)).toBeFalsy();
30     });
31 })

La duplicación que no lo es

El concepto divisible es evidente en esta ocasión y realmente no necesitamos un tercer caso para valorar la posibilidad de extraerlo. Pero aquí lo importante no es la duplicación. En realidad nos hubiese bastado con un ejemplo. Este refactor lo hacemos porque hace explícita la idea de que la condición que se evalúa es el hecho de que el número del año sea divisible por un cierto factor para que se aplique la regla.

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100)) {
 7         return false;
 8     }
 9 
10     if (divisibleBy(4)) {
11         return true;
12     }
13 
14     return false;
15 }
16 
17 describe('Identify Leap Year', () => {
18     it ('should be Common Year', () => {
19         expect(leapYear(1997)).toBeFalsy();
20         expect(leapYear(1998)).toBeFalsy();
21         expect(leapYear(2021)).toBeFalsy();
22     });
23 	
24     it('should be Leap Year', () => {
25         expect(leapYear(1992)).toBeTruthy();
26         expect(leapYear(1996)).toBeTruthy();
27         expect(leapYear(2020)).toBeTruthy();
28     });
29 
30     it('should be exceptional common year', function () {
31         expect(leapYear(1700)).toBeFalsy();
32         expect(leapYear(1800)).toBeFalsy();
33         expect(leapYear(1900)).toBeFalsy();
34     });
35 })

Esto se ve más claro si avanzamos un poco más.

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Tenemos una misma estructura repetida tres veces, pero no podemos extraer un concepto común de aquí. Dos de las repeticiones representan el mismo concepto (año bisiesto), pero la tercera representa años de duración normal excepcionales.

En búsqueda de la abstracción incorrecta

Intentemos otra aproximación:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(4 * 4 * 5 * 5)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(4 * 5 * 5)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Si dividimos el año entre cuatro podríamos plantear otra idea, ya que eso nos podría ayudar a identificar mejor las partes comunes y diferentes:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5     year /= 4
 6 
 7     if (divisibleBy(4 * 5 * 5)) {
 8         return true;
 9     }
10 
11     if (divisibleBy(5 * 5)) {
12         return false;
13     }
14 
15     if (divisibleBy(1)) {
16         return true;
17     }
18 
19     return false;
20 }

Es extraño, pero funciona. Más simplificado:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5     year /= 4
 6 
 7     if (divisibleBy(100)) {
 8         return true;
 9     }
10 
11     if (divisibleBy(25)) {
12         return false;
13     }
14 
15     if (divisibleBy(1)) {
16         return true;
17     }
18 
19     return false;
20 }

Sigue funcionando. Pero, ¿de qué nos sirve?

  • Por un lado, seguimos sin encontrar una manera de reconciliar las tres estructuras if/then.
  • Por otro, hemos hecho que las reglas de dominio sean irreconocibles.

En otras palabras: intentar encontrar una abstracción basándonos solo en la existencia de repetición en el código puede ser un camino sin salida.

La abstracción correcta

Como hemos señalado antes, el concepto que nos interesa es el de año bisiesto y las reglas que lo determinan. ¿Podemos hacer el código menos repetitivo? Puede ser. Volvamos al principio:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

La cuestión es que la regla de divisible por 400 es una excepción a la regla de divisible por 100:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100) && !divisibleBy(400)) {
 7         return false;
 8     }
 9 
10     if (divisibleBy(4)) {
11         return true;
12     }
13 
14     return false;
15 }

Lo que nos permite hacer esto y compactar un poco la solución:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100) && !divisibleBy(400)) {
 7         return false;
 8     }
 9 
10     return divisibleBy(4);
11 }

Quizá podamos hacerla un poco más explícita:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     if (isCommonYearExceptionally()) {
11         return false;
12     }
13 
14     return divisibleBy(4);
15 }

Pero ahora queda un poco raro, necesitamos ser más explícitas aquí:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     function isLeapYear() {
11         return divisibleBy(4);
12     }
13 
14     if (isCommonYearExceptionally()) {
15         return false;
16     }
17 
18     return isLeapYear();
19 }

En este punto, me pregunto si esto no es demasiado poco natural. Por un lado, la abstracción es correcta, pero a fuerza de llevarla tan lejos posiblemente estamos pecando de cierta sobre-ingeniería. El dominio del problema es muy pequeño y las reglas muy sencillas y claras. Si comparas esto:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Con esto:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     function isLeapYear() {
11         return divisibleBy(4);
12     }
13 
14     if (isCommonYearExceptionally()) {
15         return false;
16     }
17 
18     return isLeapYear();
19 }

Creo que me quedaría con la primera solución. Ahora bien, en un problema más complejo y más complicado de entender, puede que la segunda solución sea mucho más adecuada, precisamente porque nos ayudaría a hacer explícitos los conceptos implicados.

La moraleja es que no hay que empeñarse en buscar la abstracción perfecta, sino la suficiente para este momento.

Evolución del comportamiento mediante tests

La metodología TDD se basa en ciclos de trabajo en los que definimos un comportamiento deseado en forma de test, realizamos cambios en el código de producción para implementarlo, y refactorizamos la solución una vez que sabemos que funciona.

Si bien disponemos de herramientas específicas para detectar situaciones que requieren refactor e incluso métodos bien definidos para llevarlo a cabo, no tenemos recursos que guíen las transformaciones necesarias del código de una forma similar. Es decir, ¿existe algún proceso que nos sirva para decidir qué tipo de cambio aplicar al código para implementar un comportamiento?

The Transformation Priority Premise1 es un artículo que plantea un framework útil en este sentido. Partiendo de la idea de que a medida que los tests son más específicos el código se vuelve más general, propone una secuencia del tipo de transformaciones que podemos aplicar cada vez que estamos en fase de implementación, en la transición de rojo a verde.

El desarrollo mediante TDD tendría dos partes principales:

  • En la primera parte construimos la interfaz pública de la clase, definiendo cómo nos vamos a comunicar con ella y cómo nos va a responder. Esta respuesta la analizamos en su forma más genérica, que sería el tipo de dato que proporciona.
  • En la segunda parte desarrollamos el comportamiento, empezando desde los casos más genéricos e introduciendo después los más específicos.

Vamos a ver esto con un ejemplo práctico. Realizaremos la kata Roman Numerals fijándonos en cómo los tests nos sirven para guiar estas dos partes. En esta ocasión el lenguaje será Kotlin.

Construir la interfaz pública de una clase dirigida por tests

Siempre empezaremos con un test que nos obligue a definir la clase, pues de momento no nos hace falta nada más que tener algún objeto con el que poder llegar a interactuar.

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 
 5 class RomanNumeralsTest {
 6 
 7     @Test
 8     fun `Should convert numbers to roman` () {
 9         RomanNumerals()
10     }
11 }

Ejecutamos el test para verlo fallar y, a continuación, escribimos la definición de la clase vacía, lo mínimo imprescindible para que el test pase.

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4 
5 }

Si la hemos creado en el mismo archivo que el test, ahora podemos moverla a su sitio durante la fase de refactor.

Ya podemos pensar en el segundo test, que necesitamos para definir la interfaz pública, es decir: cómo vamos a comunicarnos con el objeto una vez instanciado, qué mensajes es capaz de entender:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 
 5 class RomanNumeralsTest {
 6 
 7     @Test
 8     fun `It converts numbers to roman` () {
 9         RomanNumerals().toRoman()
10     }
11 }

Estamos modificando el primer test. Ahora que tenemos algo de soltura, podemos permitirnos estas licencias, para que escribir un nuevo test nos resulte más económico. Comprobamos que falle por lo que tiene que fallar (no está definido el mensaje toRoman). Seguidamente, escribimos el código necesario para hacerlo pasar. El compilador nos ayuda: si ejecutamos el test veremos que lanza una excepción que nos dice que el método existe, pero no está implementado. Y seguramente el IDE también nos lo indica de una forma u otra. En el caso de Kotlin, que es el lenguaje que estamos usando aquí, directamente nos pide que implementemos:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman() {
5         TODO("Not yet implemented")
6     }
7 }

De momento, quitamos estas indicaciones que introduce el IDE:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman() {
5     }
6 }

Y con esto el test pasa. Ya tenemos el mensaje con el que vamos a pedirle a RomanNumerals que haga la conversión. El siguiente paso puede ser definir que la respuesta que esperamos es un String. Si trabajamos con tipado dinámico o Duck Typing necesitaremos un test específico. Sin embargo, en Kotlin podemos hacerlo sin tests. Nos basta especificar el tipo que retorna la función:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(): String {
5     }
6 }

Esto no compilará, así que el test que tenemos ahora fallará y la forma de hacerlo pasar es devolver algún String. Aunque sea vacío.

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(): String {
5         return "";
6     }
7 }

Hasta cierto punto podemos considerar esto como un refactor, pero lo puedes aplicar como si fuese un test.

Ahora ya vamos a pensar en cómo usar este código para convertir los números arábigos ea la notación romana. Como en ella no hay cero, tenemos que empezar por el 1.

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 
 5 class RomanNumeralsTest {
 6 
 7     @Test
 8     fun `Should convert numbers to roman` () {
 9         RomanNumerals().toRoman(1)
10     }
11 }

Al ejecutar el test vemos que falla porque la función no espera parámetro, así que lo añadimos:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(decimal: Int): String {
5         return "";
6     }
7 }

Y esto hace pasar el test. La interfaz pública ha quedado definida, pero aún no tenemos ningún comportamiento.

Dirigir el desarrollo de un comportamiento con ejemplos

Una vez que hemos establecido la interfaz pública de la clase que estamos desarrollando querremos empezar a implementar su comportamiento. Necesitamos un primer ejemplo, que para este ejercicio será convertir el 1 en I.

Para esto ya necesitamos asignar el valor a una variable y utilizar una aserción. El test quedará así:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         var roman = RomanNumerals().toRoman(1)
11 
12         assertEquals("I", roman)
13     }
14 }

De null a constante

Ahora mismo RomanNumerals().toRoman(1) devuelve "", que para el caso es equivalente a devolver null.

¿Cuál es la transformación más sencilla que podemos realizar para que el test pase? En pocas palabras se trata de pasar de no devolver nada a devolver algo, y para que el test pase, ese algo debe ser el valor “I”. O sea, una constante:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(number: Int): String {
5         return "I"
6     }
7 }

El test pasa. Esta solución puede chocarte si es la primera vez que te asomas a TDD, aunque si estás leyendo este libro ya habrás visto más ejemplos de lo mismo. Pero esta solución no es estúpida.

De hecho, esta es la mejor solución para el estado actual del test. Nosotras podemos saber que queremos construir un convertidor de números arábigos a romanos, pero lo que el test especifica aquí y ahora es que esperamos que convierta el número entero 1 en el String I. Y es exactamente lo que hace.

Por tanto, la implementación tiene exactamente la complejidad y el nivel de especificidad necesarios. Lo que haremos a continuación será cuestionarla con otro ejemplo.

Pero antes, nos conviene hacer un refactor.

Lo haremos a fin de prepararnos para lo que viene después. Cuando cambiemos el ejemplo tendrá que cambiar la respuesta. Así que vamos a hacer dos cosas: utilizar el parámetro que recibimos y, a la vez, asegurar que este test siempre pasará:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(number: Int): String {
5         if (number == 1) return "I"
6         return ""
7     }
8 }

Ejecutamos el test, que debería pasar sin problema. Además, haremos un pequeño arreglo al propio test:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10 
11         assertEquals("I", RomanNumerals().toRoman(1))
12     }
13 }

El test ahora sigue pasando y no hay nada que hacer ya, por lo que vamos a introducir un nuevo ejemplo, cosa que ahora es más fácil de hacer:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertEquals("I", RomanNumerals().toRoman(1))
11         assertEquals("II", RomanNumerals().toRoman(2))
12     }
13 }

Al ejecutar el test comprobamos que falla porque no devuelve el II esperado. Una forma de hacerlo pasar es la siguiente:

1 package org.talkingbit.kata
2 
3 class RomanNumerals {
4     fun toRoman(number: Int): String {
5         if (number == 2) return "II"
6         if (number == 1) return "I"
7         return ""
8     }
9 }

Observa que, de momento, estamos devolviendo constantes en todos los casos.

Hagamos refactor ya que estamos en verde. Primero del test, para que sea aún más compacto y fácil de leer y añadir nuevos ejemplos:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12     }
13 
14     private fun assertConvertsToRoman(arabic: Int, roman: String) {
15         assertEquals(roman, RomanNumerals().toRoman(arabic))
16     }
17 }

Añadamos un test más. Ahora es aún más sencillo:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13     }
14 
15     private fun assertConvertsToRoman(arabic: Int, roman: String) {
16         assertEquals(roman, RomanNumerals().toRoman(arabic))
17     }
18 }

Lo vemos fallar y para que pase, añadimos una nueva constante:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         if (number == 3) return "III"
 6         if (number == 2) return "II"
 7         if (number == 1) return "I"
 8         return ""
 9     }
10 }

Y ahora expresando lo mismo, pero de distinta manera y usando una única constante:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         if (number == 3) return "I" + "I" + "I"
 6         if (number == 2) return "I" + "I"
 7         if (number == 1) return "I"
 8         return ""
 9     }
10 }

Podríamos extraerla:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         val i = "I"
 6         if (number == 3) roman = i + i + i
 7         if (number == 2) roman = i + i
 8         if (number == 1) roman = i
 9         return ""
10     }
11 }

Y ahora es fácil ver cómo podríamos introducir una nueva transformación.

De constante a variable

Esta transformación consiste en usar una variable para generar la respuesta. Es decir, ahora en lugar de devolver un valor fijo para cada ejemplo, lo que haremos es calcular la respuesta que toca. Básicamente, hemos empezado a construir un algoritmo.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         val i = "I"
 6         var roman = ""
 7         if (number == 3) roman = i + i + i
 8         if (number == 2) roman = i + i
 9         if (number == 1) roman = i
10 
11         return roman
12     }
13 }

Esta transformación hace evidente que al algoritmo consiste en acumular tantos I como nos indica el número number. Una forma de verlo es la siguiente:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         val i = "I"
 6         var roman = ""
 7         for (counter in 1..number) {
 8             roman += i
 9         }
10         return roman
11     }
12 }

Pero este bucle for, se puede expresar mejor mediante un while, aunque antes tenemos que hacer un cambio. Hay que hacer notar que los parámetros en kotlin son final, por lo que no podemos modificarlos. Por eso hemos tenido que introducir una variable que se inicializa con el valor del parámetro.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         val i = "I"
 7         var roman = ""
 8         while (arabic >= 1) {
 9             roman += i
10             arabic -= 1
11         }
12 
13         return roman
14     }
15 }

Por otro lado, puesto que la constante i solo se usa una vez, y como su significado es bastante evidente, la vamos a eliminar.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7         while (arabic >= 1) {
 8             roman += "I"
 9             arabic -= 1
10         }
11 
12         return roman
13     }
14 }

De este modo, hemos empezado a construir una solución más general para el algoritmo, al menos hasta el punto definido actualmente por los tests. Como sabemos, no es “legal” acumular más de 3 símbolos iguales en la notación romana, por lo que el método en su estado actual generará números romanos incorrectos si lo usamos a partir del cuatro.

Esto nos indica que necesitamos un nuevo test para poder incorporar nuevo comportamiento y desarrollar más el algoritmo, que aún es muy específico.

Pero, ¿cuál es el siguiente ejemplo que podríamos implementar?

De incondicional a condicional

En primer lugar, tenemos el número 4, que en notación romana es IV. Introduce un símbolo nuevo, que es una combinación de símbolos. Por lo que sabemos hasta ahora es un caso particular, así que introducimos una condicional que separe el flujo en dos ramas: una para lo que ya sabemos y otra para lo nuevo.

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14     }
15 
16     private fun assertConvertsToRoman(arabic: Int, roman: String) {
17         assertEquals(roman, RomanNumerals().toRoman(arabic))
18     }
19 }

El test fallará porque intenta convertir el número 4 con IIII. Introducimos la condicional para tratar este caso particular.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic == 4) {
 9             roman = "IV"
10         }
11 
12         while (arabic >= 1) {
13             roman += "I"
14             arabic -= 1
15         }
16 
17         return roman
18     }
19 }

Ups. El test falla porque la respuesta es IVIII. Hemos olvidado descontar el valor consumido. Lo arreglamos así y tomamos nota para el futuro:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic == 4) {
 9             roman = "IV"
10             arabic -= 4
11         }
12 
13         while (arabic >= 1) {
14             roman += "I"
15             arabic -= 1
16         }
17 
18         return roman
19     }
20 }

Avanzamos un nuevo número:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15     }
16 
17     private fun assertConvertsToRoman(arabic: Int, roman: String) {
18         assertEquals(roman, RomanNumerals().toRoman(arabic))
19     }
20 }

Comprobamos que el test falla por las razones esperadas y nos da como resultado IIIII. Para hacerlo pasar tomaremos otro camino, introduciendo una nueva condicional porque es un caso nuevo. Esta vez no nos olvidamos de descontar el valor de 5.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic == 5) {
 9             roman = "V"
10             arabic -= 5
11         }
12 
13         if (arabic == 4) {
14             roman = "IV"
15             arabic -= 4
16         }
17 
18         while (arabic >= 1) {
19             roman += "I"
20             arabic -= 1
21         }
22 
23         return roman
24     }
25 }

Lo cierto es que ya habíamos usado condicionales antes, cuando nuestras respuestas eran constantes, para escoger “qué constante devolver” por así decir. Ahora introducimos la condicional para poder tratar nuevas familias de casos, ya que hemos agotado la capacidad del código existente para resolver los casos nuevos que estamos introduciendo. Y dentro de esa rama de ejecución que antes no existía, volvemos a resolver mediante una constante.

Introduzcamos un nuevo test que falle para forzar un nuevo avance del algoritmo:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(3, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16     }
17 
18     private fun assertConvertsToRoman(arabic: Int, roman: String) {
19         assertEquals(roman, RomanNumerals().toRoman(arabic))
20     }
21 }

En este caso es especialmente interesante ver cómo falla:

1 expected:<[V]I> but was:<[IIIII]I>
2 Expected :VI
3 Actual   :IIIIII

Necesitamos hacer que se incluya el símbolo “V”, algo que podemos hacer de forma muy simple, cambiando el == por un >=.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic >= 5) {
 9             roman = "V"
10             arabic -= 5
11         }
12 
13         if (arabic == 4) {
14             roman = "IV"
15             arabic -= 4
16         }
17 
18         while (arabic >= 1) {
19             roman += "I"
20             arabic -= 1
21         }
22 
23         return roman
24     }
25 }

Ha bastado un cambio mínimo para conseguir que el test pase. Los dos siguientes ejemplos pasan sin hacer nada:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18     }
19 
20     private fun assertConvertsToRoman(arabic: Int, roman: String) {
21         assertEquals(roman, RomanNumerals().toRoman(arabic))
22     }
23 }

Esto ocurre porque el algoritmo que tenemos hasta ahora empieza a ser lo bastante general como para contemplar esos casos. Sin embargo, con el 9, tenemos una casuística diferente:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19     }
20 
21     private fun assertConvertsToRoman(arabic: Int, roman: String) {
22         assertEquals(roman, RomanNumerals().toRoman(arabic))
23     }
24 }

El resultado es:

1 expected:<I[X]> but was:<I[V]>
2 Expected :IX
3 Actual   :IV

Necesitamos un tratamiento específico, así que añadimos una condicional para el caso nuevo:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic == 9) {
 9             roman = "IX"
10             arabic -= 9
11         }
12 
13         if (arabic >= 5) {
14             roman = "V"
15             arabic -= 5
16         }
17 
18         if (arabic == 4) {
19             roman = "IV"
20             arabic -= 4
21         }
22 
23         while (arabic >= 1) {
24             roman += "I"
25             arabic -= 1
26         }
27 
28         return roman
29     }
30 }

Seguimos progresando en los ejemplos:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20     }
21 
22     private fun assertConvertsToRoman(arabic: Int, roman: String) {
23         assertEquals(roman, RomanNumerals().toRoman(arabic))
24     }
25 }

Al tratarse de un nuevo símbolo lo abordamos de manera especial:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic == 10) {
 9             roman = "X"
10             arabic -= 10
11         }
12 
13         if (arabic == 9) {
14             roman = "IX"
15             arabic -= 9
16         }
17 
18         if (arabic >= 5) {
19             roman = "V"
20             arabic -= 5
21         }
22 
23         if (arabic == 4) {
24             roman = "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

Si observamos el código de producción vemos estructuras que son similares, pero no está del todo claro un patrón que nos permite hacer una refactor para generalizar. Quizá necesitamos más información. Vamos al caso siguiente:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21     }
22 
23     private fun assertConvertsToRoman(arabic: Int, roman: String) {
24         assertEquals(roman, RomanNumerals().toRoman(arabic))
25     }
26 }

Este test da como resultado:

1 expected:<[X]I> but was:<[VIIIII]I>
2 Expected :XI
3 Actual   :VIIIIII

Para empezar, necesitamos entrar en la condicional del símbolo “X”, así que hacemos este cambio:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic >= 10) {
 9             roman = "X"
10             arabic -= 10
11         }
12 
13         if (arabic == 9) {
14             roman = "IX"
15             arabic -= 9
16         }
17 
18         if (arabic >= 5) {
19             roman = "V"
20             arabic -= 5
21         }
22 
23         if (arabic == 4) {
24             roman = "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

Y esto es suficiente para hacer pasar el test. Con el número 12 y 13, el test sigue pasando, pero al llegar al 14, algo ocurre:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21         assertConvertsToRoman(12, "XII")
22         assertConvertsToRoman(13, "XIII")
23         assertConvertsToRoman(14, "XIV")
24     }
25 
26     private fun assertConvertsToRoman(arabic: Int, roman: String) {
27         assertEquals(roman, RomanNumerals().toRoman(arabic))
28     }
29 }

El resultado es:

1 expected:<[X]IV> but was:<[]IV>
2 Expected :XIV
3 Actual   :IV

Esto sucede porque no estamos acumulando la notación romana en la variable que vamos a retornar, por lo que en algunos lugares machacamos el resultado existente. Hagamos el cambio de una asignación simple a una expresión:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic >= 10) {
 9             roman = "X"
10             arabic -= 10
11         }
12 
13         if (arabic == 9) {
14             roman = "IX"
15             arabic -= 9
16         }
17 
18         if (arabic >= 5) {
19             roman = "V"
20             arabic -= 5
21         }
22 
23         if (arabic == 4) {
24             roman += "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

Este descubrimiento nos indica que podemos probar algunos ejemplos concretos con los que poner de manifiesto este problema y solucionarlo para otros números, por ejemplo el 15.

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21         assertConvertsToRoman(12, "XII")
22         assertConvertsToRoman(13, "XIII")
23         assertConvertsToRoman(14, "XIV")
24         assertConvertsToRoman(15, "XV")
25     }
26 
27     private fun assertConvertsToRoman(arabic: Int, roman: String) {
28         assertEquals(roman, RomanNumerals().toRoman(arabic))
29     }
30 }

Y aplicamos el mismo cambio:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         if (arabic >= 10) {
 9             roman = "X"
10             arabic -= 10
11         }
12 
13         if (arabic == 9) {
14             roman = "IX"
15             arabic -= 9
16         }
17 
18         if (arabic >= 5) {
19             roman += "V"
20             arabic -= 5
21         }
22 
23         if (arabic == 4) {
24             roman += "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

El 19 también tiene la misma solución. Pero si probamos el 20, veremos un nuevo fallo, bastante curioso:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21         assertConvertsToRoman(12, "XII")
22         assertConvertsToRoman(13, "XIII")
23         assertConvertsToRoman(14, "XIV")
24         assertConvertsToRoman(15, "XV")
25         assertConvertsToRoman(19, "XIX")
26         assertConvertsToRoman(20, "XX")
27     }
28 
29     private fun assertConvertsToRoman(arabic: Int, roman: String) {
30         assertEquals(roman, RomanNumerals().toRoman(arabic))
31     }
32 }

Este es el resultado:

1 expected:<X[X]> but was:<X[VIIIII]>
2 Expected :XX
3 Actual   :XVIIIII

El problema es que necesitamos tener que reemplazar todos los 10 contenidos en el número por X.

Cambiando de if a while

Para poder manejar este caso, lo más sencillo es cambiar el if a while. while es una estructura que es a la vez condicional y a la vez un bucle. Mientras que if solo ejecuta la rama condicionada una vez, while lo hace mientras la condición se siga cumpliendo.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         while (arabic >= 10) {
 9             roman += "X"
10             arabic -= 10
11         }
12 
13         if (arabic == 9) {
14             roman += "IX"
15             arabic -= 9
16         }
17 
18         if (arabic >= 5) {
19             roman += "V"
20             arabic -= 5
21         }
22 
23         if (arabic == 4) {
24             roman += "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

¿Podríamos usar while en todos los casos? Ahora que estamos en verde, probaremos a cambiar todas las condiciones de if a while. Y los tests demuestran que es posible hacerlo:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         while (arabic >= 10) {
 9             roman += "X"
10             arabic -= 10
11         }
12 
13         while (arabic == 9) {
14             roman += "IX"
15             arabic -= 9
16         }
17 
18         while (arabic >= 5) {
19             roman += "V"
20             arabic -= 5
21         }
22 
23         while (arabic == 4) {
24             roman += "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

Es interesante porque cada vez las estructuras se van haciendo más parecidas. Probemos ahora a cambiar los casos en que usamos una igualdad para ver si podemos comparar con >= en su lugar.

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         while (arabic >= 10) {
 9             roman += "X"
10             arabic -= 10
11         }
12 
13         while (arabic >= 9) {
14             roman += "IX"
15             arabic -= 9
16         }
17 
18         while (arabic >= 5) {
19             roman += "V"
20             arabic -= 5
21         }
22 
23         while (arabic >= 4) {
24             roman += "IV"
25             arabic -= 4
26         }
27 
28         while (arabic >= 1) {
29             roman += "I"
30             arabic -= 1
31         }
32 
33         return roman
34     }
35 }

Y los test siguen pasando. Esto nos indica un posible refactor para unificar el código.

Introducir arrays (o colecciones)

Es un refactor grande, que vamos a poner aquí en un solo paso. Básicamente, consiste en introducir una estructura de diccionario (Map en Kotlin) que contiene las diversas reglas de conversión:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         val symbols = mapOf(
 9                 10 to "X",
10                 9 to "IX",
11                 5 to "V",
12                 4 to "IV",
13                 1 to "I"
14         )
15 
16         for ((subtrahend, symbol) in symbols) {
17             while (arabic >= subtrahend) {
18                 roman += symbol
19                 arabic -= subtrahend
20             }
21         }
22 
23         return roman
24     }
25 }

Los tests siguen pasando, indicación de que nuestro refactor es correcto. De hecho, no tendríamos fallos hasta llegar al número 39. Algo esperable, porque se introduce un símbolo nuevo:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21         assertConvertsToRoman(12, "XII")
22         assertConvertsToRoman(13, "XIII")
23         assertConvertsToRoman(14, "XIV")
24         assertConvertsToRoman(15, "XV")
25         assertConvertsToRoman(19, "XIX")
26         assertConvertsToRoman(20, "XX")
27         assertConvertsToRoman(40, "XL")
28     }
29 
30     private fun assertConvertsToRoman(arabic: Int, roman: String) {
31         assertEquals(roman, RomanNumerals().toRoman(arabic))
32     }
33 }

La implementación es ahora sencilla:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     fun toRoman(number: Int): String {
 5         var arabic = number
 6         var roman = ""
 7 
 8         val symbols = mapOf(
 9                 40 to "XL",
10                 10 to "X",
11                 9 to "IX",
12                 5 to "V",
13                 4 to "IV",
14                 1 to "I"
15         )
16 
17         for ((subtrahend, symbol) in symbols) {
18             while (arabic >= subtrahend) {
19                 roman += symbol
20                 arabic -= subtrahend
21             }
22         }
23 
24         return roman
25     }
26 }

Y ahora que hemos comprobado que funciona bien, la movemos a un mejor lugar:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     private val symbols: Map<Int, String> = mapOf(
 5             40 to "XL",
 6             10 to "X",
 7             9 to "IX",
 8             5 to "V",
 9             4 to "IV",
10             1 to "I"
11     )
12 
13     fun toRoman(number: Int): String {
14         var arabic = number
15         var roman = ""
16 
17         for ((subtrahend, symbol) in symbols) {
18             while (arabic >= subtrahend) {
19                 roman += symbol
20                 arabic -= subtrahend
21             }
22         }
23 
24         return roman
25     }
26 }

Podríamos seguir añadiendo ejemplos no cubiertos todavía para añadir las reglas de transformación que nos faltan, pero esencialmente este algoritmo ya no va a cambiar, con lo que hemos alcanzado una solución general para convertir cualquier número natural a notación romana. De hecho es así como quedaría. Los tests necesarios, primero:

 1 package org.talkingbit.kata
 2 
 3 import org.junit.jupiter.api.Test
 4 import kotlin.test.assertEquals
 5 
 6 class RomanNumeralsTest {
 7 
 8     @Test
 9     fun `Should convert numbers to roman` () {
10         assertConvertsToRoman(1, "I")
11         assertConvertsToRoman(2, "II")
12         assertConvertsToRoman(3, "III")
13         assertConvertsToRoman(4, "IV")
14         assertConvertsToRoman(5, "V")
15         assertConvertsToRoman(6, "VI")
16         assertConvertsToRoman(7, "VII")
17         assertConvertsToRoman(8, "VIII")
18         assertConvertsToRoman(9, "IX")
19         assertConvertsToRoman(10, "X")
20         assertConvertsToRoman(11, "XI")
21         assertConvertsToRoman(12, "XII")
22         assertConvertsToRoman(13, "XIII")
23         assertConvertsToRoman(14, "XIV")
24         assertConvertsToRoman(15, "XV")
25         assertConvertsToRoman(19, "XIX")
26         assertConvertsToRoman(20, "XX")
27         assertConvertsToRoman(40, "XL")
28         assertConvertsToRoman(50, "L")
29         assertConvertsToRoman(90, "XC")
30         assertConvertsToRoman(100, "C")
31         assertConvertsToRoman(400, "CD")
32         assertConvertsToRoman(500, "D")
33         assertConvertsToRoman(900, "CM")
34         assertConvertsToRoman(1000, "M")
35     }
36 
37     private fun assertConvertsToRoman(arabic: Int, roman: String) {
38         assertEquals(roman, RomanNumerals().toRoman(arabic))
39     }
40 }

Y la implementación:

 1 package org.talkingbit.kata
 2 
 3 class RomanNumerals {
 4     private val symbols: Map<Int, String> = mapOf(
 5             1000 to "M",
 6             900 to "CM",
 7             500 to "D",
 8             400 to "CD",
 9             100 to "C",
10             90 to "XC",
11             50 to "L",
12             40 to "XL",
13             10 to "X",
14             9 to "IX",
15             5 to "V",
16             4 to "IV",
17             1 to "I"
18     )
19 
20     fun toRoman(number: Int): String {
21         var arabic = number
22         var roman = ""
23 
24         for ((subtrahend, symbol) in symbols) {
25             while (arabic >= subtrahend) {
26                 roman += symbol
27                 arabic -= subtrahend
28             }
29         }
30 
31         return roman
32     }
33 }

Podemos probar con diversos tests de aceptación para verificar que es posible generar cualquier número romano:

1         assertConvertsToRoman(623, "DCXXIII")
2         assertConvertsToRoman(1714, "MDCCXIV")
3         assertConvertsToRoman(2938, "MMCMXXXVIII")

Pequeñas transformaciones del código de producción pueden dar lugar a cambios grandes de comportamiento, aunque para eso necesitaremos también dedicar tiempo al refactor, de modo que la introducción de los cambios sea lo más sencilla posible.

Referencias

  • Applying Transformation Priority Premise to Roman Numerals Kata2
  • The Transformation Priority Premise3
  • The Transformation Priority Premise (TPP)4

Prime Factors

Desarrollando algoritmos

Esta kata demuestra que a medida que los tests se hacen más específicos, el algoritmo se vuelve más general. Pero, aparte de eso, es una kata estupenda para reflexionar sobre la elección de ejemplos y por qué no nos sirven los test que pasan en cuanto los escribimos.

Por otro lado, la kata revela un concepto bastante más intrigante: la premisa de prioridad de las transformaciones, según la cual, del mismo modo que hay refactors que son cambios en la estructura de un código que no alteran su comportamiento, existirían transformaciones que son cambios en código que producen cambios en su comportamiento.

Estas transformaciones tendrían un orden, desde las más sencillas a las más complejas, y una prioridad en su aplicación que dicta que deberíamos aplicar antes las más sencillas.

Historia

La kata fue creada por Robert C. Martin1 cuando escribía un programa para su hijo que calculase los factores primos de un número. Pensando sobre su desarrollo, le llamó la atención el modo en que el algoritmo evoluciona y se simplifica a medida que se hace más general.

Enunciado

Escribir una clase con un método generate que devuelva una lista de los factores primos de un número entero. Si prefieres un enfoque más procedural, o incluso funcional, escribir una función primefactors.

Para no complicar el ejercicio el resultado se puede expresar con un array, lista o colección, con los factores no agrupados en forma de potencia. Por ejemplo:

1 primefactors(2) = [2]
2 primefactors(3) = [3]
3 primefactors(6) = [2, 3]
4 primefactors(8) = [2, 2, 2]
5 primefactors(12) = [2, 2, 3]

Orientaciones para resolverla

Esta kata es muy sencilla, a la vez que muy potente: hacen falta pocos ciclos para llevarla a cabo y, sin embargo, pone en evidencia algunas características de TDD especialmente importantes.

Para comenzar, podemos analizar los ejemplos que querríamos probar. En principio los argumentos serán números naturales. Tenemos tres categorías principales:

  • Los que no tienen factores primos, el único caso es el 1.
  • Los que son números primos, como 2, 3 o 5.
  • Los que son producto de varios números primos, como 4, 6, 8 o 9.

Además, dentro de los números que no son primos, nos encontramos con los que son el producto de 2, 3 o n factores, repetidos o no.

Aplicando las leyes de TDD que ya hemos visto, comenzaremos con un test lo más pequeño posible que falle. Luego escribiremos el código de producción necesario para hacer pasar el test.

Iremos recorriendo los distintos casos escribiendo primero el test y, a continuación, el código de producción que lo hace pasar sin romper los tests anteriores.

Una de las curiosidades de esta kata es que podemos ir tomando ejemplos de la lista de números naturales en orden, desde el 1 hasta donde consideremos que podemos parar. Sin embargo, ¿es esta la mejor estrategia? ¿Puede llevarnos a escoger ejemplos que no nos sirven?

Enlaces de interés sobre la kata Prime Factors

  • Explicación de la kata2
  • The Transformation Priority Premise3

Resolviendo la kata Prime Factors

Enunciado de la kata

Nuestro objetivo será escribir un programa que descomponga un número natural en sus factores primos. Por simplicidad, no agruparemos los factores en forma de potencias. Eso lo dejaremos para un ejercicio posterior si te interesa avanzar un poco más.

Lenguaje y enfoque

Esta kata la vamos a hacer en Javascript, con el framework de testing Jest. Crearemos una función primeFactors, a la que le pasamos el número que queremos descomponer y nos devolverá un array con los factores primos, ordenados de menor a mayor.

1 var primesOf18 = primeFactors(18);
2 // -> [2, 3, 3]

Definir la función

Nuestro primer test espera que exista la función primefactors:

1 describe('Calculate prime factors', function () {
2     it ('should exist', () => {
3        expect(primefactors())
4     });
5 });

Que ya sabemos que no ha sido definida todavía:

1 ReferenceError: primefactors is not defined

La introducimos sin más. De momento en el propio archivo del test:

1 function primefactors() {
2     
3 }

De momento, no nos hemos comunicado con la función en el test, así que vamos a introducir esa idea, pasando un primer ejemplo de número para descomponer y el resultado que esperamos. Lo primero que nos debería llamar la atención es que debido a las peculiaridades de la definición y distribución de los números primos entre los números naturales, se nos presenta un orden muy intuitivo para organizar los ejemplos y escribir los tests. Casi nos basta con empezar con el número uno y avanzar progresivamente.

El uno, además, es un caso particular (no tiene factores primos), así que nos viene especialmente bien como primer test.

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it ('should exist', () => {
 5        expect(primefactors())
 6     });
 7 	
 8     it('1 should not have factors', () => {
 9         expect(primefactors(1)).toEqual([]);
10     });
11 });

Para hacer pasar el test necesitamos una implementación mínima de la función:

1 function primefactors() {
2     return [];
3 }
4 
5 export default primefactors;

Fíjate que ni siquiera implementamos que la función recibe un parámetro. Vamos a hacer que sea el test quien nos lo pida. Mientras tanto, eliminamos el primer test, dado que ahora es redundante.

1 import primefactors from "../src/primefactors";
2 
3 describe('Calculate prime factors', function () {	
4     it('1 should not have factors', () => {
5         expect(primefactors(1)).toEqual([]);
6     });
7 });

Definir la signatura de la función

El segundo test nos debería ayudar a definir la signatura de la función. Para ello necesitamos que sea un caso en el que esperemos una respuesta distinta de [], lo cual podremos hacer si recibimos un parámetro que introduzca la variación necesaria. El 2 es un buen ejemplo para conseguir esto:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('2 is a prime number', () => {
 9         expect(primefactors(2)).toEqual([2])
10     })
11 });

Para resolver este caso necesitamos tener en cuenta el parámetro que define la función, lo que nos obliga a introducirlo y utilizarlo. En nuestra solución atendemos al caso que plantea el test anterior y hacemos una implementación obvia para poder pasar el test que acabamos de introducir. Estamos posponiendo la implementación del algoritmo hasta tener más información:

1 function primefactors(numberToDecompose) {
2     if (numberToDecompose === 1) {
3         return [];
4     }
5 
6     return [2];
7 }
8 
9 export default primefactors;

Obteniendo más información sobre el problema

El siguiente caso que vamos a probar es a descomponer el número 3, que es primo como el número 2. Este test nos servirá para entender mejor cómo gestionar estos casos:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('2 is a prime number', () => {
 9         expect(primefactors(2)).toEqual([2])
10     })
11 
12     it ('3 is also a prime number', () => {
13         expect(primefactors(3)).toEqual([3])
14     })
15 });

Ahora que tenemos este test fallando, haremos una implementación obvia, como es devolver el propio número que pasamos. Puesto que es un número primo, es perfectamente correcto. No hay mucho más que rascar aquí.

 1 function primefactors(numberToDecompose) {
 2     if (numberToDecompose === 1) {
 3         return [];
 4     }
 5 
 6     return [numberToDecompose];
 7  
 8 }
 9 
10 export default primefactors;

Introduciendo un test que no falla

En la presentación de la kata hemos dividido los casos en categorías. Repasemos:

  • Casos límite o especiales, como el 1
  • Números primos, como 2, 3 o 5
  • Números no primos, como 4, 6, 8

La primera categoría ya la hemos cubierto, puesto que no hay más casos límite que considerar.

La tercera categoría todavía no hemos empezado a tratarla y no hemos hecho tests con ningún ejemplo de ella.

La segunda categoría es la que hemos estado testeando hasta ahora. En este punto, podríamos seguir tomando ejemplos de esta categoría y probar nuevos casos. Pero, ¿qué pasaría? Veámoslo:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('2 is a prime number', () => {
 9         expect(primefactors(2)).toEqual([2])
10     })
11 
12     it ('3 is also a prime number', () => {
13         expect(primefactors(3)).toEqual([3])
14     })
15 
16     it ('5 is also a prime number', () => {
17         expect(primefactors(5)).toEqual([5])
18     })
19     
20 });

¡El test pasa sin implementar nada nuevo!

Era bastante obvio, ¿no? En este momento, el algoritmo, por llamarlo de algún modo, no hace nada más que considerar todos los números como primos. Por esta razón, si seguimos usando como ejemplos números primos, nada nos obligará a hacer cambios en la implementación.

Cuando añadimos un test que no falla significa que el algoritmo que estamos desarrollando ya es lo bastante general para resolver esa categoría de casos y, por tanto, es hora de pasar a otra categoría que todavía no pueda ser manejada con éxito. O, si ya hemos cubierto todas las categorías posibles, es que hemos terminado.

Empezaremos a utilizar ejemplos de la categoría de los números no primos. Pero igualmente vamos a refactorizar el test para ver estas categorías de forma más explícita:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 });

Cuestionando nuestro algoritmo

El primer número no primo que tenemos es el 4, y es el más sencillo de todos por muchos motivos, así que hacemos un test que esta vez fallará:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 });

Hay varias formas de plantear esta implementación. Por ejemplo, tenemos esta que es especialmente ingenua, pero eficaz:

 1 function primefactors(numberToDecompose) {
 2     if (numberToDecompose === 1) {
 3         return [];
 4     }
 5 
 6     if (numberToDecompose === 4) {
 7         return [2, 2];
 8     }
 9 
10     return [numberToDecompose];
11 }
12 
13 export default primefactors;

A pesar de lo simplona, es interesante. Nos ayuda a entender que tenemos que distinguir entre números primos y no primos para poder desarrollar el algoritmo.

Sin embargo, tiene una pinta muy deslavazada. Vamos a intentar organizarla un poco mejor:

 1 function primefactors(numberToDecompose) {
 2     if (numberToDecompose > 1) {
 3         if (numberToDecompose === 4) {
 4             return [2, 2];
 5         }
 6     
 7         return [numberToDecompose];
 8     }
 9 
10     return [];
11 }
12 
13 export default primefactors;

Básicamente, dice: si un número es mayor que uno, intentamos descomponerlo. Si es cuatro, devolvemos su factorización y, si no, devolvemos el mismo número porque será primo. Lo que es cierto para los ejemplos que tenemos ahora mismo.

Descubriendo los múltiplos de 2

El siguiente número que podemos descomponer es el 6. Una cosa buena de esta kata es que cada nuevo número no primo nos da una respuesta diferente y eso quiere decir que cada test nos aportará información. Helo aquí

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 
18     it ('6 is 2 * 3', () => {
19         expect(primefactors(6)).toEqual([2, 3])
20     })
21 });

Vamos a empezar por la implementación ingenua:

 1 function primefactors(numberToDecompose) {
 2     if (numberToDecompose > 1) {
 3         if (numberToDecompose === 4) {
 4             return [2, 2];
 5         }
 6     
 7         if (numberToDecompose === 6) {
 8             return [2, 3];
 9         }
10 
11         return [numberToDecompose];
12     }
13 
14     return [];
15 }
16 
17 export default primefactors;

No hay nada de malo en hacerlo así. Al contrario, esta forma de resolver el problema nos empieza a poner de relieve regularidades. 4 y 6 son múltiplos de 2, por lo que queremos introducir ese conocimiento en forma de refactor. Y eso lo podemos hacer gracias a que tenemos tests que nos demuestran que la función ya los descompone correctamente. Así que vamos a modificar el código sin cambiar ese comportamiento que ya hemos definido con los tests.

Nuestro primer intento se basa en que el primer factor primo es 2 y es común. Es decir, podemos hacer un algoritmo que procese múltiplos de 2 y, de momento, asumimos que el número que queda como resto de la primera división por 2 es el segundo factor del compuesto, sea cual sea.

Para ello tenemos que introducir una variable de tipo array con la que entregar la respuesta, a la que le vamos añadiendo los factores que descubramos:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose > 1) {
 6         if (numberToDecompose === 4) {
 7             factors.push(2);
 8             factors.push(2);
 9 
10             return factors;
11         }
12     
13         if (numberToDecompose === 6) {
14             factors.push(2);
15             factors.push(3);
16             
17             return factors;
18         }
19 
20         factors.push(numberToDecompose);
21     }
22 
23     return factors;
24 }
25 
26 export default primefactors;

Este ha sido un primer paso, ahora ya nos queda más claro cómo funcionaría y lo podemos generalizar, expresándolo así:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose > 1) {
 6         if (numberToDecompose % 2 === 0) {
 7             factors.push(2);
 8             factors.push(Math.floor(numberToDecompose/2));
 9 
10             return factors;
11         }
12     
13         factors.push(numberToDecompose);
14     }
15 
16     return factors;
17 }
18 
19 export default primefactors;

Este refactor casi funciona, pero ha dejado de pasar el test del número 2. Arreglamos eso y avanzamos un paso más:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose > 1) {
 6         if (numberToDecompose % 2 === 0 && numberToDecompose > 2) {
 7             factors.push(2);
 8             numberToDecompose = Math.floor(numberToDecompose/2);
 9         }
10     
11         factors.push(numberToDecompose);
12     }
13 
14     return factors;
15 }
16 
17 export default primefactors;

Esta nueva implementación hace pasar todos los tests y estamos listos para forzar un nuevo cambio.

Introduciendo más factores

Dentro de los números no primos podríamos considerar varias agrupaciones a la hora de seleccionar ejemplos. Tenemos casos en que los números se descomponen en el producto de dos factores primos, y casos en los que se descomponen en el producto de 3 o más factores. Esto viene a cuenta porque nuestros próximos ejemplos son 8 y 9. El 8 es 2 * 2 * 2, mientras que 9 es 3 * 3. El caso del 8 nos obliga a considerar los casos en que podemos descomponer un número en más de dos factores, y el del 9, aquellos casos en los que se introducen nuevos divisores.

En principio puede darnos igual empezar por cualquiera de los dos. Quizá la clave sea escoger el caso que te parezca más fácil de abordar. Aquí vamos a empezar por descomponer el número 8. De este modo, mantenemos el divisor 2 que, en este momento, nos parece algo más fácil de abordar.

Hagamos un test:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 
18     it ('6 is 2 * 3', () => {
19         expect(primefactors(6)).toEqual([2, 3])
20     })
21 
22     it ('8 is 2 * 2 * 2', () => {
23         expect(primefactors(8)).toEqual([2, 2, 2])
24     })
25 });

Para implementar tenemos que cambiar un if por un while. Es decir, tenemos que seguir dividiendo el número por 2 hasta que ya no podamos más:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose > 1) {
 6         while (numberToDecompose % 2 === 0 && numberToDecompose > 2) {
 7             factors.push(2);
 8             numberToDecompose = Math.floor(numberToDecompose/2);
 9         }
10     
11         factors.push(numberToDecompose);
12     }
13 
14     return factors;
15 }
16 
17 export default primefactors;

Este cambio es muy espectacular porque es muy pequeño y muy potente. Aplicándolo podemos descomponer cualquier número que sea potencia de 2, ni más ni menos. Pero no es este el objetivo final, sino que queremos descomponer cualquier número y para eso tenemos que poder introducir nuevos divisores.

Nuevos divisores

En este punto necesitamos un ejemplo que nos obligue a introducir nuevos divisores. Antes hemos dejado aparcado el 9, y ahora nos toca retomarlo. El 9 es un buen ejemplo porque es múltiplo de 3, sin serlo de 2. Vamos a hacer un test que sabemos que fallará:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 
18     it ('6 is 2 * 3', () => {
19         expect(primefactors(6)).toEqual([2, 3])
20     })
21 
22     it ('8 is 2 * 2 * 2', () => {
23         expect(primefactors(8)).toEqual([2, 2, 2])
24     })
25 
26     it ('9 is 3 * 3', () => {
27         expect(primefactors(9)).toEqual([3, 3])
28     })
29 });

De nuevo, empecemos con una implementación muy ingenua, pero que funciona. Lo importante es que el test pase, prueba de que hemos implementado el comportamiento.

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose > 1) {
 6         while (numberToDecompose % 2 === 0 && numberToDecompose > 2) {
 7             factors.push(2);
 8             numberToDecompose = Math.floor(numberToDecompose/2);
 9         }
10 
11         while (numberToDecompose % 3 === 0 && numberToDecompose > 3) {
12             factors.push(3);
13             numberToDecompose = Math.floor(numberToDecompose / 3);
14         }
15     
16         factors.push(numberToDecompose);
17     }
18 
19     return factors;
20 }
21 
22 export default primefactors;

Con el código anterior, todos los tests están en verde. En este punto parece claro que cada nuevo divisor que queramos introducir, como el 5, necesitará una repetición del bloque así que vamos a refactorizar a una solución general.

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     let divisor = 2;
 6 
 7     while (numberToDecompose > 1) {
 8         while (numberToDecompose % divisor === 0) {
 9             factors.push(divisor);
10             numberToDecompose = Math.floor(numberToDecompose / divisor);
11         }
12         divisor++;
13     }
14 
15     return factors;
16 }
17 
18 export default primefactors;

Este algoritmo tiene pinta de ser bastante general. Así que probemos un par de casos:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('2 is a prime number', () => {
 9         expect(primefactors(2)).toEqual([2])
10     })
11 
12     it ('3 is also a prime number', () => {
13         expect(primefactors(3)).toEqual([3])
14     })
15 
16     it ('4 is 2 * 2', () => {
17         expect(primefactors(4)).toEqual([2, 2])
18     })
19     
20     it ('6 is 2 * 3', () => {
21         expect(primefactors(6)).toEqual([2, 3])
22     })
23 
24     it ('8 is 2 * 2 * 2', () => {
25         expect(primefactors(8)).toEqual([2, 2, 2])
26     })
27 
28     it ('9 is 3 * 3', () => {
29         expect(primefactors(9)).toEqual([3, 3])
30     })
31 
32     it ('10 is 2 * 5', () => {
33         expect(primefactors(10)).toEqual([2, 5])
34     })
35         
36     it ('12 is 2 * 2 * 3', () => {
37         expect(primefactors(12)).toEqual([2, 2, 3])
38     })
39 });

Hemos añadido dos tests que pasan. Por lo que parece, hemos resuelto el problema. Pero, ¿no te queda la sensación de haber saltado demasiado en este último paso?

El camino más corto no siempre es el más rápido

El camino de desarrollo en TDD no siempre es fácil. El siguiente test a veces es bastante evidente y otras veces tenemos varias alternativas. Escoger mal el camino nos puede llevar a un callejón sin salida o, como en este caso, a un punto en que tengamos que implementar mucho de golpe. Y como hemos visto, los cambios que añadamos al código de producción deberían ser lo más pequeños posible.

En el sexto test optamos por explorar la vía de las repeticiones del mismo factor en lugar de forzar que aparecieran otros factores primos. ¿Hubiera sido mejor seguir esta ramificación del problema? Probémoslo, rebobinamos y volvemos a la situación antes del sexto test.

Introduciendo nuevos factores, segundo intento

Esta es la versión del código de producción en la que estábamos al llegar al sexto test:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose % 2 === 0) {
 6         factors.push(2);
 7         numberToDecompose = Math.floor(numberToDecompose / 2);
 8     }
 9     
10     if (numberToDecompose > 1) {
11         factors.push(numberToDecompose);
12     }
13 
14     return factors;
15 }
16 
17 export default primefactors;

Ahora sigamos por la otra ruta:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 
18     it ('6 is 2 * 3', () => {
19         expect(primefactors(6)).toEqual([2, 3])
20     })
21     
22     it ('9 is 3 * 3', () => {
23         expect(primefactors(9)).toEqual([3, 3])
24     })
25 });

El siguiente código de producción nos permite pasar el nuevo test y todos los anteriores:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     if (numberToDecompose % 2 === 0) {
 6         factors.push(2);
 7         numberToDecompose = Math.floor(numberToDecompose / 2);
 8     }
 9 
10     if (numberToDecompose % 3 === 0) {
11         factors.push(3);
12         numberToDecompose = Math.floor(numberToDecompose / 3);
13     }
14     
15     if (numberToDecompose > 1) {
16         factors.push(numberToDecompose);
17     }
18 
19     return factors;
20 }
21 
22 export default primefactors;

Ahora podríamos refactorizar:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     let divisor = 2;
 6 
 7     while (divisor < numberToDecompose) {
 8         if (numberToDecompose % divisor === 0) {
 9             factors.push(divisor);
10             numberToDecompose = Math.floor(numberToDecompose / divisor);
11         }
12         divisor++;
13     }
14     
15     if (numberToDecompose > 1) {
16         factors.push(numberToDecompose);
17     }
18     
19     return factors;
20 }
21 
22 export default primefactors;

Más de dos factores

Para introducir más de dos factores necesitamos un test:

 1 import primefactors from "../src/primefactors";
 2 
 3 describe('Calculate prime factors', function () {
 4     it('1 should not have factors', () => {
 5         expect(primefactors(1)).toEqual([]);
 6     });
 7 
 8     it ('prime numbers cannot be decomposed', () => {
 9         expect(primefactors(2)).toEqual([2])
10         expect(primefactors(3)).toEqual([3])
11         expect(primefactors(5)).toEqual([5])
12     })
13 
14     it ('4 is 2 * 2', () => {
15         expect(primefactors(4)).toEqual([2, 2])
16     })
17 
18     it ('6 is 2 * 3', () => {
19         expect(primefactors(6)).toEqual([2, 3])
20     })
21 
22     it ('9 is 3 * 3', () => {
23         expect(primefactors(9)).toEqual([3, 3])
24     })
25 
26     it ('8 is 2 * 2 * 2', () => {
27         expect(primefactors(8)).toEqual([2, 2, 2])
28     })
29 });

El cambio necesario es sencillo:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     let divisor = 2;
 6 
 7     while (divisor < numberToDecompose) {
 8         while (numberToDecompose % divisor === 0) {
 9             factors.push(divisor);
10             numberToDecompose = Math.floor(numberToDecompose / divisor);
11         }
12         divisor++;
13     }
14     
15     if (numberToDecompose > 1) {
16         factors.push(numberToDecompose);
17     }
18     
19     return factors;
20 }
21 
22 export default primefactors;

Y podemos librarnos del último if puesto que queda cubierto por el while que acabamos de introducir:

 1 function primefactors(numberToDecompose) {
 2 
 3     let factors = [];
 4 
 5     let divisor = 2;
 6 
 7     while (divisor <= numberToDecompose) {
 8         while (numberToDecompose % divisor === 0) {
 9             factors.push(divisor);
10             numberToDecompose = Math.floor(numberToDecompose / divisor);
11         }
12         divisor++;
13     }
14     
15     return factors;
16 }
17 
18 export default primefactors;

Si añadimos nuevos tests veremos que podemos factorizar cualquier número sin problemas. Es decir, con este último cambio y su refactor hemos terminado el desarrollo de la clase. ¿Ha sido mejor este camino? En parte sí. Hemos llegado a un algoritmo casi idéntico, pero diría que el recorrido ha sido más suave, los saltos de código de producción menos pronunciados y todo ha ido mejor hilado.

¿Tenemos criterios para elegir buenos ejemplos?

Desde el punto de vista de QA tradicional existen una serie de métodos para elegir los casos de test. Sin embargo, estos métodos no son necesariamente aplicables en TDD. Recuerda cómo empezábamos este libro: QA y TDD no son lo mismo pese a usar las mismas herramientas y solaparse mucho ambas disciplinas. TDD es una metodología para guiar el desarrollo de software y los tests más adecuados para hacerlo pueden ser sutilmente diferentes de los que usaríamos para verificar el funcionamiento de un software.

Por ejemplo, la categorización que hemos hecho de los números en primos y no primos puede ser más que suficiente en QA, pero en TDD los casos de números no primos podrían subdividirse:

  • Potencias de un factor primo, como el 4, el 8 o el 9, que implican un único primo multiplicado varias veces por sí mismo.
  • Producto de diferentes primos, como el 6, el 10, que implican varios números primos.
  • Productos de n factores primos, con n mayor que dos.

Cada una de estas categorías nos fuerza a implementar distintas partes del algoritmo, lo que puede plantearnos problemas más o menos fáciles de resolver. Incluso, una mala elección podría llevarnos a un callejón sin salida.

Sin embargo, nada nos impide volver atrás si nos quedamos estancados. Cuando tengamos una duda razonable de si tomar un curso u otro en TDD, lo mejor es tomar nota de cuál es el estado del desarrollo y ese punto, marcándolo como un punto de retorno en caso de que nos metamos en algún cenagal de código. Marcha atrás y pensar de nuevo. Haberse equivocado es también información.

Qué hemos aprendido con esta kata

  • Con esta kata hemos aprendido cómo a medida que añadimos tests y estos son más específicos, el algoritmo se hace más general
  • También hemos visto que obtenemos mejores resultados priorizando las transformaciones (cambios en el código de producción) más sencillas

La elección del primer test

En TDD la elección del primer test es un problema interesante. En algunos trabajos y tutoriales sobre TDD se suele hablar del “caso más sencillo” y ahí se queda la recomendación. Pero en realidad deberíamos acostumbrarnos a buscar el test más pequeño que pueda fallar, que suele ser una cosa muy distinta.

Con todo, no parece una definición muy práctica, así que posiblemente merece una explicación más detenida. ¿Hay una forma más o menos objetiva de decidir cuál es el test mínimo que pueda fallar?

A la búsqueda del primer test más sencillo que pueda fallar

Supongamos la kata Roman Numerals. Consiste en crear un convertidor entre números decimales y romanos. Decidimos que la clase va a ser RomanNumeralsConverter y la función se llama toRoman, de modo que se utilizaría más o menos así:

1 $roman = $romanNumeralsConverter->toRoman(54)

Según el enfoque del “caso más sencillo” podríamos hacer un test más o menos como este:

1 public function testShouldConvertOne(): void
2 {
3     $converter = new RomanNumeralsConverter();
4     
5     $this->assertEquals("I", $converter->toRoman(1));
6 }

Parece correcto, ¿verdad? Sin embargo, este no es el test más sencillo que pueda fallar. En realidad hay como mínimo dos aún más sencillos que pueden fallar y ambos nos forzarán a crear código de producción.

– Pensemos un momento sobre el test que acabamos de escribir: ¿qué ocurrirá si lo ejecutamos?

– Fallará.

– Pero, ¿por qué fallará?

– Obvio: porque ni siquiera tenemos la clase definida. Al intentar ejecutar el test, no encuentra la clase.

– ¿Podemos decir que el test falla por la razón que esperamos que falle?

– Humm. ¿Qué quieres decir?

– Quiero decir que el test establece que esperamos que pueda convertir el decimal 1 en el romano I. Debería fallar porque no hace la conversión, no porque no encuentre la clase. En realidad el test puede fallar por, como mínimo, tres causas: que la clase no exista, que la clase no tenga el método toRoman y que no devuelva el resultado “I”. Y solo debería fallar por una.

– ¿Me estás diciendo que el primer test debería ser simplemente instanciar la clase?

– Sí.

– ¿Y qué sentido tiene?

– Que el test, al fallar, solo puede fallar por la razón que esperamos que falle.

– Tengo que pensar en eso un momento.

– Sin problema. Te espero en el párrafo siguiente.

Esta es la cuestión. Para ser el caso más sencillo, este primer test puede fallar por tres motivos diferentes que consideramos como que el test no pasa (recuerda, la segunda ley: no compilar es fallar), por tanto, deberíamos reducirlo para que solo falle por una causa.

Como nota al margen: es cierto que podría fallar por otras muchas causas, como equivocarnos de nombre al escribir, poner la clase en el namespace incorrecto, etc. Asumimos que esos errores son involuntarios. Además, al ejecutar el test nos dirá el error. De ahí la importancia de lanzar los tests, ver cómo fallan y asegurarnos de que fallan como es debido.

Vayamos más despacio, entonces.

El primer test solo debería pedir que exista la clase y se pueda instanciar.

1 public function testShouldInstantiate(): void
2 {
3     $this->expectNotToPerformAssertions();
4     
5     new RomanNumeralsConverter();
6 }

En PHPUnit un test sin aserciones falla o al menos se considera risky. Para hacer que pase claramente especificamos que no vamos a realizar aserciones. En otros lenguajes esto es innecesario.

Para pasar el test tengo que crear la clase. Una vez creada veré que el test pasa y entonces podré afrontar el siguiente paso.

El segundo test debería obligarme a definir el método deseado en la clase, aunque todavía no sepamos qué hacer con él o qué parámetros necesitará.

1 public function testShouldBeAbleToConvertToRoman(): void
2 {
3     $this->expectNotToPerformAssertions();
4     
5     $converter = new RomanNumeralsConverter();
6     $converter->toRoman();
7 }

De nuevo, este test es un poco especial en PHP. Por ejemplo, en PHP y otros lenguajes podemos ignorar el retorno de un método si no está tipado. Otros lenguajes nos exigirán tipar el método de manera explícita, cosa que en este paso podría hacerse con void para que no devuelva nada. Otra estrategia sería devolver un valor vacío del tipo adecuado (en este caso string). Hay lenguajes, por su parte, que requieren que se use el resultado del método si es que se devuelve, pero también permiten ignorarlo.

Un asunto interesante es que una vez que hemos hecho pasar este segundo test, el primero es redundante: el caso está cubierto por este segundo test y si un cambio en el código lo hace fallar, el segundo test fallará también. En la fase de refactor puedes borrarlo.

Y es ahora cuando el “caso más sencillo” tiene sentido porque este test, tras los anteriores fallará por la razón adecuada:

1 public function testShouldConvertOne(): void
2 {
3     $converter = new RomanNumeralsConverter();
4     
5     $this->assertEquals("I", $converter->toRoman(1));
6 }

Este ya es un test que fallaría por la razón esperada: la clase no tiene nada definido que realice la conversión.

De nuevo, una vez hagas pasar este test y estés en fase de refactor, el anterior puedes borrarlo. Ha cumplido su función de forzarnos a añadir un cambio en el código. Además, en caso de que ese segundo test falle por un cambio en el código, nuestro test actual también fallará. Por tanto, puedes borrarlo también.

Supongo que ahora te haces dos preguntas:

  • Por qué hacer tres tests para quedarme con el que había pensado al principio
  • Por qué puedo borrar esos tests

Vayamos por partes, entonces.

Por qué empezar con pasos tan pequeños

Un test solo debería tener una razón para fallar. Imagínalo como una aplicación del Single Responsibility Principle. Si un test tiene más de una razón para fallar lo más posible es que estemos queriendo hacer que un test provoque muchos cambios a la vez en el código.

Es cierto que en testing existe una técnica llamada triangulación en la que justamente se verifican varios posibles aspectos que deben ocurrir juntos para considerar que el test pasa o falla. Pero, como hemos dicho al principio del libro TDD no es QA, por lo que no son aplicables las mismas técnicas.

En TDD lo que queremos es que los tests nos digan cuál es el cambio que tenemos que producir en el software y este cambio debería ser lo más pequeño posible y lo menos ambiguo posible.

Cuando no tenemos nada de código de producción escrito, lo más pequeño que podemos hacer es crear un archivo en el que se defina la función o clase que estamos desarrollando. Es el primer paso. Y aun así, hay posibilidades de que no lo hagamos correctamente:

  • nos equivocamos en el nombre del archivo
  • nos equivocamos en su ubicación en el proyecto
  • nos equivocamos al teclear el nombre de la clase o función
  • nos equivocamos al situarla en un name space

Tenemos que evitar todos estos problemas solo para conseguir instanciar una clase o poder usar una función, pero ese test mínimo fallará si pasa cualquiera de esas cosas. Al corregir todas las que puedan suceder haremos que el test pase.

Sin embargo, si el test puede fallar por más razones, se multiplicarán las potenciales fuentes de errores porque hay que hacer más cosas para hacerlo pasar. Además, algunas de ellas pueden depender y entremezclarse entre sí. En general, el cambio necesario en el código de producción será demasiado grande con el test en rojo y, por tanto, hacerlo pasar a verde será más costoso y menos obvio.

Por qué borrar estos primeros tests

En TDD muchos tests son redundantes. Desde el punto de vista de QA en TDD se testea demasiado. En primer lugar, porque muchas veces se utilizan varios ejemplos de la misma clase, precisamente para encontrar la regla general que caracteriza a esa clase. Por otro lado, hay tests que hacemos en TDD que están incluidos en otros.

Es el caso de estos primeros tests que hemos mostrado hace un momento.

El test que nos fuerza a definir la unidad de software por primera vez está incluido en cualquier otro test que podamos imaginar por la simple razón de que para que se puedan ejecutar esos otros tests necesitamos la clase. Dicho de otro modo, si falla el primer test, fallarán todos los demás.

En esta situación el test es redundante y lo podemos borrar.

No siempre es fácil identificar tests redundantes. En algunas etapas de TDD usamos ejemplos de la misma clase para mover el desarrollo, con lo cual puede llegar un punto en que algunos de esos tests sean redundantes y podamos borrarlos por innecesarios.

Por otro lado, otra posibilidad es refactorizar los tests con data providers o técnicas similares con las que abaratar la creación de nuevos ejemplos.

La felicidad de los paths

Happy path testing

Denominamos happy path al flujo de un programa cuando no se producen problemas y puede ejecutar el algoritmo completo. El happy path ocurre cuando los datos de entrada son válidos y no se generan errores en el proceso porque todos los datos manejados están en sus rangos de valores aceptables y no se producen otros fallos que puedan afectar a la unidad de software que estamos desarrollando.

El happy path testing en TDD consiste en escoger ejemplos que deberían dar como resultado valores predecibles, sobre los que podemos testear. Por ejemplo, en la kata Roman Numerals, un ejemplo de test de happy path sería:

1 public function testShouldConvertFour(): void
2 {
3     $converter = new RomanNumeralsConverter();
4     
5     $this->assertEquals("IV", $converter->toRoman(4));
6 }

Con mucha frecuencia en las katas trabajamos con tests de happy path. Esto es así porque nos interesa centrarnos en el desarrollo del algoritmo y que el ejercicio no dure demasiado tiempo.

Sad path testing

Por el contrario, los sad paths son aquellos flujos del programa que terminan mal. Cuando decimos que terminan mal queremos decir que se produce un error y no se puede terminar de ejecutar el algoritmo.

Sin embargo, los errores y la forma en la que el código de producción se enfrenta a ellos, forman parte del comportamiento del software y en el trabajo real merecen ser considerados al utilizar la metodología TDD.

En ese sentido, el sad path testing sería justamente la elección de casos de tests que describen situaciones en las que el código de producción tiene que manejar datos de entrada incorrectos o respuestas de sus colaboradores que debemos tratar también. Un ejemplo de esto sería algo así:

1 public function testShouldFailWithInvalidInput(): void
2 {
3     $converter = new RomanNumeralsConverter();
4     
5     $this->expectException(InvalidInput::class);
6     $converter->toRoman(-12.34);
7 }

Esto es: nuestro convertidor de números romanos no puede manejar números negativos ni números con decimales y, por tanto, en un programa real tendríamos que gestionar esta situación. En el ejemplo, la consecuencia es lanzar una excepción. Pero podría ser cualquier otra forma de reacción que nos convenga para los propósitos de la aplicación.

1 public function testShouldFailWithInvalidInput(): void
2 {
3     $converter = new RomanNumeralsConverter();
4     
5     $this->assertEquals('Non possum hic numerus converto', $converter->\
6 toRoman(-12.34));
7 }

NIF

Comienza por los sad paths y aplaza las soluciones

Esta kata consiste originalmente en crear un Value Object para representar el NIF, o número de identificación fiscal español. Su forma habitual es una cadena de ocho caracteres numéricos y una letra de control, que nos ayuda a asegurar su validez.

Al tratarse de un Value Object queremos poder instanciarlo a partir de un string y garantizar que sea válido para poder utilizarlo sin problemas en cualquier lugar del código de una aplicación.

Una de las dificultades para desarrollar este tipo de objetos en TDD es que a veces no necesitan implementar comportamientos significativos y es más importante asegurarse de que se crean consistentes.

El algoritmo para validarlo es relativamente sencillo, como veremos, pero el interés está sobre todo en cómo librarnos de todas las cadenas de caracteres que no pueden formar un NIF válido.

Historia

Esta kata es original y surgió por casualidad al preparar un pequeño taller de introducción a TDD y live coding acerca de los beneficios de utilizar la metodología en el trabajo cotidiano.

Al profundizar en este ejemplo se fueron poniendo de manifiesto dos cuestiones que resultan muy interesantes:

  • Empezar por tests que descarten los casos inválidos nos permite evitar atacar el desarrollo del algoritmo nada más empezar, quitándolos de en medio y reduciendo el espacio del problema. La consecuencia es que acabamos desarrollando objetos más resilientes, con algoritmos más limpios y contribuye a prevenir la aparición de bugs en el código de producción.
  • Se pone de manifiesto el mecanismo de aplazar la solución de cada problema hasta el siguiente test. Es decir: para hacer que cada nuevo test pase, introducimos una implementación inflexible que nos permita cumplir ese test, pero para que los anteriores sigan pasando nos vemos obligados a refactorizar el código que ya teníamos antes.

Enunciado

Crear una clase Nif, que será un Value Object para representar el Número de Identificación Fiscal de España. Este número es una cadena de ocho caracteres numéricos, con una letra final que actúa como carácter de control.

Esta letra de control se obtiene calculando el resto de dividir la parte numérica del NIF entre 23 (mod 23). El resultado nos indica la fila en la que consultar la letra de control de la siguiente tabla:

Resto Letra
0 T
1 R
2 W
3 A
4 G
5 M
6 Y
7 F
8 P
9 D
10 X
11 B
12 N
13 J
14 Z
15 S
16 Q
17 V
18 H
19 L
20 C
21 K
22 E

Existe un caso especial de NIF que es el NIE o Número de Identificación para Extranjeros. En este caso, el primer carácter será una letra X, Y o Z. Para el cálculo del mod 23 se reemplazan por los valores 0, 1 y 2, respectivamente.

Orientaciones para resolverla

Esta kata nos puede aportar varios aprendizajes tanto de TDD como de tipos de datos y validación.

En las katas es habitual obviar temas como la validación de datos para simplificar el ejercicio y centrarnos en el desarrollo del algoritmo. En un desarrollo real no podemos hacer esto, sino justamente poner mucho énfasis en validar los datos a distintos niveles, tanto por razones de seguridad como para evitar errores en los cálculos.

Así que hemos incluido esta kata precisamente para practicar cómo desarrollar mediante TDD algoritmos que primero gestionan todos los valores que no pueden manejar tanto desde el punto de vista estructural como de dominio.

En concreto este ejemplo se baja en el hecho de que el comportamiento efectivo del constructor que vamos a crear es asignar el valor que le pasamos. Todo lo demás que hace es comprobar que ese valor es adecuado para eso, por lo que actúa como barrera de valores indeseados.

Al tratarse de un Value Object intentaremos crear una clase a la que se le pasa la cadena candidata en el constructor. Si la cadena resulta ser inválida para un NIF el constructor lanzará una excepción, impidiendo que se puedan instanciar objetos con valores inadecuados. Fundamentalmente, nuestros primeros tests esperarán excepciones o errores.

De todas las infinitas cadenas que podría recibir este constructor solo unas pocas serán NIF válidos, por lo que nuestro primer objetivo podría ser eliminar las más obvias: las que nunca podrían serlo porque tienen el número de caracteres inadecuado.

En una segunda fase, buscaremos controlar aquellas que no podrían nunca ser un NIF por su estructura, básicamente porque no siguen el esquema de ocho caracteres numéricos y una letra final, teniendo en cuanta la excepción de los NIE, que sí podrían tener una letra al principio.

Con esto tendríamos todo preparado para implementar el algoritmo de validación, ya que slo tendríamos que manejar strings que estructuralmente podrían ser NIF.

Una cosa que los pasos anteriores nos garantizan es que los tests no empezarán a fallar cuando introduzcamos el algoritmo, debido a que sus ejemplos nunca podrían ser válidos. Si comenzásemos usando string que estructuralmente podrían ser NIF, aunque los hayamos escrito al azar, podríamos encontrarnos con alguno que casualmente fuese válido y al implementar la parte correspondiente del algoritmo ese test fallaría por la razón equivocada.

Enlaces de interés sobre la kata

  • La kata del NIF para aprender TDD1

Resolviendo la Kata NIF

En este kata vamos a orientarnos en una estrategia que aborde primero los sad paths, es decir, vamos a tratar primero los casos que provocarían un error. De este modo, primero desarrollaremos la validación de la estructura del input, para pasar después al del algoritmo.

Es habitual que las katas obvien temas como las validaciones, pero en este caso hemos preferido hacer un ejemplo que es más realista, en el sentido de que es una situación con la que lidiamos habitualmente. En el código de un proyecto en producción es fundamental la validación de datos de entrada y no está de más practicar con un ejercicio que pone su foco casi exclusivamente en ello.

Aparte, veremos un par de técnicas interesantes para transformar una interfaz pública sin romper los tests.

Enunciado de la kata

Crear una clase Nif, que será un Value Object para representar el Número de Identificación Fiscal de España. Es una combinación de una cadena de ocho caracteres numéricos, con una letra final que actúa como carácter de control.

Esta letra de control se obtiene calculando el resto de dividir la parte numérica del NIF entre 23 (mod 23). El resultado nos indica la fila en la que consultar la letra de control de la siguiente tabla. En esta tabla he incluido también ejemplos sencillos de NIF válidos para que los puedas usar en los tests.

Parte numérica Resto Letra Ejemplo NIF válido
00000023 0 T 00000023T
00000024 1 R 00000024R
00000025 2 W 00000025W
00000026 3 A 00000026A
00000027 4 G 00000027G
00000028 5 M 00000028M
00000029 6 Y 00000029Y
00000030 7 F 00000030F
00000031 8 P 00000031P
00000032 9 D 00000032D
00000033 10 X 00000033X
00000034 11 B 00000034B
00000035 12 N 00000035N
00000036 13 J 00000036J
00000037 14 Z 00000037Z
00000038 15 S 00000038S
00000039 16 Q 00000039Q
00000040 17 V 00000040V
00000041 18 H 00000041H
00000042 19 L 00000042L
00000043 20 C 00000043C
00000044 21 K 00000044K
00000045 22 E 00000045E

Puedes crear NIF inválidos simplemente escogiendo una parte numérica y la letra que no le corresponde.

Ejemplo inválido
00000000S
00000001M
00000002H
00000003Q
00000004E

Hay una excepción: los NIF para extranjeras (o NIE) pueden empezar por las letras X, Y o Z, que a los efectos de los cálculos se reemplazan por los números 0, 1 y 2, respectivamente. En ese caso X0000000T equivale a 00000000T.

Para evitar confusiones se han excluido las letras U, I, O y Ñ.

Una cadena que empieza por una letra distinta de X, Y, Z, o que contenga caracteres alfabéticos en las posiciones centrales también es inválida.

Lenguaje y enfoque

Esta kata la vamos a resolver en Go, por lo que vamos a matizar un poco su resultado. En este ejemplo vamos a crear un tipo de dato Nif, que será básicamente un string, y una función factoría NewNif que nos permitirá construir NIF validados a partir de un string que le pasamos.

Por otro lado, el testing en Go es también un poco particular. Aunque el lenguaje incorpora de forma estándar soporte para realizar tests, no incluye utilidades habituales como asserts.

Disclaimer

Para resolver esta kata me voy a aprovechar de la forma en que Go gestiona los errores. Estos se pueden devolver como una de las respuestas de una función, lo que te obliga a gestionarlos siempre de manera explícita.

Basar tests en mensajes de error no es una buena práctica, porque pueden cambiar con facilidad haciendo fallar los tests aunque no haya realmente una alteración de la funcionalidad. Sin embargo, en esta kata vamos a usar los mensajes de error como una especie de comodín temporal en el que apoyarnos haciendo que cambien de más específicos a más generales. Al acabar el ejercicio manejaremos únicamente dos errores posibles.

Creando la función constructora

En esta kata nos interesa empezar centrándonos en los sad paths, los casos en los que no vamos a poder usar el argumento pasado a la función constructora. De todas las innumerables combinaciones de string que la función podría recibir vamos primero a dar una respuesta a las que sabemos con seguridad que no nos van a servir porque no se ajustan a los requisitos. Esta respuesta será un error.

Empezaremos rechazando aquellas cadenas que sean demasiado largas, las que tienen más de nueve caracteres. Esto lo podemos describir con este test:

En el archivo nif/nif_test.go

1 package nif
2 
3 import "testing"
4 
5 func TestShouldFailWhenStringIsTooLong(t *testing.T) {
6 	NewNif()
7 }

De momento vamos a ignorar las respuestas de la función simplemente para forzarnos a implementar el mínimo de código.

Como es de suponer el test fallará porque no compila. Así que implementamos el código mínimo necesario, que puede ser tan pequeño como este:

Archivo nif/nif.go

1 package nif
2 
3 func NewNif() {
4 
5 }

Con esto ya logramos una base sobre la que construir.

Ahora podemos avanzar un paso más. La función debería aceptar un parámetro:

1 package nif
2 
3 import "testing"
4 
5 func TestShouldFailWhenStringIsTooLong(t *testing.T) {
6 	NewNif("01234567891011")
7 }

Volvemos a hacer pasar el test con:

1 package nif
2 
3 func NewNif(candidate string) {
4 
5 }

Y, finalmente, devolver:

  • el NIF cuando el que hemos pasado es válido.
  • un error en caso de que no se pueda.

En Go una función puede devolver varios valores y, por convención, se devuelven también los errores como último valor devuelto.

Esto proporciona una flexibilidad que no es habitual encontrar en otros lenguajes y nos deja jugar con algunas ideas que son como mínimo curiosas. Por ejemplo, nosotras vamos a ignorar de momento la respuesta de la función y centrarnos solo en los errores. Nuestro próximo test va a pedir que la función devuelva solo el error sin hacer nada con él. El if es, de momento, para que el compilador no proteste.

1 package nif
2 
3 import "testing"
4 
5 func TestShouldFailWhenStringIsTooLong(t *testing.T) {
6 	err := NewNif("01234567891011")
7 
8 	if err != nil {}
9 }

Este test nos indica que debemos devolver algo, así que por ahora indicamos que vamos a devolver un error, que puede ser nil.

1 package nif
2 
3 func NewNif(candidate string) error {
4 	return nil
5 }

Avancemos un paso más esperando que se produzca un error determinado cuando se cumple la circunstancia definida por el test, que la cadena es demasiado larga, y con lo que tendremos ya un primer test en condiciones:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailWhenStringIsTooLong(t *testing.T) {
 6 	err := NewNif("01234567891011")
 7 
 8 	if err.Error() != "too long" {
 9 		t.Errorf("Expected too long, got %s", err.Error())
10 	}
11 }

De nuevo fallará el test y para hacerlo pasar devolvemos incondicionalmente el error:

1 package nif
2 
3 import "errors"
4 
5 func NewNif(candidate string) error {
6 	return errors.New("too long")
7 }

Y con esto ya hemos hecho que nuestro primer test esté completo y pase. Podríamos ser un poco más estrictas en el manejo de la respuesta para contemplar el caso de que err sea nil, pero de momento es algo que no nos tiene que afectar.

En este punto me gustaría llamar tu atención al hecho de que no estamos resolviendo nada todavía: el error se devuelve de manera incondicional, por lo que estamos retrasando esta validación para más tarde.

Implementar la primera validación

Nuestro segundo test tiene como objetivo forzar la implementación de la validación que acabamos de posponer. Puede sonar un poco extraño, pero nos muestra que uno de los grandes beneficios de TDD es el hecho de poder posponer decisiones. Al hacerlo, tendremos un poco más de información, lo que siempre es una ventaja.

El test es muy similar al anterior:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailWhenStringIsTooLong(t *testing.T) {
 6 	err := NewNif("01234567891011")
 7 
 8 	if err.Error() != "too long" {
 9 		t.Errorf("Expected too long, got %s", err.Error())
10 	}
11 }
12 
13 func TestShouldFailWhenStringIsTooShort(t *testing.T) {
14 	err := NewNif("0123456")
15 
16 	if err.Error() != "too short" {
17 		t.Errorf("Expected too short, got %s", err.Error())
18 	}
19 }

Este test ya nos obliga a actuar de manera diferente en cada caso, así que vamos a implementar la validación que limita los string demasiado largos:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) > 9 {
 7 		return errors.New("too long")
 8 	}
 9 
10 	return errors.New("too short")
11 }

Vuelvo a señalar que en este momento lo que dice el test no lo estamos implementando ahora. Lo haremos en el siguiente ciclo, pero el test se cumple al devolver el error esperado de forma incondicional.

No hay mucho más que podamos hacer en el código de producción, pero al fijarnos en los tests podemos ver que sería posible unificar un poco su estructura. Al fin y al cabo vamos a hacer una serie de ellos en los que pasamos un valor y esperamos un error determinado como respuesta.

Un test para dominarlos a todos

En Go existe una estructura de test similar a la que en otros lenguajes nos proporciona el uso de Data Providers: Table Test.

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{"should fail if too long", "01234567891011", "too long"},
12 		{"should fail if too short", "01234", "too short"},
13 	}
14 	for _, test := range tests {
15 		t.Run(test.name, func(t *testing.T) {
16 			err := NewNif(test.example)
17 
18 			if err.Error() != test.expected {
19 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
20 			}
21 		})
22 	}
23 }

Con esto conseguimos que ahora sea muy fácil y rápido añadir tests, sobre todo si son de la misma familia, como en este caso en el que pasamos cadenas candidatas inválidas y chequeamos por el error. Además, si hacemos cambios en la interfaz de la constructora solo tenemos un lugar en donde aplicarlos.

Con esto, tendríamos ya todo preparado para seguir desarrollando.

Completar la validación de la longitud y empezar a examinar la estructura

Con los dos tests anteriores verificamos si la cadena que vamos a examinar cumple la especificación de tener exactamente nueve caracteres, aunque de momento eso no está implementado. Lo haremos ahora.

Sin embargo, puede que te preguntes por qué no testear simplemente que la función rechaza los strings que no la cumplan, algo que podríamos hacer en un único test.

La razón es que en realidad hay dos posibles formas de que no se cumpla la especificación: el string tiene más de nueve caracteres o el string tiene menos. Si hacemos un solo test elegiremos uno de los dos casos, con lo cual no estamos garantizando que se cumpla el otro.

En este ejemplo concreto en que hay un único valor que nos interesa podríamos plantear la disyuntiva entre strings con longitud nueve y strings con longitud distinta de nueve. Sin embargo, es frecuente que tengamos que trabajar con intervalos de valores que, además, pueden estar abiertos o cerrados. En esa situación la estrategia de dos, o incluso más tests, es muchísimo más segura.

En cualquier caso, en el punto en el que estamos, para mover el desarrollo necesitamos añadir otro requisito en forma de test. Los dos tests existentes nos definen la longitud válida del string. El siguiente test pregunta por su estructura.

Y con el refactor que acabamos de hacer añadir un test es tremendamente sencillo.

Empezaremos por el principio. Los NIF válidos comienzan con un número, excepto un subconjunto de ellos que lo hace por alguna de las letras X, Y y Z. Una forma de definir el test es de la siguiente forma:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011", 
14 			"too long",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"too short",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z", 
23 			"A12345678", 
24 			"bad start format",
25 		},
26 	}
27 	for _, test := range tests {
28 		t.Run(test.name, func(t *testing.T) {
29 			err := NewNif(test.example)
30 
31 			if err.Error() != test.expected {
32 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
33 			}
34 		})
35 	}
36 }

Para hacer pasar el test, resolvemos primero el problema pendiente del anterior:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) > 9 {
 7 		return errors.New("too long")
 8 	}
 9 
10 	if len(candidate) < 9 {
11 		return errors.New("too short")
12 	}
13 
14 	return errors.New("bad start format")
15 }

Aquí tenemos una oportunidad de refactor bastante clara que consistiría en unir las condicionales que evalúan la longitud del string. Sin embargo, esa va a hacer que el test falle, ya que al menos tendríamos que cambiar un mensaje de error.

La vía no muy limpia de cambiar a la vez test y código de producción

Una posibilidad es “saltarnos” temporalmente la condición de que el refactor sea con los tests en verde y hacer cambios a la vez en prod y test. Veamos qué pasa.

Lo primero sería cambiar el test para esperar un mensaje de error distinto, que será más genérico e igual para todos los casos que queremos consolidar en este paso:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short", 
18 			"01234", 
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678", 
24 			"bad start format",
25 		},
26 	}
27 	for _, test := range tests {
28 		t.Run(test.name, func(t *testing.T) {
29 			err := NewNif(test.example)
30 
31 			if err.Error() != test.expected {
32 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
33 			}
34 		})
35 	}
36 }

Esto hará fallar el test. Cosa que se puede arreglar cambiando el código de producción del mismo modo:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) > 9 {
 7 		return errors.New("bad format")
 8 	}
 9 
10 	if len(candidate) < 9 {
11 		return errors.New("bad format")
12 	}
13 
14 	return errors.New("bad start format")
15 }

El test vuelve a pasar y estamos listas para el refactor. Pero no vamos a hacer eso.

La vía segura

Otra opción es hacer un refactor temporal en el test para hacerlo más tolerante. Simplemente, hacemos que sea posible devolver un error más genérico aparte del error específico.

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name     string
 8 		example  string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"too long",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"too short",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad start format",
25 		},
26 	}
27 	for _, test := range tests {
28 		t.Run(test.name, func(t *testing.T) {
29 			err := NewNif(test.example)
30 
31 			if err.Error() != test.expected && err.Error() != "bad format" {
32 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
33 			}
34 		})
35 	}
36 }

Este cambio nos permite ahora hacer el cambio en producción sin romper nada:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) > 9 {
 7 		return errors.New("bad format")
 8 	}
 9 
10 	if len(candidate) < 9 {
11 		return errors.New("bad format")
12 	}
13 
14 	return errors.New("bad end format")
15 }

El test sigue pasando y ahora sí podemos hacer el refactor.

Unificar la validación por longitud del string

Unificar las condicionales es fácil en este momento. Este es el primer paso, que incluyo aquí para tener un modelo de cómo hacer esto en caso de que fuese un intervalo de longitudes válidas.

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) > 9 || len(candidate) < 9 {
 7 		return errors.New("bad format")
 8 	}
 9 	
10 	return errors.New("bad start format")
11 }

Pero se puede hacer mejor:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	if len(candidate) != 9 {
 7 		return errors.New("bad format")
 8 	}
 9 
10 	return errors.New("bad start format")
11 }

Y un poco más expresivo:

 1 package nif
 2 
 3 import "errors"
 4 
 5 func NewNif(candidate string) error {
 6 	const maxlength = 9
 7 	
 8 	if len(candidate) != maxlength {
 9 		return errors.New("bad format")
10 	}
11 
12 	return errors.New("bad start format")
13 }

Finalmente, un nuevo refactor del test para contemplar estos cambios. Retiramos nuestro cambio temporal aunque es posible que tengamos que volver a utilizarlo en el futuro.

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad start format",
25 		},
26 	}
27 	for _, test := range tests {
28 		t.Run(test.name, func(t *testing.T) {
29 			err := NewNif(test.example)
30 
31 			if err.Error() != test.expected {
32 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
33 			}
34 		})
35 	}
36 }

Fíjate que hemos podido hacer todos estos cambios sin que fallaran los tests.

Avanzando en la estructura

El código está bastante compacto, así que vamos a añadir un nuevo test que nos permita avanzar con la validez de la estructura. El fragmento central del NIF está compuesto solo por números, exactamente siete:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad start format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad middle format",
30 		},
31 	}
32 	for _, test := range tests {
33 		t.Run(test.name, func(t *testing.T) {
34 			err := NewNif(test.example)
35 
36 			if err.Error() != test.expected {
37 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
38 			}
39 		})
40 	}
41 }

Lo lanzamos para asegurarnos de que falla por la razón adecuada. Para hacer pasar el test tenemos que resolver primero el test anterior, por lo que añadiremos código para verificar que el primer símbolo es un número o una letra en el conjunto X, Y y Z. Lo haremos con una expresión regular:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	invalid := regexp.MustCompile(`(?i)^[^0-9XYZ].*`)
16 
17 	if invalid.MatchString(candidate) {
18 		return errors.New("bad start format")
19 	}
20 	
21 	return errors.New("bad middle format")
22 }

Con este código hacemos pasar el test, pero vamos a hacer un refactor.

Invertir la condicional

Tiene sentido que en vez de hacer match contra una expresión regular que excluya los string no válidos, hagamos match contra una expresión que los detecte. Con eso tendremos que invertir la condicional. Lo cierto es que el cambio es bastante pequeño:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	valid := regexp.MustCompile(`(?i)^[0-9XYZ].*`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad start format")
19 	}
20 	
21 	return errors.New("bad middle format")
22 }

El final de la estructura

Nos estamos acercando al final de la validación estructural del NIF, necesitamos un test que nos diga cuáles rechazar en función de su último símbolo, lo que nos llevará a resolver el problema que quedaba pendiente del test anterior:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad start format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad middle format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 	}
37 	for _, test := range tests {
38 		t.Run(test.name, func(t *testing.T) {
39 			err := NewNif(test.example)
40 
41 			if err.Error() != test.expected {
42 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
43 			}
44 		})
45 	}
46 }

De las cuatro letras no válidas tomamos la U como ejemplo, pero podrían ser: la I, la Ñ y la O.

Sin embargo, para hacer pasar el test, lo que hacemos es asegurarnos que el anterior se va a cumplir. Lo más fácil es implementar eso de forma separada:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	valid := regexp.MustCompile(`(?i)^[0-9XYZ].*`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad start format")
19 	}
20 
21 	valid = regexp.MustCompile(`(?i)^.\d{7}.*`)
22 
23 	if !valid.MatchString(candidate) {
24 		return errors.New("bad middle format")
25 	}
26 
27 	return errors.New("invalid end format")
28 }

Compactando el algoritmo

Con esto pasa el test y nos encontramos con una situación familiar que ya hemos resuelto antes: tenemos que convertir los errores en más genéricos con la ayuda temporal de un control extra en el test:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad start format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad middle format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 	}
37 	for _, test := range tests {
38 		t.Run(test.name, func(t *testing.T) {
39 			err := NewNif(test.example)
40 
41 			if err.Error() != test.expected && err.Error() != "bad format" {
42 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
43 			}
44 		})
45 	}
46 }

Hacemos el cambio en los mensajes de error en el código de producción:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	valid := regexp.MustCompile(`(?i)^[0-9XYZ].*`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad format")
19 	}
20 
21 	valid = regexp.MustCompile(`(?i)^.\d{7}.*`)
22 
23 	if !valid.MatchString(candidate) {
24 		return errors.New("bad format")
25 	}
26 
27 	return errors.New("invalid end format")
28 }

Ahora unificamos la expresión regular y las condicionales:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}.*`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad format")
19 	}
20 	
21 	return errors.New("invalid end format")
22 }

Todavía podemos hacer un cambio pequeño pero importante. La última parte de la expresión regular .* está para cumplir el requisito de que se haga match de toda la cadena, pero realmente no necesitamos el cuantificador, ya que nos basta un carácter:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	const maxlength = 9
10 
11 	if len(candidate) != maxlength {
12 		return errors.New("bad format")
13 	}
14 
15 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}.$`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad format")
19 	}
20 
21 	return errors.New("invalid end format")
22 }

Y esto nos revela un detalle, la expresión regular hace match únicamente en cadenas de exactamente nueve caracteres, por lo que la validación inicial de la longitud resulta innecesaria:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}.$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return errors.New("bad format")
13 	}
14 
15 	return errors.New("invalid end format")
16 }

Tanto camino recorrido para desandarlo. Sin embargo, al principio no sabíamos esto y es ahí donde está el valor del proceso.

Por último, cambiamos el test para reflejar los cambios y volver a quitar nuestro apoyo temporal:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 	}
37 	for _, test := range tests {
38 		t.Run(test.name, func(t *testing.T) {
39 			err := NewNif(test.example)
40 
41 			if err.Error() != test.expected {
42 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
43 			}
44 		})
45 	}
46 }

Terminando la validación estructural

Necesitamos un nuevo test para terminar la parte de la validación estructural. Los tests existentes nos garantizarían la corrección de los strings, pero la siguiente validación ya implica el algoritmo para calcular la letra de control.

El test que necesitamos ahora controla que no podemos usar un NIF estructuralmente válido, pero en el que la letra de control sea incorrecta. Al enunciar la kata pusimos algunos ejemplos, como 00000000S. Este es el test:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 		{
37 			"should fail if doesn't end with the right control letter",
38 			"00000000S",
39 			"bad control letter",
40 		},
41 
42 	}
43 	for _, test := range tests {
44 		t.Run(test.name, func(t *testing.T) {
45 			err := NewNif(test.example)
46 
47 			if err.Error() != test.expected {
48 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
49 			}
50 		})
51 	}
52 }

Y he aquí el código que lo hace pasar:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}.$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return errors.New("bad format")
13 	}
14 
15 	valid = regexp.MustCompile(`(?i).*[^UIOÑ0-9]$`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("invalid end format")
19 	}
20 	
21 	return errors.New("bad control letter")
22 }

Y, cómo no, toca refactorizar.

Compactando la validación

Este refactor es bastante obvio, pero tenemos que volver a proteger el test temporalmente:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 		{
37 			"should fail if doesn't end with the right control letter",
38 			"00000000S",
39 			"bad control letter",
40 		},
41 	}
42 	for _, test := range tests {
43 		t.Run(test.name, func(t *testing.T) {
44 			err := NewNif(test.example)
45 
46 			if err.Error() != test.expected  && err.Error() != "bad format" {
47 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
48 			}
49 		})
50 	}
51 }

Hacemos más general el error para poder unificar las expresiones regulares y las condicionales:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}.$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return errors.New("bad format")
13 	}
14 
15 	valid = regexp.MustCompile(`(?i).*[^UIOÑ0-9]$`)
16 
17 	if !valid.MatchString(candidate) {
18 		return errors.New("bad format")
19 	}
20 
21 	return errors.New("bad control letter")
22 }

Y hacemos ahora la unificación, mientras los tests siguen pasando:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return errors.New("bad format")
13 	}
14 
15 	return errors.New("bad control letter")
16 }

Con esto terminamos la validación de la estructura y nos quedaría implementar el algoritmo mod23. Pero para eso necesitamos un pequeño cambio de enfoque.

Seamos optimistas

El algoritmo es, de hecho, muy sencillo: obtener un resto (de dividir por 23) y buscar la posición indicada por el resultado en una tabla. Es fácil de implementar en una sola iteración. Sin embargo, vamos a hacerlo más lentamente.

Hasta ahora nuestros tests eran pesimistas porque esperaban ejemplos incorrectos de NIF para poder pasar. Nuestros tests ahora tienen que ser optimistas, es decir, van a esperar que le pasemos ejemplos de NIF válidos.

En este punto introduciremos un cambio. Si recuerdas, de momento solo estamos devolviendo el error y la interfaz final de la función devolverá el string validado como un tipo NIF que vamos a crear para la ocasión.

Es decir, tenemos que cambiar el código para que devuelva algo, y que ese algo sea de un tipo que todavía no existe.

Para hacer este cambio sin romper los tests vamos a hacer una técnica de refactor un tanto rebuscada.

Cambiando la interfaz pública

En primer lugar, extraemos el cuerpo de NewNif a otra función:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	return FullNewNif(candidate)
10 }
11 
12 func FullNewNif(candidate string) error {
13 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
14 
15 	if !valid.MatchString(candidate) {
16 		return errors.New("bad format")
17 	}
18 
19 	return errors.New("bad control letter")
20 }

Los tests siguen pasando. Ahora introducimos una variable:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	err := FullNewNif(candidate)
10 	return err
11 }
12 
13 func FullNewNif(candidate string) error {
14 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
15 
16 	if !valid.MatchString(candidate) {
17 		return errors.New("bad format")
18 	}
19 
20 	return errors.New("bad control letter")
21 }

Con esto podemos hacer que FullNewNif devuelva el string sin afectar al test porque queda encapsulado dentro de NewNif.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) error {
 9 	_, err := FullNewNif(candidate)
10 	return err
11 }
12 
13 func FullNewNif(candidate string) (string, error) {
14 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
15 
16 	if !valid.MatchString(candidate) {
17 		return "", errors.New("bad format")
18 	}
19 
20 	return candidate, errors.New("bad control letter")
21 }

Los tests siguen pasando y casi hemos acabado. En el test cambiamos el uso de NewNif por FullNewNif.

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 		{
37 			"should fail if doesn't end with the right control letter",
38 			"00000000S",
39 			"bad control letter",
40 		},
41 	}
42 	for _, test := range tests {
43 		t.Run(test.name, func(t *testing.T) {
44 			_, err := FullNewNif(test.example)
45 
46 			if err.Error() != test.expected  && err.Error() != "bad format" {
47 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
48 			}
49 		})
50 	}
51 }

Siguen pasando los tests. Ahora la función devuelve los dos parámetros que queríamos y no hemos roto los tests. Podemos eliminar la función NewNif original.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func FullNewNif(candidate string) (string, error) {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return "", errors.New("bad format")
13 	}
14 
15 	return candidate, errors.New("bad control letter")
16 }

Y usar las herramientas del IDE para cambiar el nombre de la función FullNewNif a NewNif.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 func NewNif(candidate string) (string, error) {
 9 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
10 
11 	if !valid.MatchString(candidate) {
12 		return "", errors.New("bad format")
13 	}
14 
15 	return candidate, errors.New("bad control letter")
16 }

Ahora sí

Nuestro objetivo ahora es empujar la implementación del algoritmo mod23. Esta vez los tests esperan que la cadena sea válida. Además, queremos forzar que se devuelva el tipo Nif en lugar de string.

 1 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
 2 	tests := []struct {
 3 		name string
 4 		example string
 5 	}{
 6 		{"should accept mod23 being 0", "00000023T"},
 7 	}
 8 	for _, test := range tests {
 9 		t.Run(test.name, func(t *testing.T) {
10 			nif, err := NewNif(test.example)
11 			
12 			if nif != Nif(test.example) {
13 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
14 			}
15 			
16 			if err != nil {
17 				t.Errorf("Unexpected error %s", err.Error())
18 			}
19 		})
20 	}
21 }

En un primer paso cambiamos el código de producción para introducir y usar el tipo Nif:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 type Nif string
 9 
10 func NewNif(candidate string) (Nif, error) {
11 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
12 
13 	if !valid.MatchString(candidate) {
14 		return "", errors.New("bad format")
15 	}
16 
17 	return "", errors.New("bad control letter")
18 }

Ahora el test estará fallando porque no hemos validado nada todavía. Para hacerlo pasar añadimos un condicional:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 type Nif string
 9 
10 func NewNif(candidate string) (Nif, error) {
11 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
12 
13 	if !valid.MatchString(candidate) {
14 		return "", errors.New("bad format")
15 	}
16 
17 	if candidate == "00000023T" {
18 		return Nif(candidate), nil
19 	}
20 
21 	return "", errors.New("bad control letter")
22 }

Una nota sobre Go es que los tipos custom no pueden tener valor nil, sino vacío. Por eso en caso de error devolvemos string vacío.

Avanzando el algoritmo

De momento no hay muchos motivos para hacer refactor, así que vamos a introducir un test que nos ayude a avanzar un poco. En principio, queremos lograr que nos impulse a separar la parte numérica de la letra de control.

Una posibilidad sería testear otro NIF que acabe con la letra T, como el 00000046T.

 1 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
 2 	tests := []struct {
 3 		name string
 4 		example string
 5 	}{
 6 		{"should accept mod23 being 0", "00000023T"},
 7 		{"should accept mod23 being 0 letter T", "00000046T"},
 8 	}
 9 	for _, test := range tests {
10 		t.Run(test.name, func(t *testing.T) {
11 			nif, err := NewNif(test.example)
12 
13 			if nif != Nif(test.example) {
14 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
15 			}
16 
17 			if err != nil {
18 				t.Errorf("Unexpected error %s", err.Error())
19 			}
20 		})
21 	}
22 }

Para hacer pasar el test, podemos hacer esta implementación sencilla:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 type Nif string
 9 
10 func NewNif(candidate string) (Nif, error) {
11 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
12 
13 	if !valid.MatchString(candidate) {
14 		return "", errors.New("bad format")
15 	}
16 
17 	if candidate == "00000023T" {
18 		return Nif(candidate), nil
19 	}
20 
21 	if candidate == "00000046T" {
22 		return Nif(candidate), nil
23 	}
24 
25 	return "", errors.New("bad control letter")
26 }

Y ahora empezamos a refactorizar.

Más refactor

En el código de producción podemos ver lo que hay de diferente y de común entre los ejemplos. En ambos la letra de control es T y la parte numérica es divisible entre 23, por lo que su mod23 será 0.

Ahora podemos hacer el refactor. Un primer paso.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 )
 7 
 8 type Nif string
 9 
10 func NewNif(candidate string) (Nif, error) {
11 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
12 
13 	if !valid.MatchString(candidate) {
14 		return "", errors.New("bad format")
15 	}
16 
17 	control := string(candidate[8])
18 
19 	if control == "T" {
20 		return Nif(candidate), nil
21 	}
22 
23 	return "", errors.New("bad control letter")
24 }

Y, después de ver pasar los tests, el segundo paso:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	control := string(candidate[8])
19 
20 	numeric, _ := strconv.Atoi(candidate[0:8])
21 
22 	modulus := numeric % 23
23 	
24 	if control == "T" && modulus == 0 {
25 		return Nif(candidate), nil
26 	}
27 
28 	return "", errors.New("bad control letter")
29 }

Con este cambio los tests pasan y acepta todos los NIF válidos acabados en T.

Validando más letras de control

En este tipo de algoritmos no tiene mucho sentido intentar validar todas las letras de control, pero podemos introducir una más para forzarnos a entender cómo debería evolucionar el código ahora. Probaremos con una nueva letra:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 		{
37 			"should fail if doesn't end with the right control letter",
38 			"00000000S",
39 			"bad control letter",
40 		},
41 	}
42 	for _, test := range tests {
43 		t.Run(test.name, func(t *testing.T) {
44 			_, err := NewNif(test.example)
45 
46 			if err.Error() != test.expected  && err.Error() != "bad format" {
47 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
48 			}
49 		})
50 	}
51 }
52 
53 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
54 	tests := []struct {
55 		name string
56 		example string
57 	}{
58 		{"should accept mod23 being 0", "00000000T"},
59 		{"should accept mod23 being 0 letter T", "00000023T"},
60 		{"should accept mod23 being 1 letter R", "00000024R"},
61 	}
62 	for _, test := range tests {
63 		t.Run(test.name, func(t *testing.T) {
64 			nif, err := NewNif(test.example)
65 
66 			if nif != Nif(test.example) {
67 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
68 			}
69 
70 			if err != nil {
71 				t.Errorf("Unexpected error %s", err.Error())
72 			}
73 		})
74 	}
75 }

Ya tenemos este test fallando, así que vamos a hacer una implementación muy sencilla:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	control := string(candidate[8])
19 
20 	numeric, _ := strconv.Atoi(candidate[0:8])
21 
22 	modulus := numeric % 23
23 
24 	if control == "T" && modulus == 0 {
25 		return Nif(candidate), nil
26 	}
27 
28 	if control == "R" && modulus == 1 {
29 		return Nif(candidate), nil
30 	}
31 
32 	return "", errors.New("bad control letter")
33 }

Esto ya nos da una idea de por dónde van los tiros: un mapa entre letras de control y el resto al dividir por 23. Sin embargo, es frecuente que los strings puedan funcionar como arrays en muchos lenguajes, por lo que nos basta tener un string con todas las letras de control ordenadas y acceder a la letra en la posición indicada por el módulo para saber cuál es la correcta.

Un refactor para más simplicidad

Primero implementamos una versión simple de esta idea:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	controlMap := "TR"
19 
20 	control := candidate[8]
21 
22 	numeric, _ := strconv.Atoi(candidate[0:8])
23 
24 	modulus := numeric % 23
25 
26 	if control == controlMap[modulus] {
27 		return Nif(candidate), nil
28 	}
29 
30 	return "", errors.New("bad control letter")
31 }

Ya tenemos una primera versión. Luego añadiremos la lista completa de letras, pero podemos intentar arreglar un poco el código actual. Primero hacemos que controlMap sea constante:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	const controlMap = "TR"
19 
20 	control := candidate[8]
21 
22 	numeric, _ := strconv.Atoi(candidate[0:8])
23 
24 	modulus := numeric % 23
25 
26 	if control == controlMap[modulus] {
27 		return Nif(candidate), nil
28 	}
29 
30 	return "", errors.New("bad control letter")
31 }

En realidad podríamos extraer toda la parte del cálculo del módulo a otra función. Primero reorganizamos el código para controlar mejor la extracción:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	control := candidate[8]
19 	
20 	const controlMap = "TR"
21 	numeric, _ := strconv.Atoi(candidate[0:8])
22 	modulus := numeric % 23
23 	shouldBe := controlMap[modulus]
24 	
25 	if control == shouldBe {
26 		return Nif(candidate), nil
27 	}
28 
29 	return "", errors.New("bad control letter")
30 }

Recuerda verificar que pasan los tests. Ahora extraemos la función:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	control := candidate[8]
19 
20 	if control == shouldHaveControl(candidate) {
21 		return Nif(candidate), nil
22 	}
23 
24 	return "", errors.New("bad control letter")
25 }
26 
27 func shouldHaveControl(candidate string) uint8 {
28 	const controlMap = "TR"
29 	numeric, _ := strconv.Atoi(candidate[0:8])
30 	modulus := numeric % 23
31 	
32 	return controlMap[modulus]
33 }

Y podemos compactar el código un poco más, mientras que añadimos las demás letras de control. A primera vista parece “trampa”, pero en el fondo no es más que generalizar un algoritmo que se podría enunciar como “toma la letra que hay en la posición dada por el mod23 de la parte numérica”.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	if candidate[8] == shouldHaveControl(candidate) {
19 		return Nif(candidate), nil
20 	}
21 
22 	return "", errors.New("bad control letter")
23 }
24 
25 func shouldHaveControl(candidate string) uint8 {
26 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
27 
28 	numeric, _ := strconv.Atoi(candidate[0:8])
29 	modulus := numeric % 23
30 
31 	return controlMap[modulus]
32 }

Con esto ya podemos validar todos los NIF, excepto los NIE, que empiezan por las letras X, Y o Z.

Dar soporte a NIE

Ahora que hemos implementado el algoritmo general vamos a tratar sus excepciones, que no son tanto. Los NIE comienzan con una letra que a efectos del cálculo se reemplaza con un número.

El test que parece más evidente en este punto es el siguiente:

 1 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
 2 	tests := []struct {
 3 		name string
 4 		example string
 5 	}{
 6 		{"should accept mod23 being 0", "00000000T"},
 7 		{"should accept mod23 being 0 letter T", "00000023T"},
 8 		{"should accept mod23 being 1 letter R", "00000024R"},
 9 		{"should accept NIE starting with X", "X0000023T"},
10 	}
11 	for _, test := range tests {
12 		t.Run(test.name, func(t *testing.T) {
13 			nif, err := NewNif(test.example)
14 
15 			if nif != Nif(test.example) {
16 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
17 			}
18 
19 			if err != nil {
20 				t.Errorf("Unexpected error %s", err.Error())
21 			}
22 		})
23 	}
24 }

El caso X0000023T es equivalente a 00000023T, ¿afectará eso al resultado del test?

Ejecutamos el test y… ¿Sorpresa? El test pasa. Esto ocurre porque la conversión que hacemos en esta línea genera un error que actualmente estamos ignorando, pero permite que la parte numérica siga siendo equivalente a 23, cuyo mod23 es 0 y le corresponde igualmente la letra T.

1 	numeric, _ := strconv.Atoi(candidate[0:8])

En otros lenguajes la conversión no falla, pero asume la X como 0 al realizar la conversión.

En cualquier caso eso nos abre dos posibles caminos:

  • anular este test y refactorizar el código de producción para tratar el error y que el test falle cuando lo volvamos a incluir
  • probar otro ejemplo que sí pueda fallar (Y0000000Z) y hacer el cambio después

Posiblemente, para este caso la segunda opción sería más que suficiente, ya que con nuestras validaciones estructurales ya garantizaríamos que el error no tiene posibilidad de aparecer una vez que la función esté completamente desarrollada.

Sin embargo, podría ser interesante introducir la gestión del error. Manejar los errores, incluyendo los que nunca podrían llegar a pasar, es siempre una buena práctica.

Así que, anulemos el test e introduzcamos un refactor para manejar el error:

 1 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
 2 	tests := []struct {
 3 		name string
 4 		example string
 5 	}{
 6 		{"should accept mod23 being 0", "00000000T"},
 7 		{"should accept mod23 being 0 letter T", "00000023T"},
 8 		{"should accept mod23 being 1 letter R", "00000024R"},
 9 		//{"should accept NIE starting with X", "X0000023X"},
10 	}
11 	for _, test := range tests {
12 		t.Run(test.name, func(t *testing.T) {
13 			nif, err := NewNif(test.example)
14 
15 			if nif != Nif(test.example) {
16 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
17 			}
18 
19 			if err != nil {
20 				t.Errorf("Unexpected error %s", err.Error())
21 			}
22 		})
23 	}
24 }

Aquí el refactor. En este caso, gestiono el error provocando un panic, que no es la mejor manera de gestionar un error, pero que nos permite hacer que el test pueda fallar y obligarnos a implementar la solución.

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 )
 8 
 9 type Nif string
10 
11 func NewNif(candidate string) (Nif, error) {
12 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
13 
14 	if !valid.MatchString(candidate) {
15 		return "", errors.New("bad format")
16 	}
17 
18 	if candidate[8] == shouldHaveControl(candidate) {
19 		return Nif(candidate), nil
20 	}
21 
22 	return "", errors.New("bad control letter")
23 }
24 
25 func shouldHaveControl(candidate string) uint8 {
26 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
27 
28 	numeric, err := strconv.Atoi(candidate[0:8])
29 
30 	if err != nil {
31 		panic("Numeric part contains letters")
32 	}
33 	
34 	modulus := numeric % 23
35 
36 	return controlMap[modulus]
37 }

Al ejecutar los tests, comprobamos que siguen en verde. Pero si reactivamos el último test vemos cómo falla:

 1 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
 2 	tests := []struct {
 3 		name string
 4 		example string
 5 	}{
 6 		{"should accept mod23 being 0", "00000000T"},
 7 		{"should accept mod23 being 0 letter T", "00000023T"},
 8 		{"should accept mod23 being 1 letter R", "00000024R"},
 9 		{"should accept NIE starting with X", "X0000023X"},
10 	}
11 	for _, test := range tests {
12 		t.Run(test.name, func(t *testing.T) {
13 			nif, err := NewNif(test.example)
14 
15 			if nif != Nif(test.example) {
16 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
17 			}
18 
19 			if err != nil {
20 				t.Errorf("Unexpected error %s", err.Error())
21 			}
22 		})
23 	}
24 }

Y esto ya nos obliga a introducir un tratamiento para estos casos. Básicamente, es reemplazar X por 0:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 	"strings"
 8 )
 9 
10 type Nif string
11 
12 func NewNif(candidate string) (Nif, error) {
13 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
14 
15 	if !valid.MatchString(candidate) {
16 		return "", errors.New("bad format")
17 	}
18 
19 	if candidate[8] == shouldHaveControl(candidate) {
20 		return Nif(candidate), nil
21 	}
22 
23 	return "", errors.New("bad control letter")
24 }
25 
26 func shouldHaveControl(candidate string) uint8 {
27 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
28 
29 	var numPart = candidate[0:8]
30 
31 	if string(candidate[0]) == "X" {
32 		numPart = strings.Replace(numPart, "X", "0", 1)
33 	}
34 
35 	numeric, err := strconv.Atoi(numPart)
36 
37 	if err != nil {
38 		panic("Numeric part contains letters")
39 	}
40 
41 	modulus := numeric % 23
42 
43 	return controlMap[modulus]
44 }

Se puede refactorizar usando un Replacer:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 	"strings"
 8 )
 9 
10 type Nif string
11 
12 func NewNif(candidate string) (Nif, error) {
13 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
14 
15 	if !valid.MatchString(candidate) {
16 		return "", errors.New("bad format")
17 	}
18 
19 	if candidate[8] == shouldHaveControl(candidate) {
20 		return Nif(candidate), nil
21 	}
22 
23 	return "", errors.New("bad control letter")
24 }
25 
26 func shouldHaveControl(candidate string) uint8 {
27 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
28 
29 	var numPart = candidate[0:8]
30 
31 	re := strings.NewReplacer("X", "0")
32 	
33 	numeric, err := strconv.Atoi(re.Replace(numPart))
34 
35 	if err != nil {
36 		panic("Numeric part contains letters")
37 	}
38 
39 	modulus := numeric % 23
40 
41 	return controlMap[modulus]
42 }

En este punto podríamos hacer un test para forzarnos a introducir el resto de reemplazos. Es barato, aunque en el fondo no es muy necesario por lo que comentamos antes: podemos interpretar esta parte del algoritmo como “reemplazar las letras iniciales X, Y y Z por los números 0, 1 y 2, respectivamente”:

 1 package nif
 2 
 3 import "testing"
 4 
 5 func TestShouldFailIfCandidateIsInvalid(t *testing.T) {
 6 	tests := []struct {
 7 		name string
 8 		example string
 9 		expected string
10 	}{
11 		{
12 			"should fail if too long",
13 			"01234567891011",
14 			"bad format",
15 		},
16 		{
17 			"should fail if too short",
18 			"01234",
19 			"bad format",
20 		},
21 		{
22 			"should fail if starts with a letter other than X, Y, Z",
23 			"A12345678",
24 			"bad format",
25 		},
26 		{
27 			"should fail if doesn't have 7 digit in the middle",
28 			"0123X567R",
29 			"bad format",
30 		},
31 		{
32 			"should fail if doesn't end with a valid control letter",
33 			"01234567U",
34 			"invalid end format",
35 		},
36 		{
37 			"should fail if doesn't end with the right control letter",
38 			"00000000S",
39 			"bad control letter",
40 		},
41 
42 	}
43 	for _, test := range tests {
44 		t.Run(test.name, func(t *testing.T) {
45 			_, err := NewNif(test.example)
46 
47 			if err.Error() != test.expected  && err.Error() != "bad format" {
48 				t.Errorf("Expected %s, got %s", test.expected, err.Error())
49 			}
50 		})
51 	}
52 }
53 
54 func TestShouldCreateNifTypeWithValidCandidate(t *testing.T) {
55 	tests := []struct {
56 		name string
57 		example string
58 	}{
59 		{"should accept mod23 being 0", "00000000T"},
60 		{"should accept mod23 being 0 letter T", "00000023T"},
61 		{"should accept mod23 being 1 letter R", "00000024R"},
62 		{"should accept NIE starting with X", "X0000000T"},
63 		{"should accept NIE starting with Y", "Y0000000Z"},
64 	}
65 	for _, test := range tests {
66 		t.Run(test.name, func(t *testing.T) {
67 			nif, err := NewNif(test.example)
68 
69 			if nif != Nif(test.example) {
70 				t.Errorf("Expected Nif(%s), got %s", test.example, nif)
71 			}
72 
73 			if err != nil {
74 				t.Errorf("Unexpected error %s", err.Error())
75 			}
76 		})
77 	}
78 }

Solo es necesario añadir los pares correspondientes:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 	"strings"
 8 )
 9 
10 type Nif string
11 
12 func NewNif(candidate string) (Nif, error) {
13 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
14 
15 	if !valid.MatchString(candidate) {
16 		return "", errors.New("bad format")
17 	}
18 
19 	if candidate[8] == shouldHaveControl(candidate) {
20 		return Nif(candidate), nil
21 	}
22 
23 	return "", errors.New("bad control letter")
24 }
25 
26 func shouldHaveControl(candidate string) uint8 {
27 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
28 
29 	var numPart = candidate[0:8]
30 
31 	re := strings.NewReplacer("X", "0", 
32 	                          "Y", "1", 
33 	                          "Z", "2")
34 
35 	numeric, err := strconv.Atoi(re.Replace(numPart))
36 
37 	if err != nil {
38 		panic("Numeric part contains letters")
39 	}
40 
41 	modulus := numeric % 23
42 
43 	return controlMap[modulus]
44 }

Después de un rato de refactor, esta sería una posible solución:

 1 package nif
 2 
 3 import (
 4 	"errors"
 5 	"regexp"
 6 	"strconv"
 7 	"strings"
 8 )
 9 
10 type Nif string
11 
12 func NewNif(candidate string) (Nif, error) {
13 	valid := regexp.MustCompile(`(?i)^[0-9XYZ]\d{7}[^UIOÑ0-9]$`)
14 
15 	if !valid.MatchString(candidate) {
16 		return "", errors.New("bad format")
17 	}
18 
19 	if candidate[8] != controlLetterFor(candidate) {
20 		return "", errors.New("bad control letter")
21 	}
22 
23 	return Nif(candidate), nil
24 }
25 
26 func controlLetterFor(candidate string) uint8 {
27 	const controlMap = "TRWAGMYFPDXBNJZSQVHLCKE"
28 
29 	position, err := controlPosition(candidate[0:8])
30 
31 	if err != nil {
32 		panic("Numeric part contains letters")
33 	}
34 
35 	return controlMap[position]
36 }
37 
38 func controlPosition(numPart string) (int, error) {
39 	re := strings.NewReplacer("X", "0", "Y", "1", "Z", "2")
40 
41 	numeric, err := strconv.Atoi(re.Replace(numPart))
42 
43 	return numeric % 23, err
44 }

Qué hemos aprendido con esta kata

  • Utilizar sad paths para mover el desarrollo
  • Utilizar table tests en Go reduciendo el coste de añadir nuevos tests
  • Una técnica para cambiar los errores devueltos por otro más general sin romper los tests
  • Una técnica para cambiar la interfaz pública del código de producción sin romper los tests

Referencias

  • Is Go object-oriented?1

La fase de refactor

En capítulos anteriores, mencionamos las leyes de TDD. Originalmente, estas leyes eran dos, en la formulación de Kent Beck:

  • No escribir una línea de código sin antes tener un test automático que falle
  • Eliminar la duplicación

En esencia, lo que Kent Beck proponía es definir primero una pequeña parte de la especificación mediante un test, implementar un algoritmo muy pequeño que la satisfaga y, a continuación, revisar el código en busca de casos de duplicación para refactorizarlos en un algoritmo más general y flexible.

Y eso es, más o menos, como define Martin Fowler el ciclo Red-Green-Refactor:

  • Escribe un test para el siguiente fragmento de funcionalidad que deseas añadir
  • Escribe el código de producción necesario para que el test pase
  • Refactoriza el código, tanto el nuevo como el anterior, para que esté bien estructurado

Este enunciado parece dar por sentado que el refactor es, por así decir, el final de cada etapa del proceso. Pero, paradójicamente, si interpretamos el ciclo al pie de la letra caeremos en una mala práctica.

La función del refactor en TDD

Por lo general, en Test Driven Development se favorece que tanto los tests como los cambios en el código de producción sean lo más pequeños posible. Este enfoque minimalista es beneficioso porque nos permite trabajar con poca carga cognitiva en cada ciclo, mientras aprendemos y alcanzamos una mayor y más profunda comprensión del problema, aplazando decisiones hasta un momento en que estemos mejor informadas para afrontarlas.

Normalmente, los pequeños pasos en TDD nos permiten hacer cambios de código muy sencillos cada vez. Muchas veces estos cambios son obvios y nos llevan a implementaciones que podríamos considerar ingenuas. Sin embargo, por muy sencillas o bastas que nos parezcan, estas implementaciones hacen pasar los tests y, por tanto, cumplen las especificaciones. Podríamos entregar ese código si es el caso porque el comportamiento ha sido desarrollado.

La fase de refactor está precisamente para hacer evolucionar esas implementaciones ingenuas a mejores diseños teniendo la red de seguridad que nos proporcionan los tests que están pasando.

Que refactors ejecutar

En cada ciclo es posible realizar diversos refactors. Obviamente, en las primeras fases serán más pequeños e incluso es posible que nos parezca que no son necesarios. Sin embargo, es conveniente aprovechar la oportunidad cuando se presenta.

Podemos hacer muchos tipos de refactors, entre otros:

  • Introducir constantes para reemplazar valores mágicos.
  • Cambiar nombres de variables y parámetros para reflejar mejor sus intenciones.
  • Extraer métodos privados.
  • Extraer condicionales a métodos cuando se vuelvan complejas.
  • Aplanar estructuras condicionales anidadas.
  • Extraer ramas de condicionales a métodos privados.
  • Extraer funcionalidad a colaboradores.

Límites del refactor

En ocasiones, un exceso de refactor puede llevarnos a que la implementación se complique y sea difícil avanzar en el proceso de TDD. Esto ocurre cuando introducimos ciertos patrones de forma prematura, sin que el desarrollo esté todavía terminado. Sería un refactor prematuro parecido a la optimización prematura, generando un código difícil de mantener.

Podríamos decir que hay dos modalidades de refactor implicadas:

  • Una de alcance limitado aplicable en cada ciclo red-green-refactor cuya función es facilitar la legibilidad, sostenibilidad y capacidad de evolución del algoritmo en desarrollo.
  • La otra que tendrá lugar una vez que hemos completado toda la funcionalidad y cuyo objetivo es introducir un diseño más evolucionado y orientado a patrones.

Otra cuestión interesante es la introducción de características exclusivas o propias del lenguaje, que en principio también convendría posponer hasta esa fase final. ¿Por qué dejarlas para este momento? Precisamente porque pueden limitar nuestra capacidad de refactorizar un código si todavía no tenemos seguridad acerca de hacia dónde podría evolucionar.

Por ejemplo, en Ruby esta construcción:

1 def greet(name = nil)
2   if name.nil?
3     name = 'my friend'
4   end
5 
6   "Hello, #{name}"
7 end

Podría refactorizarse, y de hecho se recomienda, de esta forma que me parece realmente bonita:

1 def greet(name = nil)
2   name = 'my friend' if name.nil?
3 
4   "Hello, #{name}"
5 end

En este caso, la estructura representa la idea de asignar un valor por defecto a la variable, algo que podríamos conseguir también de este modo, el cual es común a otros lenguajes:

1 def greet(name = 'my friend')
2   "Hello, #{name}"
3 end

Las tres variantes hacen pasar los tests, pero cada una de ellas nos coloca en una posición ligeramente distinta de cara a los futuros requerimientos.

Por ejemplo, supongamos que nuestro próximo requerimiento nos pide poder introducir varios nombres. Una posible solución para eso es usar splat parameters, es decir, que la función admita un número indefinido de parámetros que luego se presentarán dentro del método como un array. En Ruby esto se expresa así:

1 def greet(*name)
2   #
3 end

Esta declaración, por ejemplo, es incompatible con la tercera variante, ya que el splat operator no admite un valor por defecto y tendremos que reimplementar ese paso, lo que nos llevará de nuevo a utilizar una de las otras variantes.

En principio no parece que sea un gran inconveniente, pero implica deshacer toda la lógica que venga determinada por esa estructura y, según el momento de desarrollo en que nos encontremos puede llevarnos incluso a callejones sin salida.

En las otras opciones, es un poco menos inconveniente. Además de cambiar la signatura lo único que tenemos que modificar es la pregunta (empty? por nil?) y el valor por defecto que, en lugar de un string, pasa a ser un array de string. Por supuesto, para finalizar tenemos que hacer un join de la colección para poder mostrarlo en el saludo.

1 def greet(*name)
2   if name.empty?
3     name = ['my friend']
4   end
5 
6   names = name.join(', ')
7 
8   "Hello, #{names}"
9 end

O la versión rubyficada:

1 def greet(*name)
2   name = ['my friend'] if name.empty?
3 
4   names = name.join(', ')
5 
6   "Hello, #{names}"
7 end

Aparte de eso, sería necesario en este punto un refactor del nombre del parámetro que exprese más claramente su nuevo significado:

1 def greet(*people)
2   people = ['my friend'] if people.empty?
3 
4   names = people.join(', ')
5 
6   "Hello, #{names}"
7 end

Así que como recomendación general es conveniente buscar un equilibrio entre los refactors que nos ayudan a mantener el código limpio y legible de aquellos que podríamos considerar como sobreingeniería. Una implementación un poco menos refinada puede ser más fácil de cambiar que una muy evolucionada a medida que se introducen nuevos tests.

No sobre refactorices antes de tiempo.

Cuando es el momento de hacer refactor

Para hacer refactor la condición sine qua non es que todos los tests existentes estén pasando. En este momento nos interesa analizar el estado de la implementación y aplicar los refactors que mejor le vayan.

Si un test está en rojo nos indica que una parte de la especificación no está conseguida y, por lo tanto, debemos trabajar en eso y no en el refactor.

Pero hay un caso especial: cuando añadimos un nuevo test que falla y nos damos cuenta de que necesitamos un refactor previo para poder implementar la solución más obvia o sencilla para ese test.

¿Cómo actuamos en este caso? Pues tenemos que dar un paso atrás.

El paso atrás en el ciclo Red-Green-Refactor

Supongamos un ejemplo sencillo. Vamos a iniciar la Greeting kata de testdouble. Empezamos con un test con el que definir la interfaz:

1 require 'rspec'
2 
3 RSpec.describe 'A simple greeting' do
4   it 'should greet a person' do
5     expect(greet('Fran')).to eq('Hello, Fran')
6   end
7 end

Nuestro siguiente paso es crear la implementación más sencilla para que el test pase, cosa que podríamos hacer así:

1 def greet(name)
2   'Hello, Fran'
3 end

El siguiente requerimiento es que maneje el caso de que no se proporcione nombre, en cuyo caso debe ofrecer alguna fórmula anónima como la que ponemos de ejemplo en este test:

 1 require 'rspec'
 2 
 3 def greet(name)
 4   'Hello, Fran'
 5 end
 6 
 7 RSpec.describe 'A simple greeting' do
 8   it 'should greet a person' do
 9     expect(greet('Fran')).to eq('Hello, Fran')
10   end
11 
12   it 'should greet even if no name provided' do
13     expect(greet).to eq('Hello, my friend')
14   end
15 end

El test falla en primer lugar porque el argumento no es opcional. Pero es que además no se usa en la implementación actual y necesitamos usarlo para hacer lo más obvio que requiere este test. Tenemos que ejecutar varios pasos preparatorios antes de poder realizar la implementación, a saber:

  • Hacer opcional el parámetro name
  • Usar el parámetro en el valor de retorno

El caso es que con el nuevo requerimiento tenemos nueva información que nos sería útil para refactorizar lo desarrollado gracias al primer test. Sin embargo, como tenemos un nuevo test que falla, no deberíamos hacer refactor, por lo que eliminamos o anulamos el test anterior. Por ejemplo, comentándolo:

 1 require 'rspec'
 2 
 3 def greet(name)
 4   'Hello, Fran'
 5 end
 6 
 7 RSpec.describe 'A simple greeting' do
 8   it 'should greet a person' do
 9     expect(greet('Fran')).to eq('Hello, Fran')
10   end
11   
12   # it 'should greet even if no name provided' do
13   #   expect(greet).to eq('Hello, my friend')
14   # end
15 end

Al hacer así, volvemos a tener los tests en verde y podemos aplicar los cambios necesarios, que no alteran el comportamiento implementado hasta el momento.

Hacemos opcional el parámetro del nombre.

1 def greet(name = nil)
2   'Hello, Fran'
3 end

Aquí empezamos a dar uso al parámetro:

1 def greet(name)
2   "Hello, #{name}"
3 end

Esto nos ha permitido pasar de nuestra primera implementación tosca a una lo bastante flexible con la que el test sigue pasando y estamos en mejores condiciones para volver a introducir el siguiente test:

 1 require 'rspec'
 2 
 3 def greet(name = nil)
 4   "Hello, #{name}"
 5 end
 6 
 7 RSpec.describe 'A simple greeting' do
 8   it 'should greet a person' do
 9     expect(greet('Fran')).to eq('Hello, Fran')
10   end
11 
12   it 'should greet even if no name provided' do
13     expect(greet).to eq('Hello, my friend')
14   end
15 end

Obviamente, el test falla, pero la razón del fallo es justamente que nos falta código que resuelva el requerimiento. Lo único que tenemos que hacer es comprobar si recibimos un nombre o no, y actuar en consecuencia.

1 def greet(name = nil)
2   if name.nil?
3     name = 'my friend'
4   end
5   
6   "Hello, #{name}"
7 end

En cierto modo, resulta que la información del futuro, o sea, el nuevo test que planteamos para introducir la siguiente funcionalidad, afecta al pasado, es decir al estado adecuado del código para poder continuar, y nos obliga a considerar la profundidad del refactor necesario antes de afrontar el nuevo ciclo. En esta situación, lo mejor es volver al último test que pasaba, anulando el nuevo, y trabajar en el refactor hasta estar mejor preparadas para continuar avanzando.

Bowling game

La fase de refactor

En las katas anteriores, por lo general, los ciclos de TDD se ejecutaban de forma bastante fluida.

Sin embargo, es posible que hayas notado que, en algún momento, hacer pasar un nuevo test implicaba un cierto refactor del código de producción antes de poder afrontar los cambios necesarios para hacer pasar el test.

La kata que vamos a practicar ahora, además de ser una de las clásicas, tiene la particularidad de que casi cada nueva funcionalidad que añadimos, cada nuevo test, require un refactor relativamente grande del algoritmo. Eso nos pone en un dilema: no podemos estar refactorizando si el test no está en verde.

O dicho de otro modo: en ocasiones nos encontraremos que el nuevo test nos proporciona una información que no teníamos antes y que nos muestra una oportunidad de refactor que debemos afrontar antes de implementar la parte nueva de funcionalidad.

Por eso, con la kata Bowling Game, aprenderemos cómo manejar esta situación y dar un paso atrás para refactorizar el código de producción con lo aprendido al pensar en el nuevo test.

En cierto modo, la información del futuro nos ayudará a cambiar el pasado.

Historia

La kata Bowling es muy conocida. Se la debemos a Robert C. Martin, aunque una versión muy popular es la de Ron Jeffreys en el libro Adventures in C#

Enunciado

La kata consiste en crear un programa para calcular las puntuaciones del juego de los Bolos, aunque para evitar complicarla mucho solo se calcula el resultado final y no se hacen validaciones sobre las puntuaciones.

Si no tienes familiaridad con el juego y su sistema de puntuación, aquí van las reglas que es necesario conocer:

  • En cada juego, el jugador o jugadora tiene 10 turnos, llamados frames.
  • Dentro de cada frame, se dispone de dos intentos para tumbar los 10 bolos (eso hace un total de 20 intentos o lanzamientos de bola en todo el juego).
  • En cada intento, se cuentan los bolos tumbados y la puntuación del frame es la suma de ambos intentos.
  • Si no se tira ningún bolo es un Gutter.
  • Si no se han tirado todos los bolos en los dos intentos esa será la puntuación. Por ejemplo 3 + 5 = 8 puntos en el frame.
  • Si se han tumbado los 10 bolos en el frame (por ejemplo 4 + 6), a eso se le llama spare y se obtiene un bonus que será la puntuación del siguiente lanzamiento, el primero del siguiente frame (10 del frame actual + 3 del siguiente lanzamiento = 13). Esto es, la puntuación final de un spare se calcula después del siguiente lanzamiento y, por así decir, ese lanzamiento se cuenta dos veces (una como bonus y otra normal).
  • Si se han tumbado los 10 bolos en un solo lanzamiento es un strike y en ese caso, el bonus es la puntuación del siguiente frame (por ejemplo, 10 + (3 + 4) = 17).
  • En el caso de que esto se produzca en el último frame, se hacen uno o dos lanzamientos extras según sea necesario.

Orientaciones para resolverla

La Bowling Game es una kata interesante por el reto que plantea el tratamiento de los spares y strikes. Cuando detectamos uno de estos casos, tenemos que consultar el resultado de los siguientes lanzamientos por lo que necesitamos conservar la historia de la partida.

Esto nos obligará a cambiar el algoritmo varias veces de una forma un tanto radical, lo que nos pone ante el problema de cómo gestionar estos cambios sin romper los ciclos de TDD, es decir, refactorizando el código de producción mientras se mantienen los tests pasando.

Para entender mejor lo que queremos decir, la situación sería la siguiente:

Después de un par de ciclos comenzamos a testear por el caso spare. En ese punto nos damos cuenta de que necesitamos hacer un cambio relativamente grande al modo en que estábamos calculando la puntuación total. En último término, lo que ocurre es que tenemos que refactorizar mientras un test no pasa. Pero es contradictorio con la definición de la fase de refactor que exige que todos los tests estén pasando.

La solución, por suerte, es muy sencilla: dar un paso atrás.

Una vez que sabemos que queremos refactorizar el algoritmo, nos basta comentar el nuevo test para desactivarlo y, con el test anterior pasando, refactorizar el código de producción. Cuando lo tengamos, volvemos a traer a la vida el nuevo test y desarrollamos el nuevo comportamiento.

Enlaces de interés sobre la kata Bowling Game

  • The Bowling Game Kata1
  • Adventures in C#: The Bowling Game2

Resolviendo la kata Bowling Game

Enunciado de la kata

La kata consiste en crear un programa para calcular las puntuaciones de un jugador en un juego de los Bolos, aunque para evitar complicarla mucho solo se calcula el resultado final y no se hacen validaciones sobre las puntuaciones.

Un breve recordatorio de las reglas:

  • Cada juego tiene 10 turnos de 2 lanzamientos cada uno.
  • En cada turno se cuentan los bolos que han caído y ese número es la puntuación
    • 0 puntos es un gutter
    • Si se tiran todos los bolos entre los dos intentos es un spare, y se suma como bonus la puntuación del siguiente lanzamiento
    • Si se tiran todos los bolos en el primer lanzamiento es un strike, y se suma como bonus la puntuación de los siguientes dos lanzamientos
  • Si el strike o el spare se logran en el último frame habrá lanzamientos extra

Lenguaje y enfoque

Para hacer esta kata he escogido Ruby y RSpec. Posiblemente, notes que tengo cierta preferencia por los frameworks de test de la familia *Spec, pero es que han sido diseñados pensando en TDD, considerando el test como especificación lo que ayuda mucho a salirse del marco de pensar en los tests como QA.

Dicho eso, no hay ningún problema en usar cualquier otro framework de testing, como los de la familia *Unit.

Por otro lado, emplearemos orientación a objetos.

Iniciando el juego

A estas alturas, el primer test debería ser suficiente para forzarnos a definir e instanciar la clase:

1 require 'rspec'
2 
3 RSpec.describe 'A Bowling Game' do
4   it 'should start a new game' do
5     BowlingGame.new
6   end
7 end

El test fallará, obligándonos a escribir el código de producción mínimo para que llegue a pasar.

1 class BowlingGame
2 
3 end

Y una vez que hemos hecho pasar el test, movemos la clase a su propio archivo y la requerimos:

1 require 'rspec'
2 require_relative '../src/bowling_game'
3 
4 RSpec.describe 'A Bowling Game' do
5   it 'should start a new game' do
6     BowlingGame.new
7   end
8 end

Estamos listas para el siguiente paso.

Lancemos la bola

Para que nuestro BowlingGame sea útil necesitaremos al menos dos cosas:

  • Una forma de indicar el resultado de un lanzamiento, pasando el número de bolos derribado, que sería un command. Un command provoca un efecto en el estado de un objeto, pero no devuelve nada por lo que necesitamos una vía alternativa de observar ese efecto.
  • Una forma de obtener la puntuación en un momento dado, que sería una query. Una query devuelve una respuesta, por lo que podemos verificar que es la que esperamos.

Puede que te preguntes: ¿Cuál de las dos deberíamos atacar primero?

No hay una regla fija, pero una forma de verlo puede ser la siguiente:

Los métodos query devuelven un resultado y su efecto puede testearse, pero hay que tener en cuenta en este punto asegurarnos de que la respuesta esperada no nos dificultará crear nuevos tests que fallen.

Por contra, los métodos command podemos introducirlos con un mínimo de código, sin tener que estar pendientes de sus efectos en futuros tests, salvo asegurarnos de que los parámetros que reciban son válidos.

Así que vamos a empezar introduciendo un método para lanzar la bola, que simplemente espera recibir el número de bolos derribado, que puede ser 0. Pero para forzar eso debemos escribir un test primero:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5   it 'should start a new game' do
 6     game = BowlingGame.new
 7   end
 8 
 9   it 'should roll a ball knocking down 0 pins' do
10     game = BowlingGame.new
11     game.roll 0
12   end
13 end

Y el código suficiente para hacer que el test pase es simplemente definir el método. Básicamente, lo que tenemos es que podemos comunicarle a BowlingGame que hemos lanzado la bola.

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 end

Hora de refactorizar

En esta kata vamos a prestar especial atención a la fase de refactor. Hay que buscar un equilibrio para que ciertos refactors no nos condicionen las posibilidades de hacer evolucionar el código. Del mismo modo que la optimización prematura es un smell, la sobre ingeniería prematura también lo es.

El código de producción no ofrece todavía ninguna oportunidad de refactor, pero los tests empiezan a mostrar un patrón. El objeto game podría vivir como variable de instancia e inicializarse en un método setup de la especificación o test case. En este caso, usamos before.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should start a new game' do
11     game = BowlingGame.new
12   end
13 
14   it 'should roll a ball knocking down 0 pins' do
15     @game.roll 0
16   end
17 end

Y esto hace que el primer test sea redundante:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 end

Con esto la especificación será más manejable.

Contando los puntos

Toca introducir un método para poder saber el marcador del juego. Lo reclamamos mediante un test que falle:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score after a gutter roll' do
15     @game.roll 0
16     expect(@game.score).to eq(0)
17   end
18 end

El test fallará porque no existe el método score.

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 
6   def score
7     
8   end
9 end

Y seguirá fallando porque tiene que devolver 0. Lo mínimo para lograr que pase es:

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 
6   def score
7     0
8   end
9 end

El peor lanzador del mundo

Muchas soluciones de la kata van directamente a este punto donde vamos a empezar a definir el comportamiento de BowlingGame tras los 20 lanzamientos. Nosotros hemos escogido un camino con pasos más pequeños y vamos a ver qué implica.

Nuestro siguiente test intentará hacer posible obtener un marcador tras 20 lanzamientos. Una forma de hacerlo es simularlos y lo más sencillo sería que todos ellos fuesen fallidos, es decir, que no tirasen ningún bolo con lo que el marcador final sería 0.

Este parece un buen test para empezar:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score after a gutter roll' do
15     @game.roll 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score a gutter game' do
20     20.times do
21       @game.roll 0
22     end
23     expect(@game.score).to eq(0)
24   end
25 end

Pero no lo es. Lo ejecutamos y pasa a la primera.

Este test no nos obliga a introducir cambios en el código de producción porque no falla. En el fondo es el mismo test que teníamos antes. Sin embargo, en algunos sentidos es un test mejor, ya que nuestro objetivo es que score nos devuelva los resultados tras la totalidad de lanzamientos.

Organizando el código

Simplemente, eliminamos el test anterior por redundante, ya que ese comportamiento estaría implícito en el que acabamos de definir.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13   
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 end

Como el test no nos ha requerido escribir código de producción, necesitamos un test que sí falle.

Enseñando a contar a nuestro juego

Lo mejor sería esperar un resultado distinto a cero en score para vernos obligadas a implementar nuevo código de producción. De todos los resultados posibles de un juego completo de bolos quizá el más sencillo de probar sea el caso en el que todos los lanzamientos acaban con un único bolo derribado. De este modo, esperamos que la puntuación final sea 20, y no hay posibilidad de que se generen puntos o tiradas extra.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 
21   it 'should score all ones' do
22     20.times do
23       @game.roll 1
24     end
25     expect(@game.score).to eq(20)
26   end
27 end

Este test ya falla porque no hay nada que acumule los puntos obtenidos en cada lanzamiento. Por tanto, necesitamos tener esa variable, que se inicie a cero y que vaya acumulando los resultados.

Pero… un momento. Eso ¿no son muchas cosas?

Un paso atrás para llegar más lejos

Repasemos, para pasar el test que tenemos ahora fallando necesitamos:

  • Añadir una variable en la clase para almacenar las puntuaciones
  • Iniciarla a 0
  • Acumular en ella los resultados

Son muchas cosas para añadir en un solo ciclo mientras tenemos un test fallando.

El caso es que, en realidad, podríamos olvidar este test un momento y volver al estado anterior cuando estábamos todavía en verde. Para ello comentamos el nuevo test de modo que no se ejecute.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 
21   # it 'should score all ones' do
22   #   20.times do
23   #     @game.roll 1
24   #   end
25   #   expect(@game.score).to eq(20)
26   # end