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