14 de junio de 2010

To override or not to override?... ya no es un dilema

Ok, continuando con las penurias de nuestro último proyecto en Python y sobre cómo el uso de TDD puede hacerlas más llevaderas, hoy quiero mostrar una técnica que me resulta muy útil.

Normalmente no me gustan las entradas del tipo "receta de cocina", creo que ya hay varios blogs dedicados a ese tipo de tips, sin embargo, como "blog" viene de "log" que a su vez es "bitácora", pues bueno, usaré este espacio como dicha bitácora para registrar lo que voy encontrando: lo bueno, lo malo y lo feo (que es mucho), para que después no se me olvide y, si de paso a alguien más le resulta útil, pues qué mejor.

Bueno, entrando en materia en el post anterior les platicaba yo cómo comenzar a agregar pruebas de caracterización en código legacy. Mientras me encontraba haciendo dichas pruebas, me topé con que el código en cuestión genera un archivo de .xls, mismo que contiene una página de resumen y tres páginas de detalle, donde se guardan los registros que se procesaron correctamente, los que no se pudieron procesar porque falta información y los que tuvieron errores de cálculo (imaginense nomás).

Bueno, como PyDev en realidad hace un trabajo bastante decente con el refactoring "Extract Method", me colgué de allí para encontrar cúmulos de lineas relacionadas e irlas extrayendo en rutinas. Al final terminé con una pequeña clase que escribe la información en las hojas de excel según se va procesando. Ahora lo que necesitaba era poder "detectar" esa información para poder verificar que el proceso fuera correcto.

Ya anteriormente mencioné lo facil que es crear una sub-clase en Python para propósitos de prueba, con el fin de poder redefinir ciertos métodos. También mencionamos que para los casos en los que solo necesitamos redefinir un método, existen otras alternativas.

En Python, es posible alterar la estructura de un objeto en tiempo de ejecución, por lo que facilmente podemos sustituir el método que necesitamos por otra cosa distinta. Veamos un ejemplo un poco más concreto:

Como ya tenemos una clase GeneradorTesting, me cuelgo de ahí para crear un "Factory Method" que cree nuestro libro de excel: Utilizando "Extract Method" con PyDev en la clase GeneradorArchivosobtengo:

class GeneradorArchivos( object ):
 def _creaWorkbook( self, nombre ):
   return MyWorkbook( nombre )

MyWorkbook es la clase que cree como interfaz con el código de creación del archivo .xls. Ahora, planeo que cada vez que se agregue un registro a una hoja de mi libro, agregar dicho registro a una lista en donde poder verificarlo posteriormente. Tambien quiero tener una lista separada para cada hoja. Para esto, creo un diccionario vacio en mi sub-clase de pruebas:

class GeneradorArchivosPrueba( GeneradorArchivos ):
 def __init__( self, frame, batch_id ):
   super( GeneradorArchivosPrueba, self ).__init__( frame, batch_id )
   self.registros = {}

Bien, ahora tengo un problema: la creación de hojas individuales es una responsabilidad de MyWorkbook, no de GeneradorArchivos (que es en parte la razón por la cual creé esa clase en primer lugar). Por lo tanto, no puedo redefinir un método en GeneradorArchivosPrueba para hacer esto.

Podría crear una sub-clase de MyWorksheet llamada MyTestingWorksheet y despues redefinir el método _creaWorkbook para que regrese una sub-clase de MyWorkbook, que a su vez devuelva instancias de MyTestingWorksheet, pero creo que resulta demasiado lio para lo que yo necesito. Parece ser que estoy atorado.

Bueno, en realidad no. Gracias a algunas características muy útiles de Python, como el que pueda redefinir ciertas cosas al vuelo, puedo crear instancias "personalizadas" de las clases de "producción" y usarlas en mis pruebas. Veamos:

Lo primero que necesito es que la creación de los objetos hoja resida en su própio método:

