Google+ Seguidores

viernes, 17 de abril de 2015

Unit Testing en Python

    2


 Introducción

En esta entrada vamos a ver como utilizar el framework de testing “unittest”, para verificar que el código que escribimos está libre de errores.
Esta es una introducción muy básica al mundo del testing mediante python. Al final de la entrada encontraréis más recursos con los que podréis ampliar vuestro conocimiento sobre Testing.

Vamos a realizar un programa que devuelva “Python” si se le pasa un número divisible por 3; que devuelva “Diario” si el número es divisible por 5; y si no es divisible ni por 3 ni por 5 que devuelva el número que se ha pasado como argumento.

Escribir tests para nuestra aplicación hará que la calidad de nuestro código mejore, ya que debemos enfocarnos en un único test a la vez, y por tanto evitaremos el tratar de resolver varios problemas al mismo tiempo, o anticiparnos y tratar de resolver problemas que se puedan plantear en un futuro. De esta forma nos enfocamos únicamente en conseguir que el Test que acabamos de escribir pase (sea válido).

Vamos a utilizar las normas que dicta TDD (Test-driven Development) para escribir y llevar a cabo los tests. Para escribir un Test debemos seguir los 3 siguientes pasos:
  1. Escribir un test que falle (Red)
  2. Hacer que el test pase (sea válido) (Green)
  3. Refactorizar (Opcional) (Refactor)
Con estas 3 reglas en mente vamos a proceder a realizar nuestro ejercicio haciendo uso de Tests.

Creamos el primer Test

Lo primero que debemos hacer es crear el fichero donde vamos a escribir nuestros tests. El fichero se llamará: test_python_diario_software.py
Y meter el siguiente código:

import unittest

import python_diario

class TestPythonSoftware(unittest.TestCase):

    def test_should_return_python_when_number_is_3(self):
        self.assertEqual('Python', python_diario.get_string(3))

    if __name__ == '__main__': 
        unittest.main()

El código consiste de las siguientes partes:
  • Importamos el módulo de unittest, que nos permite realizar los test de nuestra aplicación.
  • Lo siguiente es importar el módulo sobre el cual queremos hacer los tests (python_diario). Es importante resaltar que este módulo/fichero aún no lo hemos creado, y que lo crearemos posteriormente.
  • Creamos una clase que contendrá todos nuestros Tests. Si no tienes claro como funcionan las clases en Python, es conveniente que te pases por este tutorial introductorio: http://www.pythondiario.com/2014/10/clases-y-objetos-en-python-programacion.html
  • Definimos un test. Para ello creamos un método dentro de la clase y lo nombramos con el prefijo “test_”. Es imprescindible que el método tenga el prefijo “test_, ya que si no es así el test nunca se ejecutará.
    Como vemos el nombre del método debe describir qué es lo que vamos a testear. En este caso, queremos comprobar que cuando le pasamos el número 3, nuestro programa devuelve la palabra “Python”.
  • La línea de self.assertEqual... comprueba que la función “get_string” de nuestro módulo “python_diario” devuelve la palabra “Python” cuando le pasamos como argumento el número 3.
  • La línea de if __name__ == '__main__': sencillamente sirve para que al ejecutar al fichero test_python_diario_software.py desde la consola, se ejecuten de forma automática todos los Tests creados.

Ejecutamos el Test

Ya tenemos todo listo para ejecutar el test. Vamos a la consola y tecleamos:
>> python3 test_python_diario_software.py

Veremos la siguiente salida:

Traceback (most recent call last):

File "test_python_diario_software.py", line 2, in
<module> 

import python_diario 

ImportError: No module named 'python_diario' 

Como vemos, el Test falla, esto es normal, ya que una de las buenas prácticas del TDD es la de escribir primero el Test, antes de escribir cualquier línea de código en nuestro programa. Es por ello que nosotros hemos escrito primero el Test.

Creando el módulo/fichero que va a ser testeado

Ahora vamos a escribir el código de nuestro programa. Para ello vamos a crear el fichero “python_diario.py” en la misma carpeta donde hemos creado el fichero “test_python_diario_software.py”.

En “python_diario.py” escribimos el siguiente código:

def get_string(number): 
    return 'Python' 

