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.