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

No hay comentarios:

Publicar un comentario