Ok, aquí hay que mencionar que lo único que hemos hecho es devolver la palabra 'Python' cada vez que se llama a la función get_string. Esta es otra norma de TDD: Debemos escribir el mínimo código posible para hacer que el Test pase. En este caso hacer un return de 'Python' es lo mínimo que debemos escribir para hacer pasar el Test.
Puede resultar raro, pero debemos seguir esta práctica, ya que aporta el beneficio de resolver un "problema" de la forma más rápida, y después ya tendremos tiempo de refactorizar. Esto aplicado a un producto real, hará que si surge algún problema, lo primero será enfocarse en resolver el problema de la forma más rápida, y después ya habrá tiempo de hacer un código más elegante o eficiente.

Comprobando que el Test pasa

Vamos a comprobar que el test pasa:
>> python3 test_python_diario_software.py -v

test_should_return_python_when_number_is_3 (__main__.TestPythonSoftware) ... ok
----------------------------------------------------------------------

Ran 1 test in 0.000s 

OK

Refactorizar (Opcional)

Perfecto el Test pasa. Por lo que podemos pasar al paso 3: Refactorizar.
Refactorizar consiste en limpiar el código que hemos escrito de tal forma que sea más legible y semántico sin modificar su comportamiento. En este caso nuestro código es tan simple que no es necesario refactorizar.
Hay que tener en cuenta que la refactorización únicamente se puede llevar a cabo cuando los Tests están en Verde.

Fin de la primera iteración de Testing

Hemos terminado la primera iteración de Testing:
  1. Escribir un test que falla. (Red)
  2. Escribir el mínimo código para hacer que el Test pase. (Green)
  3. Refactorizar

Iteración 2

Vamos a comenzar con la segunda iteración, por lo que vamos a repetir los 3 pasos básicos del Testing. Así que vamos a crear un nuevo test que falle.

1.- Escribir un Test que falle (Red)

Para ello agregamos el siguiente método en nuestra clase TestPythonSofware que se encuentra en el fichero test_python_diario_software.py:

def test_should_return_diario_when_number_is_5(self): 
    self.assertEqual('Diario', python_diario.get_string(5))

El test que acabamos de crear comprueba que si pasamos el número 5, la función get_string debe devolver la cadena: “Diario”.

Ejecutamos el test y comprobamos como falla:
>> python3 test_python_diario_software.py

======================================================================

FAIL: test_should_return_diario_when_number_is_5
(__main__.TestPythonSoftware) 

----------------------------------------------------------------------

Traceback (most recent call last): 

File "test_python_diario_software.py", line 12, in
test_should_return_diario_when_number_is_5 

self.assertEqual('Diario', python_diario.get_string(5)) 
AssertionError: 'Diario' != 'Python' 

- Diario 
+ Python 

Vemos como la salida de la ejecución de los test nos indica que el test “test_should_return_diario_when_number_is_5” está fallando, ya que debería devolver “Diario” y está devolviendo “Python” ('Diario' != 'Python').

2.- Escribir el mínimo código para que el Test pase (Green)

Es hora de hacer que este test pase, para ello vamos a reescribir por completo la función get_string del fichero python_diario.py:

def get_string(number): 
    return 'Python' if number == 3 else 'Diario' 

Ahora comprobamos que los tests pasan:
>> python3 test_python_diario_software.py 
----------------------------------------------------------------------
Ran 2 tests in 0.000s 

OK 

Perfecto. Como vemos, hemos escrito lo justo y necesario para hacer que nuestro código pase los tests.

3.- Refactorizar
De momento no es necesario refactorizar.

Iteración 3

Ahora vamos a agregar nuevos Tests, de tal forma que comprobemos más de un número divisible por 3, y más de un número divisible por 5.

1.- Escribir un Test que falle (Red)

Vallamos uno por uno. Primero agregamos un test para comprobar que el número 6 devuelve 'Python'. De tal forma que nuestro Test (test_python_diario_software.py) quedará así:

def test_should_return_python_when_number_is_6(self): 
    self.assertEqual('Python', python_diario.get_string(6))

Ejecutamos los Tests y comprobamos que fallan:
>> python3
test_python_diario_software.py 

..F 

======================================================================

FAIL: test_should_return_python_when_number_is_6
(__main__.TestPythonSoftware) 
----------------------------------------------------------------------
Traceback (most recent call last): 

File "test_python_diario_software.py", line 12, in
test_should_return_python_when_number_is_6 
self.assertEqual('Python', python_diario.get_string(6)) 
AssertionError: 'Python' != 'Diario' 