class MyWorksheet( object ):
 ...
 def _creaHoja( self, titulo ):
   hoja = self._libro.add_worksheet( titulo )
   ...
   return hoja

Como siempre, corremos las pruebas que ya teníamos para verificar que lo demás siga funcionando. Después, redefinimos nuestro Factory Method para objetos libro:

class GeneradorArchivosPrueba( GeneradorArchivos ):
 ...
 def _creaWorkbook( self, nombre ):
   workbook = GeneradorArchivos._creaWorkbook( nombre )
   return workbook

Ahora, después de volver a correr las pruebas, ya que puedo controlar la creación de libros. Personalicemos dichos objetos para nuestras pruebas:

class GeneradorArchivosPrueba( GeneradorArchivos ):
 ...
 def _creaWorkbook( self, nombre ):
   workbook = GeneradorArchivos._creaWorkbook( nombre )
   old_crea_hoja = workbook._creaHoja

   def nuevaCreaHoja( *args, **kwds ):
     return old_crea_hoja( *args, **kwds )

   workbook._creaHoja = nuevaCreaHoja
   return workbook

Probamos y vemos que las cosas parecen seguir funcionando hasta aquí... ¡¡fiiuuu!!

En este punto, aún no he cambiado el comportamiento del código, sin embargo, ya estoy en posibilidad de hacerlo.

Antes de continuar, me gustaría llamar su atención a la variable old_crea_hoja. Aquí estamos guardando una referencia a la definición original del método creaHoja. Sin embargo, si miramos con cuidado, vemos que es una variable local... ¡¡se pierde al momento de salir de _creaWorkbook!! Bueno, no en realidad.

Lo que sucede aquí es que Python está creando un closure para la función nuevaCreaHoja. En términos simples, lo que esto significa es que el contexto de ejecución de nuevaCreaHoja es guardado y después recuperado cuando se llama a la función, por lo que las variables locales de la función superior a las cuales nuevaCreaHoja tiene acceso, conservan su valor cada vez que esta se ejecuta. Esto incluye a old_crea_hoja por supuesto, y esto es lo que nos permite llamar al método original.

Bueno, ahora necesitamos agregar elementos a nuestro diccionario registros cada vez que se agregue una hoja:

class GeneradorArchivosPrueba( GeneradorArchivos ):
 ...
 def _creaWorkbook( self, nombre ):
   workbook = GeneradorArchivos._creaWorkbook( nombre )
   old_crea_hoja = workbook._creaHoja

   def nuevaCreaHoja( *args, **kwds ):
     hoja = old_crea_hoja( *args, **kwds )
     self.registros[hoja.titulo] = []
     return hoja

   workbook._creaHoja = nuevaCreaHoja
   return workbook

¡Perfecto! Ahora (después de probar, claro está), podemos aplicar exactamente la misma técnica para lograr que al agregar registros a cada hoja se registren en la lista que les corresponde:

class GeneradorArchivosPrueba( GeneradorArchivos ):
 ...
 def _creaWorkbook( self, nombre ):
   workbook = GeneradorArchivos._creaWorkbook( nombre )
   old_crea_hoja = workbook._creaHoja
     def nuevaCreaHoja( *args, **kwds ):
       hoja = old_crea_hoja( *args, **kwds )
       self.registros[hoja.titulo] = []
       old_agrega_registro = hoja.agregaRegistro
       def nuevaAgregaRegistro( *args ):
           self.registros[hoja.titulo].append( args )
           return old_agrega_registro( *args )
       hoja.agregaRegistro = nuevaAgregaRegistro
       return hoja
   workbook._creaHoja = nuevaCreaHoja
   return workbook

