21 febrero 2014

Probando tu código con unittest

Cuando programas aplicaciones pequeñas, el ciclo de desarrollos suele ser "escribir código->probar manualmente->escribir código->probar manualmente". El problema con este método es que conforme el proyecto crece en complejidad hay que emplear más y más tiempo en probarlo para asegurar que los últimos cambios no han tenido efectos colaterales en parte alguna de la aplicación. Es habitual olvidarse de probar cosas o pensar que siguen bien después de los últimos cambios, sólo para encontrarse con que una parte de la aplicación que probaste al principio y que funcionaba ahora no lo hace por culpa de un cambio que hiciste hace varios ciclos sin que te dieses cuenta del problema.

En realidad, las pruebas manuales suelen ser ineficientes y es fácil cometer errores con ellas, por eso cuando el proyecto se vuelva complejo, deberíamos automatizar nuestras pruebas. Una de las librerías más usadas para automatizar pruebas es unittest, presente en python desde su versión 2.1. Esta librería te permite preparar pequeños scripts para probar el comportamiento de los componentes de nuestro programa.

Si quieres usar unitest para probar tu código yo seguiría la metodología TDD. Esta metodología te test cases), que son scripts para probar una sección concreta de tu código. Estos test cases son muy útiles para forzarnos a definir los interfaces y el comportamiento deseados de las funciones a desarrollar. Una vez definidos las pruebas, y sólo entonces, se puede escribir el código manteniendo en mente que el objetivo es superar las pruebas. Cuando el código está finalizado es cuando se le somete a las pruebas, si las supera se puede acometer el siguiente ciclo de desarrollo (definir pruebas, escribir código, ejecutar código), si el código fracasa en las pruebas se resuelve el fallo y se vuelve a intentar hasta que supere el test.
hace escribir primero tus paquetes de prueba (en adelante

Se que a primera vista este método parece innecesariamente complejo. Lo que un desarrollador quiere es escribir código para su aplicación no emplear tiempo con el código de las pruebas. Esa es la razón por la que muchos desarrolladores odian esta técnica. Pero tras darle una oportunidad lo habitual es acabar adorando TDD por la confianza que aporta sobre el código. Una vez que tu test está definido lo único que necesitas es ejecutarlos tras un cambio de código para asegurar que el cambio no haya introducido ningún error en un remoto lugar de tu código. Además si trabajas en un proyecto con colaboradores, las pruebas son una manera estupenda de asegurar que una aporte de código realmente funciona.

Podemos probar lo que queramos de nuestra aplicación: sus módulos, sus funciones y clase, su GUI, etc. Por ejemplo, si estuviéramos probando una aplicación web podríamos combinar unittest con Selenium para simular un navegador usando la web, mientras que si estuviéramos probando un GUI basado en QT deberíamos usar QTest.

Cuando trabajemos con unittest deberíamos tener claro que nuestro instrumento principal serán los test cases. Un test case debería enfocarse a probar un único escenario. In python un test case es una clase que hereda de unittest.TestCase. Los test case tienen esta estructura general:

    import unittest

    class TestPartOfCode(unittest.TestCase):

 def setUp(self):
     <test initialization>

 def test_something(self):
     <code to test something>
     self.assert... # All the asserts you need to be sure correctness condition is found.
     
 def test_something_else(self):
     <code to test something>
     self.assert... # All the asserts you need to be sure correctness condition is found.

 def tearDown(self):
     <test shutdown>


Puedes hacer que que un test case se ejecute por si mismo añadiendo al final:

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

Si no se hace la anterior no queda otra más que llamar al test case externamente.

Cuando se ejecuta unittest, este busca las subclasses de unittest.Testcase y ejecuta todos los métodos de esas subclasses cuyo nombre empiece por "test_". Hay métodos especiales como setUp() y tearDown(): setUp() se ejecuta antes de cada prueba para preparar su contexto, mientras que tearDown() se ejecuta después para desmontar dicho contexto.

Normalmente no se tiene un único test case sino varios para probar cada característica de tu programa. Hay muchas aproximaciones, en aplicaciones con GUI se puede tener un test case por ventana en los que sus métodos chequearían cada control de esa ventana. Un truco válido sería agrupar en un test case todas las pruebas que compartan la misma lógica de setUp() y tearDown().

Por eso lo normal es tener muchos test cases, por lo que es más eficiente cargarlo externamiento y ejecutarlos de manera automatizada. Creo que es una buena práctica mantener los test en una carpeta diferente del código, por ejemplo en una carpeta "tests" que se encuentre dentro de la de proyecto. Suelo incluir un fichero vacío "__init__.py" dentro de ese directorio para convertirlo en un paquete de python. Supongamos que ese es nuestro caso, para cargar y ejecutar los test cases necesitamos un script para descubrirlos (suelo llamarlo "run_tests.py"):

    import unittest

    def run_functional_tests(pattern=None):
       print("Running tests...")
       if pattern is None:
           tests = unittest.defaultTestLoader.discover("tests")
       else:
           pattern_with_globs = "%s" % (pattern,)
           tests = unittest.defaultTestLoader.discover("tests", pattern=pattern_with_globs)
       runner = unittest.TextTestRunner()
       runner.run(tests)

    if __name__ == "__main__":
       if len(sys.argv) == 1:
       run_functional_tests()
    else:
       run_functional_tests(pattern=sys.argv[1])

Este script se sitúa habitualmente en la carpeta raíz del proyecto, al mismo nivel que la carpeta de tests. Si se le llama sin argumentos se limita a entrar en el directorio de tests y cargar todos los tests cases que descubra en fichero python cuyo nombre comience por la cadena "tests". Si se ejecuta el script con un argumento, lo usa como una especie de filtro, para cargar sólo aquellos test cases localizados en fichero de python cuyo nombre comiencen con la palabra pasada como argumento. De esta manera se puede ejecutar sólo un subconjunto de los tests cases.

Con unittest se pueden probar aplicaciones web y de consola, e incluso las de GUI. Las últimas son más difíciles de probar porque acceder a los widgets de GUI depende de cada implementación de los mismos y de las herramientas que facilite cada implementación para ello. Por ejemplo, los creadores de QT ofrecen el módulo QTests para ser usado con Unittest. Este módulo permite simular pulsaciones tanto de teclado como de ratón.

Por eso podríamos utilizar tanto una aplicación web como de consola para explicar cómo usar unittest, pero como los tutoriales de cómo usar QTest con pyQT son bastante escasos voy a contribuir haciendo uno aquí., esa es la razón por la que en este artículo voy a desarrollar tests cases para probar una aplicación GUI de pyQT. Como base del ejemplo vamos a usar el código fuente de pyQTMake. Lo mejor es que te descargues todo el código fuente  usando Mercurial tal y como expliqué en uno de mis artículos anteriores. Para clonar el código fuente y situarlo en la versión que vamos a usar hay que teclear lo siguiente en la consola de Ubuntu:
dante@Camelot:~$ hg clone https://borjalopezm@bitbucket.org/borjalopezm/pyqtmake/ example requesting all changes adding changesets adding manifests adding file changes added 10 changesets with 120 changes to 74 files updating to branch default 67 files updated, 0 files merged, 0 files removed, 0 files unresolved dante@Camelot:~/Desarrollos$ cd example dante@Camelot:~/Desarrollos/example$ hg update 9 0 files updated, 0 files merged, 0 files removed, 0 files unresolved dante@Camelot:~/Desarrollos/example$

Ok, ahora que ya tenemos el código fuente vamos a analizar el código del fichero pyqtmake.py. Fíjate en la función "connections":

def connections(MainWin):
    ## TODO: This signals are connected using old way. I must change it to new way
    MainWin.connect(MainWin.ui.action_About,  SIGNAL("triggered()"),  MainWin.onAboutAction)
    MainWin.connect(MainWin.ui.actionLanguajes,  SIGNAL("triggered()"),  MainWin.onLanguagesAction)
    MainWin.connect(MainWin.ui.actionOpen,  SIGNAL("triggered()"),  MainWin.onOpenAction)
    MainWin.connect(MainWin.ui.actionPaths_to_compilers,  SIGNAL("triggered()"),  MainWin.onPathsToCompilersAction)
    MainWin.connect(MainWin.ui.actionPyQTmake_Help,  SIGNAL("triggered()"),  MainWin.onHelpAction)
    MainWin.connect(MainWin.ui.actionQuit,  SIGNAL("triggered()"),  MainWin.close)
    MainWin.connect(MainWin.ui.actionSave,  SIGNAL("triggered()"),  MainWin.onSaveAction)
    MainWin.connect(MainWin.ui.actionSave_as,  SIGNAL("triggered()"),  MainWin.onSaveAsAction)
    return MainWin

Parece que este segmento de código se puede mejorar para que use el nuevo estilo de conexión de señales de pyQT. La cuestión es que no queremos que se rompa nada por nuestras modificaciones por lo que vamos a desarrollar test cases para asegurar que nuestro nuevo código se comporta como el antiguo.

Estas conexiones permiten a MainWin reaccionar a los clicks de ratón en determinados widgets con la correspondiente apertura de ventanas. Nuestro test debería comprobar que estas ventanas se siguen abriendo correctamente tras  los cambios en nuestro código.

El código completo para estos tests se encuentra en el fichero test_main_window.py dentro de la carpeta tests del código fuente de pyQTMake.

Para probar la aplicación nuestro test debe arrancarla primero. Unittest tiene dos métodos para preparar el contexto de nuestras pruebas: setUp() y setUpClass(). El primer método, setUp() se ejecuta antes de cada prueba, mientras que setUpClass() se ejecuta uno única vez cuando se crea el test case al completo.

En este test case en concreto vamos a usar setUp() para crear la aplicación cada vez que queramos probar uno de sus componentes:

    def setUp(self):
        # Initialization
        self.app, self.configuration = run_tests.init_application()
        # Main Window creation.
        self.MainWin = MainWindow()
        # SLOTS
        self.MainWin = pyqtmake.connections(self.MainWin)
        #EXECUTION
        self.MainWin.show()
        QTest.qWaitForWindowShown(self.MainWin)
        # self.app.exec_() # Don't call exec or your qtest commands won't reach
                           # widgets.

QTest.qWaitForWindowShow() para la ejecución hasta que la ventana a la que se espera esté realmente activa. Si no se usa podría ocurrir que nuestro programa avanzase y comenzase a llamar a elementos de una ventana que no está aún activa.

Nuestro primer test va a ser realmente simple:

    def test_on_about_action(self):
        """Push "About" menu option to check if correct window opened."""
        QTest.keyClick(self.MainWin, "h", Qt.AltModifier)
        QTest.keyClick(self.MainWin.ui.menu_Help, 'a', Qt.AltModifier)
        QTest.qWaitForWindowShown(self.MainWin.About_Window)
        self.assertIsInstance(self.MainWin.About_Window, AboutWindow)

QTest.KeyClick() manda la pulsación de una tecla al widget que hayamos señalado. Puede usarse con modificadores de teclado, en este caso Qt.AltModifiers significa que estamos simulando la que se presionando una tecla al mismo tiempo que la tecla Alt. ¿Por qué usamos teclas en este caso? ¿No podemos utilizar QTest para simular una pulsación de ratón?, sí se puede, el problema es que QTest.mouseClick() sólo puede interactuar con widgets mientras que los items de un menú de QT son en realidad actions, por lo que la única manera de llamarlos que he encontrado es usar los atajos de teclado que tengan configurado esos elementos de menú.

El punto clave en una prueba con unittest son las llamadas de tipo "assert...". Esta familia de funciones comprueban que se cumple una condición específica, si es así la prueba se declara exitosa y si no fallida. Hay un tercer estado de salida para una prueba: errónea, pero esto sólo significa que nuestro test no funcionó como esperábamos, fallando en algún punto.

En nuestro ejemplo, self.assertInstance() comprueba, como sugiere su nombre, que el atributo About_Window es en realidad una instancia de AboutWindow. Si examinamos el slot que estamos probando, MainWin.onAboutAction(), esto ocurre solamente cuando se abre correctamente una ventana, que es precisamente lo que estamos probando.

Unittest ofrece una larga lista de variantes de assert:



Sin embargo, hay que fijarse que sólo un pequeño subconjunto de ellos están incluidos en versiones antiguas de Python.

Si lo que queremos es probar que el código lanza excepciones como se espera que haga podemos usar:


En este punto, si ejecutamos "run_test.py" el test será exitoso. El TDD dice que debes desarrollar pruebas que fallen en un principio, pero aquí no estamos desarrollando código desde cero sino modificando código que ya funciona, por lo que no está mal que el test sea exitoso para asegurarnos de que es correcto.

Para empezar a modificar el código e incluir el "nuevo estilo" de connections deberíamos comentar todas las conexiones que queremos cambiar. Para simplificar nuestro ejemplo vamos a modificar sólo la primera conexión:

def connections(MainWin):
    ## TODO: This signals are connected using old way. I must change it to new way
    #MainWin.connect(MainWin.ui.action_About,  SIGNAL("triggered()"),  MainWin.onAboutAction)
    MainWin.connect(MainWin.ui.actionLanguajes,  SIGNAL("triggered()"),  MainWin.onLanguagesAction)
    MainWin.connect(MainWin.ui.actionOpen,  SIGNAL("triggered()"),  MainWin.onOpenAction)
    MainWin.connect(MainWin.ui.actionPaths_to_compilers,  SIGNAL("triggered()"),  MainWin.onPathsToCompilersAction)
    MainWin.connect(MainWin.ui.actionPyQTmake_Help,  SIGNAL("triggered()"),  MainWin.onHelpAction)
    MainWin.connect(MainWin.ui.actionQuit,  SIGNAL("triggered()"),  MainWin.close)
    MainWin.connect(MainWin.ui.actionSave,  SIGNAL("triggered()"),  MainWin.onSaveAction)
    MainWin.connect(MainWin.ui.actionSave_as,  SIGNAL("triggered()"),  MainWin.onSaveAsAction)
    return MainWin


Aquí es donde ejecutar "run_tests.py" falla, por lo que no situamos en el punto correcto de TDD. Partiendo de ahí tenemos que desarrollar código que haga que nuestra prueba sea exitosa de nuevo.

def connections(MainWin):
    ## TODO: This signals are connected using old way. I must change it to new way
    #MainWin.connect(MainWin.ui.action_About,  SIGNAL("triggered()"),  MainWin.onAboutAction)
    MainWin.ui.action_About.triggered.connect(MainWin.onAboutAction)
    MainWin.connect(MainWin.ui.actionLanguajes,  SIGNAL("triggered()"),  MainWin.onLanguagesAction)
    MainWin.connect(MainWin.ui.actionOpen,  SIGNAL("triggered()"),  MainWin.onOpenAction)
    MainWin.connect(MainWin.ui.actionPaths_to_compilers,  SIGNAL("triggered()"),  MainWin.onPathsToCompilersAction)
    MainWin.connect(MainWin.ui.actionPyQTmake_Help,  SIGNAL("triggered()"),  MainWin.onHelpAction)
    MainWin.connect(MainWin.ui.actionQuit,  SIGNAL("triggered()"),  MainWin.close)
    MainWin.connect(MainWin.ui.actionSave,  SIGNAL("triggered()"),  MainWin.onSaveAction)
    MainWin.connect(MainWin.ui.actionSave_as,  SIGNAL("triggered()"),  MainWin.onSaveAsAction)
    return MainWin

Con esta modificación nuestro test volverá a ser exitoso de nuevo, lo que es señal de que nuestro código funciona. Puedes verificarlo manualmente si lo deseas.

Una vez que has finalizado tus tests lo normal es que quieras cerrar las ventanas de prueba, para ello el método tearDown() de tu test case debería ser:

def tearDown(self):
    #EXIT
    if hasattr(self.MainWin, "About_Window"):
        self.MainWin.About_Window.close()
    self.MainWin.close()
    self.app.exit()

Para probar más aspectos de tu código sólo tienes añadir más métodos de tests en tus subclases de unittest.TestCase.

Con todo esto ya estás preparado para equiparte con un buen conjunto de tests para guiarte a través de tu desarrollo.


1 comentario:

Reinier Hernández Ávila dijo...

Esta muy bueno este post pero tengo varias dudas si run_tests lo escribes tu todo el codigo esta ahi, donde esta el metodo init_application() que llamas en test_main_window.py? y si run_tests es el encargado de ejecutar todas los test entonces no se realiza una recursion infinita al estarse llamando mutuamente?