- Python 
+ Diario 
----------------------------------------------------------------------

Ran 3 tests in 0.001s 
FAILED (failures=1) 

2.- Escribir el mínimo código para que el Test pase (Green)

Vamos a escribir el mínimo código para hacer pasar el Test (python_diario.py):

def get_string(number):
    return 'Python' if number in [3, 6] else 'Diario'

Ejecutamos los Tests:
>>  python3
test_python_diario_software.py 
.. 
----------------------------------------------------------------------
Ran 3 tests in 0.000s 
OK 

Perfecto, los Tests pasan.

Iteración 4

Ahora vamos a agregar un nuevo Test y vamos a seguir los pasos de hacer que el Test falle, y escribir el mínimos código posible para hacer que el Test pase. No pongo la salida de los tests para no hacer demasiado larga la entrada.

1.- Escribir un Test que falle (Red)

El test que vamos a crear es el siguiente (test_python_diario_software.py):

def test_should_return_python_when_number_is_9(self):
    self.assertEqual('Python', python_diario.get_string(9))

2.- Escribir el mínimo código para que el Test pase (Green)

python_diario.py:
def get_string(number):
    return 'Python' if number in [3, 6, 8] else 'Diario'

3.- Refactorizar

Ahora que todos los Tests están en verde, es momento de refactorizar. Ya que estamos viendo que existen varios tests para los números divisibles por 3 (3, 6 y 9), es conveniente adaptar el código de python_diario.py para que todos los números divisibles por 3 devuelvan 'Python'. Para ello escribiremos la función get_string de la siguiente manera (python_diario.py):

def get_string(number):
    return 'Python' if number % 3 == 0 else 'Diario' 

Siempre que refactorizamos debemos volver a correr los Tests:
>> python3 test_python_diario_software.py 
..
----------------------------------------------------------------------
Ran 4 tests in 0.000s 
OK 

A esto que hemos hecho se le llama “triangular”. Cuando hacemos tests, únicamente nos ocupamos del caso actual que estamos testeando, y de no romper ninguno de los test anteriores, nunca nos preocupamos de los futuros casos que el programa debe contemplar.
En un momento dado nos daremos cuenta de un patrón que cuadra perfectamente para resolver el test actual y los anteriores, es en ese caso, cuando estemos en el paso de “refactorizar”, escribiremos un algoritmo que sirva para que los tests ya escritos pasen, y que además servirá para futuros casos.
Por ejemplo, en nuestro caso no tiene sentido escribir más de 3 tests para los números múltiplos de 3, ya que podríamos estar añadiendo de forma infinita números múltiplos de 3 ([3,6,9,12,15,...]). En algún momento debemos “generalizar” nuestra solución, y esto suele ocurrir cuando hemos escrito 3 tests, de ahí el nombre de “triangular”.

Iteración 5

1.- Escribir un Test que falle (Red)

Vamos a crear un nuevo test que compruebe que el programa devuelve el número que le pasamos (test_python_diario_software.py).

def test_should_return_7_when_number_is_7(self): 
    self.assertEqual(7, python_diario.get_string(7))

Corremos el Test y vemos como falla:
>> python3 test_python_diario_software.py 

F.... 
======================================================================

FAIL: test_should_return_7_when_number_is_7
(__main__.TestPythonSoftware) 
----------------------------------------------------------------------
Traceback (most recent call last): 
File "test_python_diario_software.py", line 21, in
test_should_return_7_when_number_is_7 
self.assertEqual(7, python_diario.get_string(7)) 
AssertionError: 7 != 'Diario' 
----------------------------------------------------------------------
Ran 5 tests in 0.001s 

FAILED (failures=1) 

2.- Escribir el mínimo código para que el Test pase (Green)

Vamos a escribir el mínimo código posible para hacer pasar el Test (python_diario.py):

def get_string(number): 
    result = 7

    if number % 3 == 0:
        result = 'Python'
    elif number == 5:
        result = 'Diario' 

    return result

Como vemos nuestro código se ha modificado significativamente. Esto es debido a que el nuevo Test ha hecho que tengamos que contemplar más casos, y por tanto tengamos que readaptar nuestro código anterior. Esto es completamente normal cuando se hacen Tests, por lo que no debemos sorprendernos si en ciertos momentos debemos reescribir el código al igual que nos ha sucedido en este caso.
Esto no se considera una refactorización, ya que lo que hemos hecho es escribir el mínimo código posible para que el test pase. En este caso el mínimo código posible son varias líneas.

