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()
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])
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
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.
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)
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:
Si lo que queremos es probar que el código lanza excepciones como se espera que haga podemos usar:
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
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()
Con todo esto ya estás preparado para equiparte con un buen conjunto de tests para guiarte a través de tu desarrollo.