¡Puf, casi terminamos!. Ahora todo lo que nos hace falta es verificar la información que estamos escribiendo a la hoja de cálculo en nuestra suite de pruebas. Para ello, genero la hoja correspondiente con la versión sin modificar de la aplicación y a partir de esta obtengo mi información de referencia:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
 def checaNegativos( self, *esperados ):
   for esperado in esperados:
     assert esperado in self.gen.registros["Negativos"]

 def testGenera9981( self ):
   """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
   self.gen.genera(9981)
   assert self.gen.registros_totales == 82
   assert self.gen.registros_negativos == 2
   self.checaNegativos( ('9981',1834,'GOMEZ, PACO','',516.34),
                        ('9981',2359,'DOE, JOHN','',1677.42') )

Saludos y hasta la próxima

8 de junio de 2010

Porque TDD no es opcional

Como probablemente sabrán, Alcides y un servidor hemos estado los últimos meses dándole mantenimiento a una aplicación legacy en python. Bueno, creo que es una buena oportunidad de comentar algunos de los problemas más duros con los que nos hemos topado y, como a pesar de ser problemas comunes en la mayoría de los proyectos, al estar en un lenguaje dinámico como python estos se exacerban.

Creo que el problema principal es el excesivo nivel de acoplamiento presente en la totalidad de la aplicación. El siguiente ejemplo fue extraído del código de un proceso por lotes que genera una serie de archivos TXT para realizar transferencias electrónicas masivas:

  numero_cuenta, nombre_beneficiario = self.frame.obtenDatosBanco( str( numero_empleado ) )

nota: el código ha sido modificado para no comprometer la confidencialidad del cliente y para no ofender los castos ojos del amable lector con detalles de formato, nomenclatura, estilo, etc., presentes en el original)

En el ejemplo anterior, self.frame se refiere a una referencia que guarda el objeto a la forma que lo invocó. Desde ahí uno se puede preguntar ¿Porqué un objeto que ejecuta un proceso batch requiere una referencia a la interfaz de usuario?. Más aún, ¿Porqué ese elemento de la GUI implementa un método que claramente pertenece al dominio del negocio (obtenDatosBanco)?

Al analizar el objeto en cuestión, nos dimos cuenta que las llamadas a self.frame.* se encuentran por todas partes en el código. Prácticamente no había manera de saber qué podíamos afectar si cambiábamos el batch o la ventana.

Una complicación adicional es que python, al ser un lenguaje interpretado, no genera errores de compilación u otra clase de advertencia si, por algún descuido, modificamos adversamente las dependencias del código mientras lo modificamos. Si bien es cierto que existe una herramienta (¡Muy útil!) que nos asiste en el proceso, llamada pylint, lo cierto es que aún así es necesario leer y re-leer el código varias veces tomando notas intensivamente y tratando de descubrir todos los posibles efectos que pueda tener una modificación.

Ahora, si bien en cualquier pieza de código el proceso anterior resulta extenuante, cuando se trata de un mega-archivo fuente de al rededor de 40,000 líneas de código y con un nivel extremadamente alto de acoplamiento, es poco decir que la tarea se vuelve insufrible.

Existe una técnica que nos ha dado resultado, llamada "Pruebas de Caracterización", documentada por Michael Feathers en su libro "Working Effectively with Legacy Code". Básicamente, lo que hacemos es crear un arnés de pruebas alrededor del código que deseamos modificar. La diferencia con las pruebas unitarias tradicionales es que en este caso, las pruebas no pueden ser escritas antes que el código (obvio), por lo tanto no pueden ser una especificación de lo que el código debe hacer. En cambio, las pruebas documentan lo que el código hace actualmente.

Esto significa que es probable que el código actual contenga bugs. Sin embargo, nuestro arnés de pruebas puede o no detectar dichos bugs, ya que no es esa su función. En cambio, su función es la de hacer visible el comportamiento actual del código, de tal suerte que podamos hacer las modificaciones necesarias y aún estar razonablemente seguros de que el resto aún funciona. Lo que es más, el tener nuestras Pruebas de Caracterización en su lugar, ¡No excluye el escribir pruebas unitarias para el código nuevo que se deba escribir!

Usualmente la prueba más difícil es la primera: crear una instancia del objeto en cuestión dentro de nuestro arnés. En este caso, es algo así:

from pago_electronico import GeneradorArchivos

class TestGeneradorArchivos( TestCase ):
    def setUp( self ):
        self.gen = GeneradorArchivos( frame=None, batch_id=None )
    def testCrea( self ):
        """Verifica que sea capaz de crear una instancia en el ambiente de pruebas"""
        self.assertIsNotNone ( self.gen )

Inicialmente, la prueba falla, ya que se requiere pasar una instancia del frame antes mencionado para inicializar la instancia. Sin embargo, no queremos crear una instancia de ese frame para esta prueba ya que por una parte, es una clase monstruo de arriba de 11 mil lineas de código que a su vez incluye toneladas de dependencias con ella. Por otra parte, requeriría la inicialización del sistema wxPython para el manejo de interfaz de usuario, lo que nos implicaría inicializar una instancia de wx.App... bueno, creo que ustedes captan la idea.

En su lugar, lo que queremos hacer es sustituir esa referencia a frame con otra cosa que se más fácil de crear y que podamos controlar.

Una de las propiedades de frame que utiliza GeneradorArchivos es connection, que regresa una referencia a la conexión con la base de datos. Anteriormente había desarrollado una sub-clase de TestCase para esos caso (con el plus de que esta última se asegura de abrir y cerrar la conexión cuando sea necesario, evitando la fuga de recursos). Así, lo primero que hago es cambiar TestGeneradorArchivos para ser un descendiente de mi clase:

from pago_electronico import GeneradorArchivos
from tests.custom import ConnectedDatabaseTest

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=None )

La llamada al método setUp de la superclase es necesaria para asegurarnos que la inicialización de la conexión se haga correctamente. Noten que también se pasa self como el parámetro frame.

Ahora bien, para todos los que leen esto que están acostumbrados a C#, Java, etc., dirán "eso es un error". Claramente, en esos lenguajes no podría hacer esto, ya que TestGeneradorArchivos no es de la clase adecuada que espera el constructor de GeneradorArchivos. Sin embargo, recordemos que:

Python es un lenguaje dinámico al que no le importa mucho la clase particular a la que pertenecen nuestros objetos, sino su tipo.

¿Su tipo?, ¿Que, no es lo mismo? er... no.

  • La clase de un objeto es el molde a partir del cual se crean instancias del mismo. Todo lo que tiene la clase terminará siendo parte de los objetos que a partir de ella se creen.

  • El tipo de un objeto esta definido por el conjunto de mensajes a los que dicho objeto puede responder.

En los lenguajes estáticos como Visual Basic, Java, C#, etc., la clase de un objeto define también su tipo, ya que también define los mensajes a los que ese objeto responderá. En dichos lenguajes y otros similares, clase y tipo son conceptos inseparables (o casi, si consideramos el uso de interfaces).

Por otro lado, en python podemos pasar una referencia a cualquier objeto como parámetro de una función y esta no se inmutará. Lo único que importa es que si la función llama a foo.bar(), el objeto que pasemos en el parámetro foo responda al mensaje bar.

De vuelta a nuestro ejemplo, vemos que la primera prueba pasa. Hora de escribir una segunda prueba. En este caso, las pruebas de caracterización son más como mini-pruebas de integración, ya que describen escenarios en lugar de solo verificar el resultado de una sola operación. Veamos:

    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()

Esta prueba falla inmediatamente, ya que al no especificar un batch_id, la clase lanza una caja de dialogo para obtener dicho ID del usuario. Sin embargo, como no hemos incluido wxPython en nuestra prueba, levanta una excepción.

Esto se arregla fácilmente pasando la información requerida en el constructor:

    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )

Claro, primero nos aseguramos que el batch 1234 exista y cumpla con los requisitos que necesitamos para nuestra prueba.

Ahora la prueba falla, pues está intentando acceder al método GetCurrentDate del frame. De nuevo, ¿que tiene que hacer un método como GetCurrentDate ahí, no lo sé. Sin embargo, le daremos lo que requiere. Como estamos pasando una referencia a self en el constructor, crearemos un método stub en TestGeneradorArchivos:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def GetCurrentDate( self ):
        return ( 1, 3, 2010 )

Y así sucesivamente.

Otro problema con el que nos topamos es que repetidamente GeneradorArchivos intenta crear cajas de diálogo para mandar mensajes al usuario o pedir información del mismo. Normalmente mientras estamos haciendo pruebas de caracterización, no hacemos refactorings. Esto es, porque para hacerlos correctamente, ¡Necesitamos pruebas que cubran el código que queremos refactorizar! Es como el problema de la gallina y el huevo.

Sin embargo, en este caso los refactorings son extremadamente simples y podemos realizarlos con un nivel aceptable de seguridad:

class GeneradorArchivos( object ):
    def obtenPrimerSecuencial( self ):
        return wx.GetTextFromUser( caption=u"Número Secuencial", message=u"Digite el Número Secuencial a usar", default_value="1" )

    def genera(self):
        ...
        inicial = self.obtenPrimerSecuencial()
        ...

A continuación, podemos aplicar la técnica estándar de sustitución de métodos en una prueba unitaria:

class GeneradorArchivosPrueba( GeneradorArchivos ):
    def __init__( self, frame, batch_id ):
        super( GeneradorArchivosPrueba, self ).__init__( frame, batch_id )
    def obtenPrimerSecuencial( self ):
        return "1"

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivosPrueba( frame=self, batch_id=1234 )

En este caso, hay otros métodos que queremos redefinir en GeneradorArchivos, por lo que conservaremos la clase GeneradorArchivosPruebas. Sin embargo, si solo necesitáramos redefinir obtenPrimerSecuencial o un par de métodos simples más, existe una forma más sencilla de hacerlo:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        def regresaUno(): return "1"
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )
        self.gen.GeneradorArchivosPruebas = regresaUno

o incluso:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )
        self.gen.GeneradorArchivosPruebas = ( lambda "1" )

Ahora que ya logramos ejecutar correctamente a GeneradorArchivos en un arnés de prueba, ¿qué probamos? Uno de los métodos que extrajimos al eliminar el uso de diálogos fue muestraResumen, que simplemente muestra un resumen del trabajo realizado por el batch:

class GeneradorArchivos( object ):
    ....
    def muestraResumen( self, registros_totales, registros_negativos ):
        Mensajes.MuestraAviso( FMT_RESUMEN % ( registros_totales, registros_negativos ), parent=self.frame )

bueno, podemos utilizar este método para verificar estos resultados:

class GeneradorArchivosPrueba( GeneradorArchivos ):
    registros_totales = 0
    registros_negativos = 0
    def muestraResumen( self, registros_totales, registros_negativos ):
        self.registros_totales = registros_totales
        self.registros_negativos = registros_negativos

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()
        assert self.gen.registros_totales == -1
        assert self.gen.registros_negativos == -1

Esto nos da un primer acercamiento a las pruebas de caracterización. Al correr el proceso batch en una base de datos de prueba contra el batch número 1234, descubrimos que procesa un total de 36 registros, de los cuales 4 son negativos. Como nuestra prueba debe describir lo que el programa hace actualmente, incluimos estos resultados en nuestra prueba:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()
        assert self.gen.registros_totales == 36
        assert self.gen.registros_negativos == 4

Este proceso continúa hasta que nos sentimos satisfechos con nuestras pruebas de caracterización, rompiendo dependencias y detectando resultados. A continuación pudimos hacer las modificaciones que se nos pidieron y de paso pudimos darle una muy necesaria limpieza al código—no tanto como hubiésemos querido, pero es un comienzo.

Saludos y espero que haya sido de su interés.