Iteraciones 6 y 7

1.- Escribir un Test que falle (Red)

Para no hacer el post más largo vamos a agregar dos Tests y ver como quedaría el algoritmo que hace que los tests pasen (test_python_diario_software.py):

def test_should_return_11_when_number_is_11(self):
    self.assertEqual(11, python_diario.get_string(11)) 

def test_should_return_41_when_number_is_41(self):
    self.assertEqual(41, python_diario.get_string(41))

2.- Escribir el mínimo código para que el Test pase (Green)

python_diario.py:
def get_string(number):
    result = number

    if number % 3 == 0: 
        result = 'Python'
    elif number == 5:
        result = 'Diario' 

    return result 

Iteraciones 8 y 9

Vamos a agregar 2 test más y mostrar la solución del algoritmo final. Recordad que los test se deben agregar de uno en uno y pasar el ciclo de: Red, Green, Refactor:

1.- Escribir un Test que falle (Red)

test_python_diario_software.py:

def test_should_return_diario_when_number_is_10(self):
    self.assertEqual('Diario', python_diario.get_string(10))
def test_should_return_diario_when_number_is_20(self):
    self.assertEqual('Diario', python_diario.get_string(20))

2.- Escribir el mínimo código para que el Test pase (Greeny 3.- Refactorizar


python_diario.py:
def get_string(number):
    result = number

    if number % 3 == 0:
        result = 'Python'
    elif number % 5 == 0:
        result = 'Diario'

    return result

Conclusión

Hemos visto una función básica de como crear Tests en python haciendo uso de la librería “unittest”.
Este ejemplo ha sido muy sencillo pero nos sirve para familiarizarnos con el ciclo de:
  • Escribir un Test que falla (Red)
  • Escribir el mínimos código posible para hacer pasar el Test (Green)
  • Refactorizar
Los Tests nos ayudan a enfocarnos en una cosa a la vez, y evitan la tendencia de implementar en nuestro código funcionalidades extra que tal vez nunca se lleguen a usar.
Cada Test debe considerarse como un requerimiento nuevo que nos hace el “cliente”, y por tanto únicamente debemos implementar aquello que el cliente nos pide.
Por ejemplo, si el cliente nos pide un formulario de login sencillo que únicamente consiste de los campos: email y password. Nos limitaremos a crear ese formulario.
Si el cliente posteriormente nos pide que quiere agregar un “captcha” al formulario, lo implementaremos y refactorizaremos nuestro código según los nuevos requerimientos.

Podéis ver el código de este ejercicio en esta dirección de Github: https://github.com/RubenDjOn/Python-FizzBuzz

Más información sobre unittesting en python: http://www.diveintopython3.net/unit-testing.html

----
Mi nombre es Rubén Hernández. En los últimos años he comenzado a programar en python, y es algo que me encanta.
Sigo aprendiendo nuevas cosas sobre este genial lenguaje, y aprovecharé este espacio para compartir mis nuevos descubrimientos, y afianzar otros conocimientos ya adquiridos.
Si tenéis dudas, sugerencias o queréis contar conmigo para algún proyecto, podéis contactar conmigo a través de mis redes sociales:

Twitter@RubenDjOn
Google+: https://plus.google.com/+RubenHernandezA
Web Personal: rubendjon.com

2 comentarios:
Write comentarios
  1. Gracias por el artículo, es muy entendedor.

    ResponderEliminar
  2. Hola Ruben Hernandez, muy agradecida por el artículo.
    Solo tengo que comentarte que en el primer bloque de código que muestras:
    """
    import unittest

    import python_diario

    class TestPythonSoftware(unittest.TestCase):

    def test_should_return_python_when_number_is_3(self):
    self.assertEqual('Python', python_diario.get_string(3))

    if __name__ == '__main__':
    unittest.main()
    """"

    se tendría que sacar el if __name__==... de la clase para que pueda leer los test cuando ejecutas el archivo. Fue la solución que conseguí debido a que nunca se leían los test cuando ejecutaba el archivo.

    Gabriela

    ResponderEliminar

Tu comentario es importante y nos motiva a seguir escribiendo...

Entradas más recientes

© 2014 Mi diario Python. Designed by Bloggertheme9 | Distributed By Gooyaabi Templates
Powered by Blogger.