Introducción a Unit Testing con Python
Aquí hay una versión PDF que los muestra correctamente
Autor: Tomás Zulberti
¿Qué es y porqué usar Unit Testing?
Unit Testing son test en donde cada parte (modulo, clase, función) del programa es testeado por separado. Idealmente, se pone a prueba todas todas las funciones y todos los casos posibles para cada una de ellas.
El unit testing tiene varias ventajas:
- Permite probar que el programa funciona correctamente. En Python, los tests también permiten identificar variables que no existen o tipos esperados en las funciones (en otros lenguajes eso se hace en tiempo de compilación).
- Permite identificar en caso de que se haga una modificación que siga funcionando correctamente todas las parte del programa. Tanto las cosas modificadas como las cosas que dependen de las modificadas. Esto es muy importante cuando se trabaja en grupo (con algún sistema de control de versiones) ya que permite asegurar que el código que usa el resto del grupo y que uno modifico sigue funcionando.
- Permiten documentar el código. Esto no es en forma directa, pero como los tests indican como es que se tiene que comportar el programa, viendo los tests uno puede fijarse cual es el resultado esperado para ciertas entradas del programa. Esto no excluye que se tenga que escribir la documentación del código.
¿Entonces porqué existen bugs si se podría escribir tests?. Unit Testing tiene algunas desventajas
- Toman bastante tiempo de escribir. Algunas clases son fáciles de testear pero otras no tanto.
- Cuando se hace un gran cambio en el código (un refactor) hay que actualizar los tests. Cuando se hace un cambio que es chico, seguramente también haya que escribir o cambiar algún test pero generalmente no toma mucho tiempo.
- Algo muy importante a tener en cuenta es que aprobar los tests no significa que el sistema funcione a la perfección. Un ejemplo de esto es CPython (el python que generalmente uno instala). Tiene muchísimos tests, y aun así también tiene errores. Sin embargo, los tests unitarios garantizan cierta funcionalidad mínima.
¿Cómo tienen que ser los tests?
Es muy importante que un test cumpla las siguientes reglas:
- Tiene que poder correr sin interacción humana. Es decir, los tests no deben pedir que el usuario ingrese valores en ningún caso. Para esto, es en el test mismo cuando se pasan los valores a la función.
- Tienen que poder verificar el resultado de la ejecución sin interacción humana. De nuevo, para saber si esta bien o no el resultado no tiene que pedirle al usuario que verifique el resultado. Para esto, se tiene que saber de antemano el resultado del test con los valores que se pasaron.
- Un test tiene que ser independiente del otro. Es decir, el resultado de un test no debería depender del resultado anterior.
Sabiendo estas reglas, vemos que condiciones deberían comprobar los tests:
- Que funcione correctamente cuando los valore de entrada son válidos.
- Que falle cuando los valores de entrada son inválidos, o que tire una excepción.
Dentro de lo posible los tests se deberían empezar al escribir el código, ya que esto permite:
- Identificar detalladamente que es lo que tiene que cumplir el código a escribir.
- Cuando uno escribe la implementación que pasa todos los tests entonces terminó. Esto tiene dos ventajas:
- Permite saber cuando uno tiene que terminar de escribir el código
- Hace que uno no tenga que escribir cosas de más.
Ejemplo
Supongamos que tenemos los coeficientes de una función cuadrática y queremos encontrar sus raíces. Es decir, tenemos una función del tipo:
\[a * x^2 + b * x + c = 0\]y queremos encontrar los valores \(r_{1}\) y \(r_{2}\) tales que:
\[a * r_{1}^2 + b * r_{1} + c = 0\]\[a * r_{2}^2 + b * r_{2} + c = 0\]Además, dados los valores \(r_{1}\) y \(r_{2}\) queremos encontrar los valores de a, b y c. Sabemos que:
\[(x - r_{1}) * (x - r_{2}) = a x^2 + b x + c = 0\]Todo esto son cosas de matemática que vimos en el colegio. Ahora veamos un código que cumple eso:
import math class NoEsFuncionCuadratica(Exception): pass class NoExistenRaicesReales(Exception): pass def buscar_raices(a, b, c): """ Toman los coeficientes a, b y c de una función cuadrática y busca sus raíces. La función cuadrática es del estilo: ax**2 + b x + c = 0 Va a devolver una tupla con las dos raíces donde la primer raíz va a ser menor o igual que la segunda raíz. """ if a == 0: raise NoEsFuncionCuadratica() discriminante = b * b - 4 * a * c if discriminante < 0: raise NoExistenRaicesReales() raiz_discriminante = math.sqrt(discriminante) primer_raiz = (-1 * b + raiz_discriminante) / (2 * a) segunda_raiz = (-1 * b - raiz_discriminante) / (2 * a) # min y max son funciones de python chico = min(primer_raiz, segunda_raiz) grande = max(primer_raiz, segunda_raiz) return (chico, grande) def buscar_coeficientes(primer_raiz, segunda_raiz): """ Dada las raíces de una función cuadrática, devuelve los coeficientes. La función cuadrática va a estar dada por: (x - r1) * (x - r2) = 0 """ # a este resultado se llega haciendo las cuentas de distribución return (1, -1 * (primer_raiz + segunda_raiz), primer_raiz * segunda_raiz)
Por último vemos los tests que escribimos para ese código:
import unittest from polinomio import buscar_raices, buscar_coeficientes, \ NoEsFuncionCuadratica, \ NoExistenRaicesReales class TestPolinomio(unittest.TestCase): def test_buscar_raices(self): COEFICIENTES_RAICES = [ ((1,0,0), (0, 0)), ((-1,1,2), (-1, 2)), ((-1,0,4), (-2, 2)), ] for coef, raices_esperadas in COEFICIENTES_RAICES: raices = buscar_raices(coef[0], coef[1], coef[2]) self.assertEquals(raices, raices_esperadas) def test_formar_poliniomio(self): RAICES_COEFICIENTES = [ ((1, 1), (1, -2, 1)), ((0, 0), (1, 0, 0)), ((2, -2), (1, 0, -4)), ((-4, 3), (1, 1, -12)), ] for raices, coeficientes_esperados in RAICES_COEFICIENTES: coeficientes = buscar_coeficientes(raices[0], raices[1]) self.assertEquals(coeficientes, coeficientes_esperados) def test_no_pudo_encontrar_raices(self): self.assertRaises(NoExistenRaicesReales, buscar_raices, 1, 0, 4) def test_no_es_cuadratica(self): self.assertRaises(NoEsFuncionCuadratica, buscar_raices, 0, 2, 3) def test_integridad(self): RAICES = [ (0, 0), (2, 1), (2.5, 3.5), (100, 1000), ] for r1, r2 in RAICES: a, b, c = buscar_coeficientes(r1[0], r2[1]) raices = buscar_raices(a, b, c) self.assertEquals(raices, (r1, r2)) def test_integridad_falla(self): COEFICIENTES = [ (2, 3, 0), (-2, 0, 4), (2, 0, -4), ] for a, b, c in COEFICIENTES: raices = buscar_raices(a, b, c) coeficientes = buscar_coeficientes(raices[0], raices[1]) self.assertNotEqual(coeficientes, (a, b, c)) if __name__ == '__main__': unittest.main()
El código se puede bajar desde acá: codigo_unittest.zip Es importante que los métodos y la clase del test tengan la palabra test (para las clases tiene que empezar en mayúscula) para que después puedan ser identificadas como clases que se usan para testear.
Veamos paso por paso como está escrito el test:
Se tiene que importar el modulo unittest que viene con python. Siempre para testear se tiene que crear una clase por mas de que lo que vayamos a probar sea una función. Esta clase tiene varios métodos llamados assertX donde la parte X cambia. Se usan para comprobar que el resultado sea correcto. Personalmente, los que mas uso son:
- assertEqual(valor1, valor2)
Comprueba que los dos valores sean iguales y falla el test en caso de que no lo sean. Si es una lista comprube que los valores de la lista sean iguales, lo mismo si es un set.
- assertTrue(condición):
Verifica que la condición sea cierta, y en caso de que no lo sea falla el test.
- assertRaises(exception, funcion, valor1, valor2, etc...):
Confirma que la excepción sea lanzada cuando se llame a función con los valores valor1, valor2, etc...)
Se importa el código que se va a probar con las diferentes excepciones que se lanza.
Se crea que extienda de TestCase, que va a tener diferentes métodos para testear. Dentro de estos métodos se van a usar el assertEquals, etc para comprobar que este todo bien. En nuestro caso, definimos las siguientes funciones:
- test_buscar_raices
Dada una lista de coeficientes y sus raíces testea que el resultado de las raíces obtenidas para el coeficiente sea el mismo que el esperado. Este test se encarga de probar que el buscar_raices funcione correctamente. Para esto iteramos una lista que tiene dos tuplas:
- una tupla con los coeficientes para llamar la función
- una tupla con las raíces esperadas para esos coeficientes. Estas raíces fueron calculadas a mano y no con el programa.
- test_formar_poliniomio
Dada una lista de raíces se fija los coeficientes sean correctos. Este test se encara de probar que formar_polinomio funcione correctamente. En este caso usa una lista que contiene dos tuplas:
- la primera con las raíces.
- la segunda con los coeficientes esperados para esas raíces.
- test_no_pudo_encontrar_raices
Se fija que se lance la excepción cuando no se pueden encontrar las raíces reales. Esta se fija que buscar_raices tire la excepción cuando no se pueden encontrar raíces para la función cuadrática.
- test_no_es_cuadratica
Se fija qué es lo que pasa cuando los coeficientes no son de una función cuadrática.
- test_integridad
Para un grupo de raíces intenta encontrar los coeficientes, y para esos coeficientes encontrar las raíces del polinomio. Las dos raíces que se usaron originalmente tienen que ser las mismas que el resultado final.
- test_integridad_falla
Se fija para que caso el test de integridad falla. En este caso usamos funciones cuyo valor a no es 1, y por mas de que las raíces sean las mismas, no es la misma función cuadrática.
Al final del test se pone el siguiente código:
if __name__ == '__main__': unittest.main()
Lo que hace el mismo es que si corremos python nombre_archivo.py entonces entra en ese if. Lo que se encarga de hacer el mismo es de correr todos los tests que están escritos en ese archivo.
Supongamos que estamos en la carpeta en donde estan los archivos descargados:
pymag@localhost:/home/pymag$ ls polinomio.py test_polinomio.py test_polinomio_falla.py
Por lo tanto, vamos a correr los tests que pasan:
pymag@localhost:/home/pymag$ python test_polinomio.py ...... ---------------------------------------------------------------------- Ran 6 tests in 0.006s OK
El resultado indica que se corrieron los 6 tests de la clase y que todos estuvieron bien. Ahora veremos que es lo que pasa cuando falla un test. Para esto lo que hice fue cambiar uno de los tests para que la segunda raíz sea menor que la primera. Cambie test_integridad para una de las raíces esperadas sea (2, 1), pero en verdad debería de ser (1, 2) ya que la primer raíz es menor que la segunda. Este cambio fue hecho en el archivo test_polinomio_falla.py. Por lo tanto, si ejecutamos los tests que fallan aparece:
pymag@localhost:/home/pymag$ python test_polinomio_falla.py
..F...
======================================================================
FAIL: test_integridad (test_polinomio.TestPolinomio)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/media/sdb5/svns/tzulberti/pymag/testing_01/source/test_polinomio.py",
line 48, in test_integridad
self.assertEquals(raices, raices_esperadas)
AssertionError: (1.0, 2.0) != (2, 1)
----------------------------------------------------------------------
Ran 6 tests in 0.006s
FAILED (failures=1)
Tal como el mensaje indica se volvieron a correr los 6 tests de los cuales falló 1, y muestra el error y la línea del mismo.